From 106e829be6bd5d823a891f4787aed97d4147e938 Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Thu, 10 Sep 2026 16:34:55 +0300 Subject: [PATCH 01/46] docs: refresh ARCHITECTURE-PROXY.md --- docs/ARCHITECTURE-PROXY.md | 760 ++++++++++++++++++++++++------------- 1 file changed, 501 insertions(+), 259 deletions(-) diff --git a/docs/ARCHITECTURE-PROXY.md b/docs/ARCHITECTURE-PROXY.md index a7242b0f5..caa669f80 100644 --- a/docs/ARCHITECTURE-PROXY.md +++ b/docs/ARCHITECTURE-PROXY.md @@ -1,7 +1,7 @@ # CodeMie Proxy Architecture -**Version**: 1.0 -**Date**: 2025-12-11 +**Version**: 2.0 +**Date**: 2026-09-10 **Status**: Production --- @@ -27,22 +27,25 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between AI coding agents and their target API endpoints. It enables: -- **SSO Authentication**: Automatic cookie injection for enterprise SSO +- **SSO / JWT Authentication**: Automatic cookie or bearer-token injection for enterprise auth +- **Local Gateway Auth**: Static bearer key validation for daemon-mode clients (e.g. Claude Desktop, VS Code BYOK) - **MCP Authorization**: OAuth proxy for remote MCP servers with SSRF protection +- **Request Normalization & Sanitization**: Per-agent body fixes (Claude thinking params, Kimi token caps, Codex model mapping, encrypted reasoning-state replay/retry, VS Code user-id constraints) - **Header Management**: CodeMie-specific header injection for traceability - **Observability**: Detailed logging and metrics collection -- **Metrics Sync**: Background sync of session metrics to CodeMie API +- **Session Sync**: Background sync of session metrics _and_ conversations to the CodeMie API - **Desktop Telemetry**: Local Claude Desktop 3P transcript discovery and conversation sync when daemon mode is enabled - **VS Code BYOK**: Profile-configured OpenAI-compatible custom endpoints with transparent forwarding +- **Self-Healing Daemon**: Background watcher that detects a dead/unhealthy proxy and restarts it in-process on the same pinned port - **Extensibility**: Plugin architecture for future features ### 1.2 Key Design Principles -* ✅ **KISS (Keep It Simple)**: Core does ONE thing - forwards HTTP with streaming -* ✅ **SOLID**: Single Responsibility, Open/Closed via plugins, Dependency Injection -* ✅ **Zero Buffering**: True HTTP streaming with no body buffering -* ✅ **Plugin-Based**: Core is stable, features added via plugins -* ✅ **Fail-Safe**: Plugin failures don't break proxy flow +- ✅ **KISS (Keep It Simple)**: Core does ONE thing - forwards HTTP with streaming +- ✅ **SOLID**: Single Responsibility, Open/Closed via plugins, Dependency Injection +- ✅ **Zero Buffering**: True HTTP streaming with no body buffering (buffering is an opt-in exception for a few auth/reasoning-state hooks, see §6.5 and §6.9) +- ✅ **Plugin-Based**: Core is stable, features added via plugins +- ✅ **Fail-Safe**: Plugin failures don't break proxy flow --- @@ -53,7 +56,7 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A ``` ┌─────────────────────────────────────────────────────────────────┐ │ AI Coding Agent │ -│ (claude, gemini, etc.) │ +│ (claude, codex, gemini, kimi, vscode-byok, ...) │ └────────────────────────────┬────────────────────────────────────┘ │ HTTP Request ▼ @@ -63,19 +66,28 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A │ ┌────────────────────────────────────────────────────────────┐ │ │ │ Plugin System (Priority-Based) │ │ │ │ │ │ -│ │ [3] MCP Auth Plugin → MCP OAuth proxy & URL rewrite│ │ -│ │ [10] SSO Auth Plugin → Inject cookies │ │ -│ │ [20] Header Injection → Add X-CodeMie headers │ │ -│ │ [50] Logging Plugin → Log requests/responses │ │ -│ │ [100] Metrics Sync Plugin → Background metrics sync │ │ +│ │ [3] MCP Auth → MCP OAuth proxy & URL rewrite│ +│ │ [5] Endpoint Blocker → block unwanted endpoints early│ +│ │ [7] Gateway Key → validate local daemon bearer key│ +│ │ [10] SSO Auth / JWT Auth → Inject cookies / bearer token│ +│ │ [14] Claude/Kimi/Codex Normalizers → per-agent body fixes │ +│ │ [15] Request Sanitizer → strip unsupported reasoning params│ +│ │ [16] Codex/Copilot Encrypted-Content Sanitizers → reasoning replay retry│ +│ │ [17] VS Code Request Normalizer → constrain user identifiers│ +│ │ [20] Header Injection → Add X-CodeMie headers │ +│ │ [50] Logging → Log requests/responses │ +│ │ [100] SSO Session Sync → Background metrics + conversation sync│ │ └────────────────────────────────────────────────────────────┘ │ │ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ HTTP Streaming Core │ │ │ │ • Build context │ │ +│ │ • /health, /healthz liveness probe (pre-auth, pre-hooks) │ │ +│ │ • Run handleRequest hooks (full-bypass, e.g. MCP relay) │ │ │ │ • Run onRequest hooks │ │ │ │ • Forward to upstream (no buffering) │ │ -│ │ • Run onResponseHeaders hooks │ │ +│ │ • Run onUpstreamResponse hooks (optional buffered retry) │ │ +│ │ • Run onResponseHeaders hooks │ │ │ │ • Stream response chunks (with optional transform) │ │ │ │ • Run onResponseComplete hooks │ │ │ └────────────────────────────────────────────────────────────┘ │ @@ -116,7 +128,7 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A │ Layer 4: HTTP Forwarding (ProxyHTTPClient) │ │ • Upstream connection management │ │ • True HTTP streaming (no buffering) │ -│ • SSL/TLS handling │ +│ • SSL/TLS handling │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -124,83 +136,99 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A ## 3. Core Components +> All proxy source lives under `src/providers/plugins/sso/proxy/` (proxy core + plugins are shipped as part of the `sso` provider plugin, not a standalone top-level module). + ### 3.1 CodeMieProxy -**Location**: `src/utils/codemie-proxy.ts` +**Location**: `src/providers/plugins/sso/proxy/sso.proxy.ts` **Responsibilities**: + - HTTP server lifecycle (start/stop) - Request routing to plugins - Error handling and recovery -- Port management (dynamic allocation) +- Port management (dynamic allocation, or pinned-port EADDRINUSE retry for daemon restarts) +- `/health` and `/healthz` liveness probe, answered before authentication and before any plugin hook **Key Operations**: + - Initialize plugins and start server -- Graceful shutdown with plugin cleanup -- Main request handler -- Streaming response handler - -**Configuration Parameters**: -- Target API URL (upstream endpoint) -- Local port (0 = dynamic allocation) -- Client type (agent identifier) -- Timeout duration -- Profile name, provider, model -- Session ID +- Graceful shutdown with plugin cleanup (force-drains keep-alive sockets before closing, so a persistent client like Claude Desktop can't hang an in-process restart) +- Main request handler (`handleRequest`) +- Streaming response handler (`streamResponse`) + +**Configuration Parameters** (`ProxyConfig`, `src/providers/plugins/sso/proxy/proxy-types.ts`): + +- Target API URL (upstream endpoint), host/port (0 = dynamic allocation), `pinnedPort` (retry same port on EADDRINUSE instead of falling back to random) +- Client type (agent identifier), timeout duration +- Profile name, provider, model, integration ID +- Session ID, CLI version +- `authMethod` (`'sso' | 'jwt'`), `jwtToken` +- `repository` / `branch` / `project` (header injection), `gatewayKey` (local daemon auth) +- `syncApiUrl` / `syncCodeMieUrl` (session sync target and credential lookup, independent of the main upstream) +- `telemetryMode` (`'none' | 'claude-desktop'`) and related poll/inactivity timeouts ### 3.2 PluginRegistry -**Location**: `src/proxy/plugins/registry.ts` +**Location**: `src/providers/plugins/sso/proxy/plugins/registry.ts` **Responsibilities**: + - Plugin registration and storage - Dependency resolution - Priority-based sorting (0-1000) - Lifecycle hook invocation **Key Operations**: + - Register plugins at startup -- Initialize plugins with context +- Initialize plugins with context (`getPluginRegistry()` / `resetPluginRegistry()`) - Enable/disable plugins at runtime - Retrieve plugin configurations -**Plugin Priority Levels**: +**Plugin Priority Levels** (current, see §6 for the full plugin list): + - **0-3**: MCP protocol handling (MCP Auth: 3) -- **4-10**: Authentication and security (SSO Auth: 10) -- **11-50**: Header manipulation (Header Injection: 20) -- **51-100**: Observability (Logging: 50, Metrics Sync: 100) -- **101-500**: Business logic (rate limiting, caching) -- **501-1000**: Post-processing (analytics, reporting) +- **4-10**: Endpoint blocking, gateway/local auth, and upstream authentication (Endpoint Blocker: 5, Gateway Key: 7, SSO Auth: 10, JWT Auth: 10) +- **11-19**: Per-agent request normalization and sanitization (Claude/Kimi/Codex Normalizers: 14, Request Sanitizer: 15, Codex/Copilot Encrypted-Content Sanitizers: 16, VS Code Request Normalizer: 17) +- **20-50**: Header manipulation and observability (Header Injection: 20, Logging: 50) +- **100**: Session sync (SSO Session Sync: 100) + +Note: priorities 14-17 do request-_body_ normalization/sanitization, not header work — despite falling inside what an older version of this doc called the "header manipulation" band. Priority bands are a loose grouping, not a hard contract; always check the actual `priority` field and registration comments in `plugins/index.ts` before assuming a band's purpose. ### 3.3 ProxyHTTPClient -**Location**: `src/proxy/http-client.ts` +**Location**: `src/providers/plugins/sso/proxy/proxy-http-client.ts` **Responsibilities**: + - HTTP/HTTPS forwarding with streaming - Connection pooling - Timeout management - SSL/TLS certificate handling **Features**: + - Zero buffering (streams directly) - Async iteration over response chunks -- Custom SSL/TLS options (self-signed certs) +- Custom SSL/TLS options (self-signed certs; `rejectUnauthorized: false` by default) - Configurable timeouts +- `readResponseBody()` helper used by plugins that need to buffer-and-retry (see `onUpstreamResponse` in §4.1) ### 3.4 ProxyContext -**Location**: `src/proxy/types.ts` +**Location**: `src/providers/plugins/sso/proxy/proxy-types.ts` **Purpose**: Shared state across all plugin hooks for a single request **Context Attributes**: -- **Identity**: Request ID, Session ID, Agent name + +- **Identity**: Request ID, Session ID, Agent name (`agentName`) - **Traceability**: Profile, Provider, Model -- **Request Details**: Method, URL, Headers, Body +- **Request Details**: Method, URL, Headers, Body (`Buffer`, to preserve byte integrity for multi-byte UTF-8) - **Timing**: Request start time - **Upstream**: Target URL -- **Extensibility**: Metadata dictionary for plugin-specific data +- **Extensibility**: Metadata dictionary for plugin-specific data (also used for the `blocked` / `blockedResponseBody` short-circuit, and `gatewayKeyValidated` flag) --- @@ -210,21 +238,28 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A **Design Pattern**: Chain of Responsibility + Observer -**Plugin Interface**: +**Plugin Interface** (`ProxyPlugin`, `src/providers/plugins/sso/proxy/plugins/types.ts`): + - **Metadata**: ID, name, version, priority, dependencies -- **Factory Method**: Creates interceptor instance with context +- **Factory Method**: `createInterceptor()` — creates interceptor instance with context - **Lifecycle Hooks**: Install, uninstall, enable, disable -**Interceptor Interface**: -- **Proxy Lifecycle**: onProxyStart, onProxyStop -- **Request Lifecycle**: onRequest, onResponseHeaders, onResponseChunk, onResponseComplete, onError +**Interceptor Interface** (`ProxyInterceptor`): + +- **Proxy Lifecycle**: `onProxyStart`, `onProxyStop` +- **Full Request Bypass**: `handleRequest` — optional hook checked in priority order _before_ the standard pipeline; if a plugin returns `true`, it has fully handled the request (response written) and the entire onRequest → forward → onResponseHeaders → stream → onResponseComplete pipeline is skipped. Used for traffic that doesn't go to the configured `targetApiUrl` at all (e.g. MCP Auth's `/mcp_auth` and `/mcp_relay/...` routes to arbitrary MCP servers). The handling plugin owns all security guarantees (SSRF checks etc.) for its own traffic. +- **Request Lifecycle**: `onRequest`, `onUpstreamResponse`, `onResponseHeaders`, `onResponseChunk`, `onResponseComplete`, `onError` + +`onUpstreamResponse(context, upstreamResponse, tools)` runs immediately after the upstream call and before header/streaming hooks. `tools` gives a plugin `readBody()` (buffer the full response), `retry()` (re-issue the same request, e.g. after stripping now-rejected state), and `fromBuffer()` (turn a buffered `Buffer` back into a stream-shaped `IncomingMessage`). Only the Copilot encrypted-content sanitizer uses this hook (see §6.8) — the Codex one instead watches `onResponseChunk` and flips a permanent `onRequest`-time flag; see §6.8 for why these two plugins differ. + +**`handleRequest` is not limited to non-upstream traffic.** In addition to MCP Auth's full off-upstream relay (§6.1), the Gateway Key Plugin (§6.3) also implements `handleRequest` — it runs _before_ `onRequest`, validating the local daemon bearer key and returning `true` with a 401 body on failure, or `false` to let the same upstream-bound request continue into the normal `onRequest` pipeline. So `handleRequest` is used both for "this traffic never reaches `targetApiUrl`" (MCP Auth) and "reject this upstream-bound request early, before auth injection" (Gateway Key) — check each plugin's own hook rather than assuming from the hook name alone. ### 4.2 Plugin Lifecycle ``` ┌──────────────────────────────────────────────────────────────┐ │ Application Startup │ -│ ├─ Import: src/proxy/plugins/index.ts │ +│ ├─ Import: src/providers/plugins/sso/proxy/plugins/index.ts │ │ ├─ Auto-register: registerCorePlugins() │ │ └─ Plugins registered in PluginRegistry │ └──────────────────────────────────────────────────────────────┘ @@ -233,21 +268,27 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A ┌──────────────────────────────────────────────────────────────┐ │ Proxy Start (per session) │ │ ├─ CodeMieProxy.start() called │ -│ ├─ Build PluginContext (config, credentials) │ +│ ├─ Build PluginContext (config, credentials, profileConfig) │ │ ├─ PluginRegistry.initialize(context) │ -│ │ ├─ Filter enabled plugins │ +│ │ ├─ Filter enabled plugins (createInterceptor may throw │ +│ │ │ ConfigurationError to opt itself out silently) │ │ │ ├─ Sort by priority │ │ │ ├─ Call createInterceptor() for each │ │ │ └─ Return sorted interceptor list │ -│ └─ Call onProxyStart() on all interceptors │ +│ ├─ Bind server (dynamic port, or pinned-port EADDRINUSE retry)│ +│ └─ Call onProxyStart() on all interceptors, then resolve │ +│ (only once the final bound port is known) │ └──────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────┐ │ Request Handling (per request) │ +│ ├─ /health, /healthz short-circuit (no context, no hooks) │ │ ├─ Build ProxyContext │ -│ ├─ onRequest() hooks (all interceptors) │ +│ ├─ handleRequest() hooks (full bypass, priority order) │ +│ ├─ onRequest() hooks (all interceptors, early-exit if blocked)│ │ ├─ Forward to upstream │ +│ ├─ onUpstreamResponse() hooks (optional buffer/retry) │ │ ├─ onResponseHeaders() hooks │ │ ├─ Stream response with onResponseChunk() hooks │ │ └─ onResponseComplete() hooks │ @@ -258,32 +299,35 @@ The CodeMie Proxy is a **plugin-based HTTP streaming proxy** that sits between A │ Proxy Stop (per session) │ │ ├─ CodeMieProxy.stop() called │ │ ├─ Call onProxyStop() on all interceptors │ -│ └─ Cleanup resources │ +│ └─ Cleanup resources │ └──────────────────────────────────────────────────────────────┘ ``` ### 4.3 Plugin Registration Pattern **Auto-Registration**: -- Plugins register themselves on module import -- Core plugins registered in central index file -- Registry maintains plugin instances and configurations -**Manual Registration**: -- Runtime registration for conditional plugins -- Used for feature flags or environment-specific plugins +- Plugins register themselves on module import via `registerCorePlugins()`, called at the bottom of `plugins/index.ts` +- Any consumer that imports `plugins/index.ts` (the proxy core itself, or `bin/proxy-daemon.ts`) triggers registration as a side effect + +**Opt-Out via createInterceptor()**: + +- A plugin opts itself out for a given session by throwing `ConfigurationError` from `createInterceptor()` (e.g. SSO Session Sync throws if there's no session ID, credentials aren't SSO, or sync is disabled by config/env var) rather than through a separate enable/disable flag ### 4.4 Error Handling **Fail-Safe Design**: Plugin errors don't break proxy flow **Error Handling Strategy**: -- Try-catch wrapper around all plugin hooks + +- Try-catch wrapper around all plugin hooks (`runHook()` in `sso.proxy.ts`) - Errors logged for debugging - Execution continues with remaining interceptors - Graceful degradation ensures proxy availability +- Exception: `handleRequest` hook errors are routed through the normal error pipeline (`onError` interceptors run) instead of being swallowed, since a full-bypass plugin failing usually means the request truly cannot be served **Benefits**: + - One misbehaving plugin doesn't crash the proxy - Full error context captured in logs - System remains operational under failure conditions @@ -301,15 +345,31 @@ Detailed Flow: 1. Agent sends HTTP request to localhost:PORT 2. Proxy receives request - ├─ Build ProxyContext (requestId, sessionId, headers, body) - ├─ Run onRequest() hooks (priority order) - │ ├─ SSOAuthPlugin: Inject cookies - │ ├─ HeaderInjectionPlugin: Add X-CodeMie headers - │ └─ LoggingPlugin: Log request + ├─ Short-circuit /health, /healthz (before context/auth/hooks) + ├─ Build ProxyContext (requestId, sessionId, agentName, headers, body) + ├─ Run handleRequest() hooks in priority order — a plugin here can either + │ fully handle the request (MCPAuthPlugin relaying to an MCP server, + │ short-circuiting the rest of this flow entirely) or reject it early + │ (GatewayKeyPlugin returning a 401 before auth injection runs); any + │ plugin returning false here falls through to onRequest() below + ├─ Run onRequest() hooks (priority order), e.g.: + │ ├─ EndpointBlockerPlugin: reject/short-circuit unwanted endpoints + │ ├─ SSOAuthPlugin / JWTAuthPlugin: inject cookies / bearer token + │ ├─ Claude/Kimi/Codex request normalizers: fix up model-specific body fields + │ ├─ RequestSanitizerPlugin: strip unsupported reasoning params + │ ├─ Codex/Copilot encrypted-content sanitizers: forward reasoning state as-is + │ ├─ VsCodeRequestNormalizerPlugin: constrain user identifiers + │ ├─ HeaderInjectionPlugin: add X-CodeMie headers + │ └─ LoggingPlugin: log request + ├─ Check context.metadata.blocked — if set by any onRequest hook, respond + │ immediately (200 + configured body) without contacting upstream ├─ Build target URL (targetApiUrl + request path) └─ Forward to upstream via ProxyHTTPClient 3. Upstream responds + ├─ Run onUpstreamResponse() hooks — most plugins pass the stream through + │ untouched; the encrypted-content sanitizers may buffer, detect a + │ reasoning-replay rejection, strip the offending state, and retry here ├─ Receive response headers ├─ Run onResponseHeaders() hooks │ └─ LoggingPlugin: Log response headers @@ -324,7 +384,7 @@ Detailed Flow: 5. Response complete ├─ Run onResponseComplete() hooks │ ├─ LoggingPlugin: Log final stats - │ └─ MetricsSyncPlugin: No-op (runs on timer) + │ └─ SSOSessionSyncPlugin: No-op (runs on timer) └─ Close connection ``` @@ -334,7 +394,7 @@ Detailed Flow: Client → Proxy → Error → Proxy → Client Error Handling: -1. Error occurs (network, timeout, upstream error) +1. Error occurs (network, timeout, upstream error, or a handleRequest hook throwing) 2. Proxy catches error ├─ Check if client disconnected (abort error) @@ -363,66 +423,228 @@ Streaming Strategy: - Optional transformation via plugin hooks - Immediate write to client (no accumulation) - Constant memory footprint +- Downstream disconnect is detected mid-stream and iteration stops early Benefits: - ~90% less memory usage (no buffering) - Constant memory regardless of response size - True streaming for SSE and long responses - Real-time data delivery + +Exceptions (deliberate, narrow buffering): +- MCP Auth buffers small JSON auth-metadata responses to rewrite embedded URLs (§6.5) +- Codex/Copilot encrypted-content sanitizers buffer a response only after detecting a + reasoning-state replay rejection, to retry with state stripped (§6.9) ``` --- ## 6. Plugin Implementations -### 6.1 SSO Auth Plugin +All plugin files below live under `src/providers/plugins/sso/proxy/plugins/`. Registration order and priority comments are the source of truth in `plugins/index.ts`. + +### 6.1 MCP Auth Plugin + +**Priority**: 3 (runs before all other plugins) +**File**: `mcp-auth.plugin.ts` + +**Purpose**: Proxy MCP OAuth authorization flows through the CodeMie proxy so that all auth traffic is routed centrally and `client_name` can be overridden via the `MCP_CLIENT_NAME` environment variable. + +#### 6.1.1 URL Scheme + +The plugin intercepts two URL patterns via its `handleRequest` hook (full bypass of the standard pipeline): + +| Route | Pattern | Purpose | +| ----------- | ------------------------------------------ | ------------------------------------------- | +| **Initial** | `/mcp_auth?original=` | First MCP connection — starts an OAuth flow | +| **Relay** | `/mcp_relay///` | Subsequent requests routed through proxy | -**Priority**: 10 (must run first) -**File**: `src/proxy/plugins/sso-auth.plugin.ts` +- `root_b64`: Base64url-encoded root MCP server origin (for per-flow isolation) +- `relay_b64`: Base64url-encoded actual target origin (may differ when auth server is on a separate host) + +#### 6.1.2 Request Handling + +**`/mcp_auth` route:** + +1. Extract `original` query parameter (the real MCP server URL) +2. Validate URL (SSRF check) +3. Forward request to the target MCP server +4. Buffer the JSON response and rewrite all discovered URLs to proxy relay URLs +5. Return the rewritten response to the MCP client + +**`/mcp_relay` route:** + +1. Decode `root_b64` and `relay_b64` to recover target origin +2. Validate root-relay association (per-flow origin scoping) +3. Reconstruct the full target URL from relay origin + path + query +4. Forward request to the real target +5. Buffer JSON auth metadata responses and rewrite URLs; stream all other responses + +#### 6.1.3 Response URL Rewriting + +The plugin buffers JSON responses (auth metadata, client registration, etc.) and rewrites all absolute HTTP(S) URLs found in JSON values to proxy relay URLs. This ensures the MCP client routes all subsequent requests through the proxy. + +**Exceptions**: Token audience identifiers (e.g., `resource` field) are not rewritten — they are logical identifiers, not URLs to access. + +**Browser endpoints** (e.g., `authorization_endpoint`) are left as-is so the user's browser navigates directly to the auth server. + +#### 6.1.4 Security + +**SSRF Protection:** + +- Private/loopback IP addresses are rejected (both literal hostname check and DNS resolution) +- Only `http:` and `https:` schemes are allowed + +**Per-Flow Origin Scoping:** + +- Discovered origins (from auth metadata) are tagged with their root MCP server origin +- Relay requests validate that the relay origin is associated with the claimed root origin +- Prevents cross-flow origin confusion + +**Buffering Policy:** + +- Only auth metadata responses are buffered (for URL rewriting) +- Post-auth MCP traffic streams through without buffering + +#### 6.1.5 Companion Components + +The MCP Auth Plugin works in conjunction with the stdio-to-HTTP bridge: + +| Component | File | Purpose | +| ----------------- | ------------------------------------ | ------------------------------------------------------------- | +| Stdio-HTTP Bridge | `src/mcp/stdio-http-bridge.ts` | Bridges stdio JSON-RPC to streamable HTTP transport | +| OAuth Provider | `src/mcp/auth/mcp-oauth-provider.ts` | Implements `OAuthClientProvider` for browser-based OAuth flow | +| Callback Server | `src/mcp/auth/callback-server.ts` | Ephemeral localhost server for receiving OAuth callbacks | +| Proxy Logger | `src/mcp/proxy-logger.ts` | File-based logger for proxy operations | +| Constants | `src/mcp/constants.ts` | `MCP_CLIENT_NAME` default and accessor | + +#### 6.1.6 Configuration + +**Environment Variables:** + +- `MCP_CLIENT_NAME`: Client name for OAuth Dynamic Client Registration (default: `CodeMie CLI`) +- `MCP_PROXY_DEBUG`: Enable verbose proxy logging +- `CODEMIE_PROXY_PORT`: Fixed proxy port (for stable MCP auth URLs across restarts) + +**Log Location**: `~/.codemie/logs/mcp-proxy.log` + +### 6.2 Endpoint Blocker Plugin + +**Priority**: 5 +**File**: `endpoint-blocker.plugin.ts` + +**Purpose**: Blocks unwanted upstream endpoints early, before any auth or normalization work runs, by short-circuiting via `context.metadata.blocked` (see §5.1 step 2). + +### 6.3 Gateway Key Plugin + +**Priority**: 7 +**File**: `gateway-key.plugin.ts` + +**Purpose**: Validates a static local bearer key (`gatewayKey` on `ProxyConfig`) for daemon-mode clients (Claude Desktop, VS Code BYOK) so they authenticate to the local proxy without ever seeing real SSO/JWT credentials. Strips the header before the request is forwarded upstream. + +**Hook used**: `handleRequest`, not `onRequest` — this plugin runs in the earlier full-bypass phase (§4.1) so an invalid key is rejected (401) before any auth-injection or normalization plugin sees the request. On a valid key it returns `false` and the request falls through to the normal `onRequest` pipeline. + +### 6.4 SSO Auth Plugin + +**Priority**: 10 (must run early, alongside JWT Auth) +**File**: `sso-auth.plugin.ts` **Purpose**: Inject SSO cookies into requests for enterprise authentication **Behavior**: + - Reads cookies from PluginContext credentials - Builds Cookie header from key-value pairs - Only runs when SSO credentials present - Executes in onRequest() hook **Architecture**: + - Single responsibility: Cookie injection - No state maintained between requests - Fails if credentials missing -### 6.2 Header Injection Plugin +### 6.5 JWT Auth Plugin + +**Priority**: 10 (alternative to SSO Auth — mutually exclusive per `ProxyConfig.authMethod`) +**File**: `jwt-auth.plugin.ts` + +**Purpose**: Injects a Bearer `Authorization` header from a JWT token (CLI arg, `CODEMIE_JWT_TOKEN` env var, or credential store) instead of SSO cookies, for the `authMethod: 'jwt'` path. + +### 6.6 Per-Agent Request Normalizers + +**Priority**: 14 +**Files**: `claude-request-normalizer.plugin.ts`, `kimi-request-normalizer.plugin.ts`, `codex-request-normalizer.plugin.ts` + +**Purpose**: Fix up agent-specific request body quirks before the request reaches upstream: + +- **Claude**: normalizes `thinking` params for Claude models +- **Kimi**: caps Kimi output-token requests to stay within upstream limits +- **Codex**: maps the Codex app's undated model names onto dated CodeMie deployments + +### 6.7 Request Sanitizer Plugin + +**Priority**: 15 +**File**: `request-sanitizer.plugin.ts` + +**Purpose**: Strips reasoning parameters the target upstream doesn't support, independent of any single agent's normalizer. + +### 6.8 Codex / Copilot Encrypted-Content Sanitizer Plugins + +**Priority**: 16 +**Files**: `codex-encrypted-content-sanitizer.plugin.ts`, `copilot-encrypted-content-sanitizer.plugin.ts` + +**Shared purpose**: Forward Responses-API encrypted reasoning state untouched by default (preserving cross-turn reasoning continuity), and self-heal once the upstream signals the state is no longer replayable. **The two plugins implement this with genuinely different mechanisms — do not assume one describes the other:** + +- **Codex** (`codex-encrypted-content-sanitizer.plugin.ts`): watches for the rejection marker in **`onResponseChunk`** (streaming inspection of the SSE body), not `onUpstreamResponse`. On the first sighting it sets a permanent `reasoningStateUnusable` flag (comment: "never cleared for this proxy") that causes its `onRequest` hook to strip reasoning state from every _future_ request. It does **not** retry the failing turn — that turn's response streams through unchanged and its error surfaces to the client; only the _next_ request onward gets the stripped-state treatment. +- **Copilot** (`copilot-encrypted-content-sanitizer.plugin.ts`): uses **`onUpstreamResponse`** with `tools.readBody`/`tools.retry` and retries the _same_ failing request once, inline, after stripping the offending state (`return tools.retry(sanitizedRequest.body)`). It keeps **no persistent latch** — every subsequent request is re-checked independently rather than being pre-stripped based on prior failures. + +In short: Codex = detect-in-stream, no retry, permanent latch for later requests. Copilot = detect-post-response, immediate retry of the same request, no latch. Both achieve "the session keeps working instead of repeatedly replaying unusable state," but via opposite trade-offs (Copilot fixes the current turn but re-pays the detection cost every time; Codex pays once and then avoids the cost, at the price of always failing the turn that first triggers it). See §6.13 for the VS Code BYOK context this was built for; the same class of self-healing applies uniformly to `codemie-codex`, `codemie-code`, `codemie-opencode`, `codemie-pi`, and `vscode-byok`. + +### 6.9 VS Code Request Normalizer Plugin + +**Priority**: 17 +**File**: `vscode-request-normalizer.plugin.ts` + +**Purpose**: Constrains the Responses `user` identifier for `vscode-byok` traffic (bounded compatibility normalization only — see §6.14). + +### 6.10 Header Injection Plugin **Priority**: 20 -**File**: `src/proxy/plugins/header-injection.plugin.ts` +**File**: `header-injection.plugin.ts` **Purpose**: Add CodeMie-specific headers for traceability -**Headers Injected**: +**Headers Injected**: only `X-CodeMie-Request-ID` and `X-CodeMie-CLI` are truly unconditional (`header-injection.plugin.ts:30-73`). Every other header — including `X-CodeMie-Session-ID` and `X-CodeMie-Client` — is conditional on the corresponding value being present. **Always Injected:** + - `X-CodeMie-CLI`: CLI wrapper and version (e.g., `codemie-cli/0.0.16`) -- `X-CodeMie-Client`: Agent identifier (e.g., `codemie-claude`, `codemie-gemini`, `codemie-code`) - `X-CodeMie-Request-ID`: Request UUID for traceability -- `X-CodeMie-Session-ID`: Session UUID for correlation **Conditionally Injected:** + +- `X-CodeMie-Session-ID`: Session UUID for correlation, only `if (context.sessionId && context.sessionId !== 'unknown')` +- `X-CodeMie-Client`: Agent identifier (e.g., `codemie-claude`, `codemie-gemini`, `codemie-code`), only `if (config.clientType)` - `X-CodeMie-Integration`: Integration ID (only when provider requires integration via `requiresIntegration` flag) -- `X-CodeMie-CLI-Model`: Model name from config (if configured) +- `X-CodeMie-CLI-Model`: Model name from config (if configured) — this fires for `vscode-byok` too whenever the active profile has a `model` set, since the daemon receives `--model` for every target (see §6.13) - `X-CodeMie-CLI-Timeout`: Timeout value from config (if configured) +- `X-CodeMie-Repository`: Repository name (parent/current format), from `ProxyConfig.repository` +- `X-CodeMie-Branch`: Git branch at proxy startup, from `ProxyConfig.branch` +- `X-CodeMie-Project`: CodeMie project name, from `ProxyConfig.project` (also used by VS Code BYOK, see §6.13) +- `x-litellm-session-id`: session ID, injected only for `codemie-codex`/`codemie-copilot` client types (`header-injection.plugin.ts:41-43`) — not a `X-CodeMie-*` header, easy to miss when scanning for the CodeMie prefix **Architecture**: + - Reads values from PluginContext and ProxyContext - Adds headers to outgoing request - Executes in onRequest() hook - Fails gracefully if values are missing (optional headers) -### 6.3 Logging Plugin +### 6.11 Logging Plugin **Priority**: 50 -**File**: `src/proxy/plugins/logging.plugin.ts` +**File**: `logging.plugin.ts` **Purpose**: Log detailed proxy activity @@ -431,62 +653,68 @@ Benefits: **Log Level**: DEBUG (file only, console when CODEMIE_DEBUG=1) **Lifecycle Hooks**: + - **onRequest**: Log request details (method, URL, headers, body size) - **onResponseHeaders**: Log response headers (content-type, encoding) -- **onResponseChunk**: Log streaming progress (every 10th chunk) +- **onResponseChunk**: Log streaming progress (1st chunk, then every 1000th chunk) - **onResponseComplete**: Log final stats (status, duration, bytes sent) - **onError**: Log error details (type, message, stack trace) **Architecture**: + - Stateless within single request - Maintains chunk counter for sampling - No impact on proxy performance (async logging) -### 6.4 Metrics Sync Plugin +### 6.12 SSO Session Sync Plugin (Unified) **Priority**: 100 -**File**: `src/proxy/plugins/metrics-sync.plugin.ts` +**File**: `sso.session-sync.plugin.ts` -**Purpose**: Background sync of session metrics to CodeMie API +**Purpose**: Unified background orchestrator that syncs session data — both metrics _and_ conversations — to the CodeMie API. This plugin replaced an earlier, metrics-only "Metrics Sync Plugin"; if you find references to `metrics-sync.plugin.ts` or `CODEMIE_METRICS_SYNC_*` env vars elsewhere (old docs, old comments), they describe the previous design and no longer match the code. -#### 6.4.1 Overview +#### 6.12.1 Overview **Design Decisions**: -- ✅ **Aggregation over Granularity**: Multiple deltas aggregated into single metric per sync -- ✅ **Single Metric Sync**: API receives one aggregated metric object, not array + +- ✅ **Unified Orchestration**: Sessions are discovered and read once, then handed to multiple pluggable processors (metrics, conversations) — zero duplicated I/O +- ✅ **Agent-Agnostic**: Adapters support Claude, Gemini, and others behind a common interface - ✅ **Session-Level Sync**: Plugin is session-scoped (syncs only current session) -- ✅ **In-Place Marking**: Sync status tracked directly in JSONL file -- ✅ **SSO-Only Operation**: Only runs when provider is `ai-run-sso` -- ✅ **Cookie Authentication**: Uses SSO cookies from proxy context +- ✅ **SSO-Only Operation**: Only runs when credentials are SSO cookies (guards against JWT-only sessions) +- ✅ **Opt-Out via createInterceptor()**: Throws `ConfigurationError` (not a separate flag) when session ID, SSO credentials, client type, or sync-enabled config is missing — the plugin simply isn't registered for that session -#### 6.4.2 Architecture +#### 6.12.2 Architecture **Lifecycle**: + ``` Proxy Start └─ onProxyStart() - ├─ Initialize MetricsApiClient - ├─ Start background timer (every 5 minutes) - └─ Log: "Starting metrics sync" - -Background Timer (every 5 minutes) - └─ syncMetrics() - ├─ Read {sessionId}_metrics.jsonl - ├─ Filter deltas with syncStatus='pending' - ├─ Load session metadata - ├─ Aggregate deltas → single metric - ├─ POST ${apiUrl}/metrics - ├─ On success: Mark deltas as 'synced' in JSONL - └─ Log: "Synced N deltas" + ├─ Initialize SessionSyncer + ├─ Start background timer (every 2 minutes by default — see §6.12.6) + └─ Log: "Starting session sync" + +Background Timer (every 2 minutes by default) + └─ sync() + ├─ Discover session files via adapter (once) + ├─ Pass parsed sessions to all processors (metrics, conversations) + ├─ Each processor aggregates/syncs its own concern + └─ Log: "Synced N sessions" Proxy Stop └─ onProxyStop() ├─ Stop background timer - ├─ Final sync (ensures all pending deltas sent) + ├─ Final sync (ensures all pending data sent) └─ Log: "Final sync completed" ``` -#### 6.4.3 Claude Desktop 3P Telemetry Runtime +**Components**: + +- `SSOSessionSyncPlugin` / `SSOSessionSyncInterceptor`: Plugin registration and timer/sync orchestration (`sso.session-sync.plugin.ts`) +- `SessionSyncer` (`src/providers/plugins/sso/session/SessionSyncer.ts`): Discovery + I/O shared across processors +- `BaseProcessor` (`src/providers/plugins/sso/session/BaseProcessor.ts`) and concrete processors under `src/providers/plugins/sso/session/processors/` (e.g. `processors/metrics/metrics-sync-processor.ts` for the metrics-aggregation logic previously described as the whole plugin) + +#### 6.12.3 Claude Desktop 3P Telemetry Runtime When the proxy daemon is started in Desktop mode, the daemon also starts a local telemetry runtime for Claude Desktop 3P: @@ -497,16 +725,9 @@ When the proxy daemon is started in Desktop mode, the daemon also starts a local - Syncs pending JSONL metrics and conversations through `SessionSyncer` - Sends session lifecycle metrics with client identity `claude-desktop` -This path is intentionally separate from the hook-based `codemie-claude` flow. Claude Desktop does not expose CodeMie-managed lifecycle hooks, so ingestion is file-discovery driven rather than event-callback driven. The shared runtime is generic; client-specific logic lives behind a Desktop adapter so future IDE or desktop clients can plug into the same sync pipeline. +This path is intentionally separate from the hook-based `codemie-claude` flow. Claude Desktop does not expose CodeMie-managed lifecycle hooks, so ingestion is file-discovery driven rather than event-callback driven. The shared runtime is generic; client-specific logic lives behind a Desktop adapter (`src/telemetry/clients/claude-desktop/ClaudeDesktopTelemetryAdapter.js`, wired into `src/telemetry/runtime/DesktopTelemetryRuntime.ts`) so future IDE or desktop clients can plug into the same sync pipeline. Started/stopped directly by `src/bin/proxy-daemon.ts` when `--telemetry-mode claude-desktop` is passed. -**Components**: -- MetricsSyncPlugin: Plugin registration and initialization -- MetricsSyncInterceptor: Interceptor with timer and sync logic -- MetricsApiClient: HTTP client for API communication -- Aggregation Logic: Combines deltas into session metric -- JSONL Utilities: Atomic file operations - -#### 6.4.3 API Contract +#### 6.12.4 API Contract **Endpoint**: `POST ${apiUrl}/metrics` **Example**: `POST https://codemie.ai/metrics` @@ -514,11 +735,13 @@ This path is intentionally separate from the hook-based `codemie-claude` flow. C **Auth**: `Cookie: session={token}` (SSO cookies) **Metric Structure**: + - **metric_name**: Always `codemie_coding_agent_usage` - **attributes**: Session-aggregated metrics - **time**: ISO timestamp **Metric Attributes**: + - **Identity**: agent, agent_version, llm_model, project, session_id - **Interaction**: total_user_prompts, total_ai_requests, total_ai_responses - **Tokens**: total_input_tokens, total_output_tokens, total_cache_read_input_tokens @@ -527,26 +750,30 @@ This path is intentionally separate from the hook-based `codemie-claude` flow. C - **Session**: session_duration_ms, exit_reason, had_errors, status, is_final, count **Response Structure**: + - success: Boolean flag - received: Number of metrics received - processed: Number successfully processed - failed: Number of failures - timestamp: Server timestamp -#### 6.4.4 Data Flow +#### 6.12.5 Data Flow **Local Metrics Storage**: `~/.codemie/sessions/` + - `{sessionId}.json`: Session metadata - `{sessionId}_metrics.jsonl`: Delta records (one per line) **Delta Lifecycle**: -1. MetricsOrchestrator writes delta with syncStatus='pending' -2. MetricsSyncPlugin reads all pending deltas periodically (every 5 minutes) + +1. Metrics orchestrator writes delta with syncStatus='pending' +2. SSOSessionSyncPlugin's metrics processor reads all pending deltas periodically (every `CODEMIE_SESSION_SYNC_INTERVAL` ms, default 120000 = 2 minutes, see §6.12.6) 3. All pending deltas aggregated into single session metric 4. Single metric sent to API as JSON object 5. On success: All aggregated deltas marked with syncStatus='synced' **Sync Algorithm**: + 1. Read all deltas from JSONL file 2. Filter for syncStatus='pending' only 3. Load session metadata @@ -555,49 +782,55 @@ This path is intentionally separate from the hook-based `codemie-claude` flow. C 6. On success: Mark all aggregated deltas as 'synced' with atomic JSONL rewrite 7. On failure: Retry with exponential backoff, keep deltas as 'pending' -#### 6.4.5 Configuration +#### 6.12.6 Configuration **Priority**: Environment Variables > Profile Config > Default (true) **Environment Variables**: -- `CODEMIE_METRICS_SYNC_ENABLED`: Enable/disable sync (default: true for SSO) -- `CODEMIE_METRICS_SYNC_INTERVAL`: Sync interval in milliseconds (default: 300000) -- `CODEMIE_METRICS_MAX_RETRIES`: Max retry attempts (default: 3) + +- `CODEMIE_SESSION_SYNC_ENABLED`: Enable/disable sync (default: true for SSO) +- `CODEMIE_SESSION_DRY_RUN`: Run sync logic without actually sending data (default: false) +- `CODEMIE_SESSION_SYNC_INTERVAL`: Background sync interval in milliseconds (default: `120000` = 2 minutes). Not gated by profile config — env var only. **Profile Configuration**: + - Location: `~/.codemie/codemie-cli.config.json` -- Path: `profiles[name].metrics.sync` -- Properties: enabled, interval, maxRetries +- Path: `profiles[name].session.sync` (properties: `enabled`, `dryRun`) **Opt-Out Options**: + - Single session: Set env var to false - Profile-wide: Disable in profile config -#### 6.4.6 Error Handling +#### 6.12.7 Error Handling **Retryable Errors** (exponential backoff: 1s → 2s → 5s): + - Network timeouts - 5xx server errors - 429 Rate limiting - Connection refused **Non-Retryable Errors** (fail immediately): + - 401 Unauthorized (SSO session expired) - 403 Forbidden (insufficient permissions) - 400 Bad Request (invalid payload) **Failure Strategy**: + - On retry exhaustion: Keep deltas as 'pending' - Next sync cycle retries automatically - Errors logged at ERROR level - Plugin failure doesn't break proxy **Concurrency Protection**: + - isSyncing flag prevents concurrent syncs - Timer skips if sync already in progress - Serial processing guaranteed -#### 6.4.7 Performance +#### 6.12.8 Performance **Memory**: ~5MB for plugin (within proxy process) **Disk I/O**: O(1) - single session file read/write @@ -605,114 +838,38 @@ This path is intentionally separate from the hook-based `codemie-claude` flow. C **CPU**: Minimal (simple arithmetic aggregation) **Scalability**: + - Session-scoped: Only syncs current session - No cross-session interference - Timer-based: Predictable resource usage -#### 6.4.8 Monitoring +#### 6.12.9 Monitoring **Log Location**: `~/.codemie/logs/debug-YYYY-MM-DD.log` **Log Events**: -- Starting metrics sync (with interval) + +- Starting session sync (with interval) - Syncing N pending deltas - Successfully synced N deltas -- Stopping metrics sync +- Stopping session sync - Final sync completed - Sync failures with error details **Troubleshooting**: + - Check syncStatus in JSONL file (pending/synced) - Verify SSO cookies valid - Check network connectivity to API - Enable debug logging -### 6.5 MCP Auth Plugin - -**Priority**: 3 (runs before all other plugins) -**File**: `src/providers/plugins/sso/proxy/plugins/mcp-auth.plugin.ts` - -**Purpose**: Proxy MCP OAuth authorization flows through the CodeMie proxy so that all auth traffic is routed centrally and `client_name` can be overridden via the `MCP_CLIENT_NAME` environment variable. - -#### 6.5.1 URL Scheme +### 6.13 VS Code BYOK Profile Configuration -The plugin intercepts two URL patterns: +`codemie proxy connect --vscode` resolves one effective CodeMie profile before configuring the client. It does **not** write the profile's single `model` into VS Code's config as one entry — `writeVsCodeLanguageModelsConfig()` (`connectors/vscode.ts`, `vscode-models.ts`) writes the **entire fixed `VS_CODE_SUPPORTED_MODELS` catalog** (~20 entries, including the GPT-5.5/5.6 Responses-API entries described below) into a single managed provider in `chatLanguageModels.json`. The profile's `model` field is not read by this write path at all. The profile's `codeMieProject` is passed independently to the daemon for `X-CodeMie-Project` header injection. -| Route | Pattern | Purpose | -|-------|---------|---------| -| **Initial** | `/mcp_auth?original=` | First MCP connection — starts an OAuth flow | -| **Relay** | `/mcp_relay///` | Subsequent requests routed through proxy | +The persistent daemon **does** receive a configured model: `spawnDaemon()` passes `--model ` whenever the active profile has a `model` set, and this is shared across every `proxy connect` target (`--vscode`, `--claude-desktop`, `--codex-desktop`), not restricted to Codex. When present, the daemon injects `X-CodeMie-CLI-Model` (§6.10) for `vscode-byok` traffic same as any other client. What the daemon does _not_ do for VS Code traffic is rewrite the **request body's** model field — the Codex request normalizer (§6.6), which does that kind of body rewrite, explicitly excludes `vscode-byok` from its allowed client list. It validates the local gateway key (via the Gateway Key Plugin, §6.3), injects SSO authentication and CodeMie context headers, then forwards request and response bodies through the existing streaming path byte-for-byte. -- `root_b64`: Base64url-encoded root MCP server origin (for per-flow isolation) -- `relay_b64`: Base64url-encoded actual target origin (may differ when auth server is on a separate host) - -#### 6.5.2 Request Handling - -**`/mcp_auth` route:** -1. Extract `original` query parameter (the real MCP server URL) -2. Validate URL (SSRF check) -3. Forward request to the target MCP server -4. Buffer the JSON response and rewrite all discovered URLs to proxy relay URLs -5. Return the rewritten response to the MCP client - -**`/mcp_relay` route:** -1. Decode `root_b64` and `relay_b64` to recover target origin -2. Validate root-relay association (per-flow origin scoping) -3. Reconstruct the full target URL from relay origin + path + query -4. Forward request to the real target -5. Buffer JSON auth metadata responses and rewrite URLs; stream all other responses - -#### 6.5.3 Response URL Rewriting - -The plugin buffers JSON responses (auth metadata, client registration, etc.) and rewrites all absolute HTTP(S) URLs found in JSON values to proxy relay URLs. This ensures the MCP client routes all subsequent requests through the proxy. - -**Exceptions**: Token audience identifiers (e.g., `resource` field) are not rewritten — they are logical identifiers, not URLs to access. - -**Browser endpoints** (e.g., `authorization_endpoint`) are left as-is so the user's browser navigates directly to the auth server. - -#### 6.5.4 Security - -**SSRF Protection:** -- Private/loopback IP addresses are rejected (both literal hostname check and DNS resolution) -- Only `http:` and `https:` schemes are allowed - -**Per-Flow Origin Scoping:** -- Discovered origins (from auth metadata) are tagged with their root MCP server origin -- Relay requests validate that the relay origin is associated with the claimed root origin -- Prevents cross-flow origin confusion - -**Buffering Policy:** -- Only auth metadata responses are buffered (for URL rewriting) -- Post-auth MCP traffic streams through without buffering - -#### 6.5.5 Companion Components - -The MCP Auth Plugin works in conjunction with the stdio-to-HTTP bridge: - -| Component | File | Purpose | -|-----------|------|---------| -| Stdio-HTTP Bridge | `src/mcp/stdio-http-bridge.ts` | Bridges stdio JSON-RPC to streamable HTTP transport | -| OAuth Provider | `src/mcp/auth/mcp-oauth-provider.ts` | Implements `OAuthClientProvider` for browser-based OAuth flow | -| Callback Server | `src/mcp/auth/callback-server.ts` | Ephemeral localhost server for receiving OAuth callbacks | -| Proxy Logger | `src/mcp/proxy-logger.ts` | File-based logger for proxy operations | -| Constants | `src/mcp/constants.ts` | `MCP_CLIENT_NAME` default and accessor | - -#### 6.5.6 Configuration - -**Environment Variables:** -- `MCP_CLIENT_NAME`: Client name for OAuth Dynamic Client Registration (default: `CodeMie CLI`) -- `MCP_PROXY_DEBUG`: Enable verbose proxy logging -- `CODEMIE_PROXY_PORT`: Fixed proxy port (for stable MCP auth URLs across restarts) - -**Log Location**: `~/.codemie/logs/mcp-proxy.log` - -### 6.6 VS Code BYOK Profile Configuration - -`codemie proxy connect vscode` resolves one effective CodeMie profile before configuring the client. The selected profile's `model` is written directly to VS Code's `models[].id`, which is the identifier VS Code sends in inference requests. The profile's `codeMieProject` is passed independently to the daemon for `X-CodeMie-Project` header injection. - -The persistent daemon never receives a configured model and does not rewrite request bodies. It validates the local gateway key, injects SSO authentication and CodeMie context headers, then forwards request and response bodies through the existing streaming path byte-for-byte. - -The command reuses a healthy daemon when its profile, project, provider, target URL, and `vscode-byok` client type match. Model changes only rewrite VS Code configuration; they do not restart the daemon. +The command reuses a healthy daemon when its profile, project, provider, target URL, `vscode-byok` client type, **and configured model** all match (`daemonMatchesRequest()` in `connect-orchestrator.ts` also compares model when the request specifies one — a mismatch spawns a fresh daemon rather than silently reusing a stale one). The connector merges one managed model into VS Code's `chatLanguageModels.json` and preserves unrelated models plus an existing `${input:chat.lm.secret.*}` reference as `apiKey`. If no valid reference exists, it omits `apiKey` rather than generating a placeholder and directs the user to open `Chat: Manage Language Models`, right-click **CodeMie Profile Model**, and choose **Update API Key**. VS Code then stores the local `codemie-proxy` key in secret storage; CodeMie SSO credentials never enter VS Code configuration. @@ -723,7 +880,7 @@ Code owns the complete conversation history for these entries and sends stateles function-call-output items. This keeps LiteLLM free to load-balance each request across Azure deployments. -The proxy performs only bounded compatibility normalization for `vscode-byok`: it constrains the +The proxy performs only bounded compatibility normalization for `vscode-byok` (§6.9): it constrains the Responses `user` identifier and forwards reasoning state untouched. It preserves the selected `reasoning.effort`, visible messages, assistant phases, tools, call IDs, and tool outputs. The proxy does not persist conversation content, add session affinity, buffer Responses events, or @@ -733,7 +890,7 @@ Deployment-bound reasoning state is now handled server-side: the gateway runs Li `encrypted_content_affinity`, which routes a follow-up carrying encrypted content back to the deployment that produced it. Follow-ups therefore keep full cross-turn reasoning continuity. -The proxy retains a self-healing fallback for the case where an affinity pin has expired +The proxy retains a self-healing fallback (§6.8) for the case where an affinity pin has expired (`deployment_affinity_ttl_seconds`). It watches upstream responses for `invalid_encrypted_content` and for bare-reasoning-id rejections; on the first sighting it strips reasoning state from every later request for the life of that proxy. The failing turn surfaces its error, then the session @@ -767,33 +924,42 @@ sequenceDiagram ### 7.1 Performance **Streaming**: Zero buffering, constant memory + - Before: ~100MB memory for 10MB response (buffered) - After: ~10MB memory for 10MB response (streamed) **Throughput**: No artificial limits + - Limited only by network and upstream API - No CPU-intensive operations in hot path **Latency**: Minimal overhead + - Plugin hooks: ~1-5ms per request - Streaming: No added latency (pass-through) **Concurrency**: Multi-request support + - Node.js event loop handles concurrent requests - No blocking operations in request path ### 7.2 Reliability **Fail-Safe**: Plugin failures don't break proxy + - Try-catch around all plugin hooks - Log errors and continue **Graceful Shutdown**: Clean resource cleanup + - Call onProxyStop() on all plugins - Final sync operations complete -- HTTP server closes gracefully +- Force-drain keep-alive sockets, then close the HTTP server + +**Self-Healing (daemon mode)**: `ProxyWatcher` (`src/cli/commands/proxy/watcher.ts`) deep-checks the daemon every 30s via `/health`; on failure it restarts the proxy in-process on the same pinned port (up to 3 attempts) before giving up and recording `health: 'unhealthy'` in the daemon state file. See §9.1. **Error Recovery**: Structured error responses + - Normalized error types - Actionable error messages - Full error context in logs @@ -801,6 +967,7 @@ sequenceDiagram ### 7.3 Maintainability **SOLID Principles**: + - **Single Responsibility**: Core = forward HTTP, Plugins = features - **Open/Closed**: Add features via plugins without modifying core - **Liskov Substitution**: All plugins implement same interface @@ -808,37 +975,61 @@ sequenceDiagram - **Dependency Inversion**: Core depends on plugin abstractions **Code Organization**: + ``` -src/proxy/ -├── errors.ts # Error types -├── http-client.ts # HTTP forwarding -├── types.ts # Core types +src/providers/plugins/sso/proxy/ +├── proxy-errors.ts # Error types +├── proxy-http-client.ts # HTTP forwarding +├── proxy-types.ts # Core types +├── sso.proxy.ts # CodeMieProxy core └── plugins/ - ├── index.ts # Plugin registration - ├── registry.ts # Plugin management - ├── types.ts # Plugin interfaces - ├── sso-auth.plugin.ts - ├── header-injection.plugin.ts - ├── logging.plugin.ts - └── metrics-sync.plugin.ts + ├── index.ts # Plugin registration + ├── registry.ts # Plugin management + ├── types.ts # Plugin interfaces + ├── mcp-auth.plugin.ts # priority 3 + ├── endpoint-blocker.plugin.ts # priority 5 + ├── gateway-key.plugin.ts # priority 7 + ├── sso-auth.plugin.ts # priority 10 + ├── jwt-auth.plugin.ts # priority 10 + ├── claude-request-normalizer.plugin.ts # priority 14 + ├── kimi-request-normalizer.plugin.ts # priority 14 + ├── codex-request-normalizer.plugin.ts # priority 14 + ├── request-sanitizer.plugin.ts # priority 15 + ├── codex-encrypted-content-sanitizer.plugin.ts # priority 16 + ├── copilot-encrypted-content-sanitizer.plugin.ts # priority 16 + ├── vscode-request-normalizer.plugin.ts # priority 17 + ├── header-injection.plugin.ts # priority 20 + ├── logging.plugin.ts # priority 50 + └── sso.session-sync.plugin.ts # priority 100 + +src/providers/plugins/sso/session/ +├── SessionSyncer.ts # discovery + I/O shared by processors +├── BaseProcessor.ts +└── processors/ + ├── metrics/metrics-sync-processor.ts + └── ... # conversation and other processors ``` ### 7.4 Security -**Authentication**: SSO cookie handling -- Cookies never logged (sanitized) +**Authentication**: SSO cookie / JWT bearer / local gateway key handling + +- Credentials never logged (sanitized) - Secure credential storage (CredentialStore) - Encrypted at rest **TLS/SSL**: Support for self-signed certs -- rejectUnauthorized option configurable + +- `rejectUnauthorized` option configurable (defaults to `false`) - Allows enterprise CA certificates **Input Validation**: Header sanitization + - Remove Host and Connection headers - Validate proxy configuration **Audit Trail**: Full request logging + - Request ID for tracing - Session ID for correlation - Detailed logs for forensics @@ -848,14 +1039,18 @@ src/proxy/ **Plugin System**: Add features without core changes **Extension Points**: + - **onProxyStart**: Initialization tasks, background services +- **handleRequest**: Full request bypass for traffic that doesn't target the main upstream (rare — use only when the standard pipeline genuinely doesn't apply) - **onRequest**: Request modification, authentication, validation +- **onUpstreamResponse**: Inspect/replace the raw upstream response before header/streaming hooks run (buffer-and-retry patterns) - **onResponseHeaders**: Header inspection, caching decisions - **onResponseChunk**: Streaming transformation, filtering - **onResponseComplete**: Analytics, logging, cleanup - **onError**: Error handling, alerting, recovery **Future Plugin Examples**: + - Rate Limiting: Per-session request throttling - Caching: LRU cache with TTL expiration - Request Replay: Store/retry failed requests @@ -908,26 +1103,40 @@ src/proxy/ ### 9.1 Startup Flow ``` -1. Agent CLI starts - └─ codemie-claude "implement feature" --provider ai-run-sso +1. Agent CLI starts, or `codemie proxy start` / `codemie proxy connect ...` spawns the daemon + └─ e.g. codemie-claude "implement feature" --provider ai-run-sso -2. Agent detects SSO provider +2. Agent/CLI detects SSO provider (or daemon mode is requested) └─ Checks if proxy is needed -3. Agent spawns proxy +3a. In-process usage: agent spawns proxy directly ├─ Create ProxyConfig (targetApiUrl, sessionId, etc.) ├─ new CodeMieProxy(config) └─ await proxy.start() - ├─ Load SSO credentials + ├─ Load SSO/JWT credentials ├─ Initialize plugins ├─ Call onProxyStart() hooks └─ Bind to dynamic port +3b. Daemon usage (src/bin/proxy-daemon.ts, detached process, e.g. for Claude + Desktop or VS Code BYOK, spawned via src/cli/commands/proxy/daemon-manager.ts): + ├─ Parse CLI args (--target-url, --state-file, --gateway-key, --port, ...) + ├─ new CodeMieProxy(config) and await proxy.start() + ├─ Optionally start DesktopTelemetryRuntime (--telemetry-mode claude-desktop) + ├─ Persist state file atomically (pid, port, url, health, timestamps) + ├─ Start ProxyWatcher: deep-checks /health every 30s, restarts the proxy + │ in-process on the SAME pinned port (up to 3 attempts) on failure, + │ records health: 'unhealthy' in the state file if it gives up + └─ Register SIGTERM/SIGINT handlers that stop the watcher, telemetry + runtime, and proxy, then delete the state file + 4. Proxy returns URL - └─ http://localhost:54321 + └─ http://localhost:PORT or http://127.0.0.1:PORT (daemon binds 127.0.0.1 + explicitly — Claude Desktop's gateway URL validator requires the literal + loopback IP; 'localhost' can resolve to IPv6 ::1 only on macOS) 5. Agent uses proxy URL - └─ Set environment variable: ANTHROPIC_BASE_URL=http://localhost:54321 + └─ Set environment variable: ANTHROPIC_BASE_URL=http://localhost:PORT 6. Agent runs normally └─ All API requests go through proxy @@ -936,51 +1145,65 @@ src/proxy/ ### 9.2 Shutdown Flow ``` -1. User exits agent (Ctrl+C or normal exit) +1. User exits agent (Ctrl+C or normal exit), or sends SIGTERM/SIGINT to the daemon -2. Agent cleanup - ├─ Signal proxy to stop +2. Cleanup + ├─ Stop the ProxyWatcher (daemon mode only) + ├─ Stop the Desktop telemetry runtime, if running └─ await proxy.stop() ├─ Call onProxyStop() hooks - │ └─ MetricsSyncPlugin: Final sync - ├─ Close HTTP server + │ └─ SSOSessionSyncPlugin: Final sync + ├─ Force-drain keep-alive sockets, then close HTTP server └─ Cleanup HTTP client -3. Agent exits +3. Daemon mode also deletes the state file + +4. Process exits ``` ### 9.3 Configuration **Programmatic Configuration**: + - Target API URL -- Port (0 = dynamic) +- Port (0 = dynamic), pinned-port retry flag - Client type - Session ID - Profile, provider, model +- Auth method (`sso` | `jwt`), gateway key +- Telemetry mode and intervals **Environment Variables** (plugin-specific): -- CODEMIE_METRICS_SYNC_ENABLED -- CODEMIE_METRICS_SYNC_INTERVAL -- CODEMIE_DEBUG + +- `CODEMIE_SESSION_SYNC_ENABLED` +- `CODEMIE_SESSION_DRY_RUN` +- `CODEMIE_DEBUG` +- `MCP_PROXY_DEBUG` +- `MCP_CLIENT_NAME` +- `CODEMIE_PROXY_PORT` +- `CODEMIE_JWT_TOKEN` **Profile Configuration** (plugin-specific): + - Location: ~/.codemie/codemie-cli.config.json - Provider-specific settings -- Metrics sync configuration +- Session sync configuration (`profiles[name].session.sync`) **Priority**: Environment variables > Profile config > Defaults ### 9.4 Monitoring -**Log Files**: `~/.codemie/logs/debug-YYYY-MM-DD.log` +**Log Files**: `~/.codemie/logs/debug-YYYY-MM-DD.log`, `~/.codemie/logs/mcp-proxy.log` **Log Levels**: + - ERROR: Plugin failures, network errors - WARN: Retry attempts, deprecated features - INFO: Plugin initialization, sync operations - DEBUG: All proxy activity (file only) -**Metrics** (via Metrics Sync Plugin): +**Metrics** (via SSO Session Sync Plugin): + - Request count per session - Token usage - Tool calls @@ -988,7 +1211,9 @@ src/proxy/ - Session duration **Health Indicators**: -- Proxy responds to requests + +- `/health` / `/healthz` responds (used by CLI commands and the in-daemon watcher) +- Daemon state file `health` field (`ok` / `unhealthy`) - Plugins loaded successfully - No critical errors in logs @@ -996,15 +1221,19 @@ src/proxy/ **Problem**: Proxy not starting **Diagnosis**: Check logs for port binding errors -**Solution**: Use dynamic port (port: 0) +**Solution**: Use dynamic port (port: 0), or check for a stale pinned-port daemon holding the port **Problem**: SSO auth failing **Diagnosis**: Check if credentials exist **Solution**: Re-authenticate with auth command -**Problem**: Metrics not syncing -**Diagnosis**: Check plugin enabled in config -**Solution**: Enable in profile or env var +**Problem**: Session sync not working +**Diagnosis**: Check plugin enabled in config/env; confirm credentials are SSO (not JWT) +**Solution**: Enable via `CODEMIE_SESSION_SYNC_ENABLED` or `profiles[name].session.sync.enabled` + +**Problem**: Daemon restarts repeatedly / marked unhealthy +**Diagnosis**: Check `ProxyWatcher` log lines and the daemon state file's `healthReason` +**Solution**: After 3 failed restarts the watcher gives up rather than looping forever — investigate the underlying upstream/auth failure before forcing another daemon start **Problem**: Plugin error breaking proxy **Diagnosis**: Check logs for plugin name @@ -1016,41 +1245,52 @@ src/proxy/ ### 10.1 Planned Features -**Request Caching** (Priority: 90): +> These were speculative when originally written. Priorities 15-17 are now occupied by shipped +> request-sanitization plugins (§6.7-6.9), so any new plugin in this range should pick an unused +> priority rather than reusing the numbers below verbatim. + +**Request Caching**: + - LRU cache for identical requests - TTL-based expiration - Cache invalidation API -**Rate Limiting** (Priority: 15): +**Rate Limiting**: + - Per-session rate limits - Token bucket algorithm - Configurable limits per profile -**Request Replay** (Priority: 110): +**Request Replay**: + - Store failed requests - Automatic retry on recovery - Persistence across restarts -**Request/Response Transformation** (Priority: 25): +**Request/Response Transformation**: + - Modify request body (add system prompts) - Filter response content (PII redaction) - Format transformation (OpenAI → Anthropic) ### 10.2 Scalability Considerations -**Current Limitation**: Single process, single session +**Current Limitation**: Single process, single session (per daemon instance) **Future Enhancement**: Multi-session support + - Session routing via header - Per-session plugin context - Shared cache across sessions **Load Balancing**: Multiple upstream targets + - Round-robin routing - Health checks - Failover logic **Distributed Tracing**: OpenTelemetry integration + - Trace ID propagation - Span creation for plugin hooks - Export to observability platforms @@ -1060,12 +1300,14 @@ src/proxy/ **Vision**: Community-contributed plugins **Requirements**: + - Plugin validation (security, performance) - Versioning and compatibility checks - Documentation standards - Distribution via npm **Example Third-Party Plugins**: + - Advanced analytics with custom metrics - Record/replay for debugging - Security scanning for vulnerabilities From 4c1834ed3fed510f34c2f3baf631ee472b72e221 Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Thu, 10 Sep 2026 18:13:32 +0300 Subject: [PATCH 02/46] fix: commit npm install articats --- package-lock.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9e79cf3c9..ee2ab5b35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "codemie-kimi-acp": "bin/codemie-kimi-acp.js", "codemie-mcp-proxy": "bin/codemie-mcp-proxy.js", "codemie-opencode": "bin/codemie-opencode.js", + "codemie-openwiki": "bin/codemie-openwiki.js", "codemie-pi": "bin/codemie-pi.js", "proxy-daemon": "bin/proxy-daemon.js" }, @@ -3693,7 +3694,7 @@ "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -9342,7 +9343,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicorn-magic": { From 4921bc502aa06c83ceae6f97dc057420dc6fde18 Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Thu, 10 Sep 2026 19:05:43 +0300 Subject: [PATCH 03/46] chore: add commands for proxy development --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index f4b561592..2d2194ace 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,9 @@ "copy-plugin": "node scripts/copy-plugins.js", "prepare:install-artifacts": "node scripts/prepare-install-artifacts.mjs", "dev": "tsc --watch", + "proxy:start": "npm run build && node bin/codemie.js proxy connect --claude-desktop", + "proxy:state": "cat ~/.codemie/proxy-daemon.json", + "proxy:stop": "node -e \"const fs=require('fs'),os=require('os'),path=require('path');const f=path.join(os.homedir(),'.codemie','proxy-daemon.json');if(!fs.existsSync(f)){console.log('no state file at '+f);process.exit(0)}const {pid}=JSON.parse(fs.readFileSync(f,'utf8'));try{process.kill(pid)}catch(e){console.log('kill failed: '+e.message)}fs.unlinkSync(f);console.log('killed pid '+pid+' and removed '+f)\"", "test": "vitest run --project unit && vitest run --project cli && vitest run --project agent", "test:coverage": "vitest run --project unit --coverage", "test:watch": "vitest --watch", From f2dcdab6029fe26225448e338658a3a00b3d358b Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Thu, 10 Sep 2026 20:22:17 +0300 Subject: [PATCH 04/46] feat(proxy): add --cursor-ide target to connect command Wire the connect command to support --cursor-ide as an analytics-only target, short-circuiting the orchestrator with an explanatory note. --- .../actual-complexity.json | 23 +++ .../code-review-final.json | 9 ++ .../code-review.head | 1 + .../decisions.jsonl | 2 + .../events.jsonl | 4 + .../gate-plan.local.json | 16 ++ .../gate-run.json | 81 ++++++++++ .../implementation.jsonl | 2 + .../plan.md | 142 ++++++++++++++++++ .../state.local.json | 1 + .../technical-analysis.md | 129 ++++++++++++++++ .../__tests__/connect-orchestrator.test.ts | 14 ++ .../proxy/__tests__/connect-wiring.test.ts | 15 +- .../commands/proxy/connect-orchestrator.ts | 10 +- src/cli/commands/proxy/index.ts | 3 + 15 files changed, 449 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/actual-complexity.json create mode 100644 docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/code-review-final.json create mode 100644 docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/code-review.head create mode 100644 docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/decisions.jsonl create mode 100644 docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/events.jsonl create mode 100644 docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/gate-plan.local.json create mode 100644 docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/gate-run.json create mode 100644 docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/implementation.jsonl create mode 100644 docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/plan.md create mode 100644 docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/state.local.json create mode 100644 docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/technical-analysis.md diff --git a/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/actual-complexity.json b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/actual-complexity.json new file mode 100644 index 000000000..1299ff2df --- /dev/null +++ b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/actual-complexity.json @@ -0,0 +1,23 @@ +{ + "schema": 1, + "generated": "2026-09-10T00:00:00Z", + "dimensions": { + "component_scope": { "score": 3, "label": "M" }, + "requirements_clarity": { "score": 2, "label": "S" }, + "technical_risk": { "score": 2, "label": "S" }, + "file_change_estimate": { "score": 3, "label": "M" }, + "dependencies": { "score": 2, "label": "S" }, + "affected_layers": { "score": 1, "label": "XS" } + }, + "total": 13, + "size": "S", + "band_range": "10-14", + "files_changed": 6, + "routing": "writing-plans", + "key_reasoning": [ + { "dimension": "component_scope", "reason": "Adds a `--cursor-ide` target flag and gating logic (hasAnyTarget, describeTargets, TARGET_LIST, analytics-only early-return) into the existing connect-orchestrator.ts, plus wiring in index.ts — two files in one CLI command layer, no new subsystem or writer added." }, + { "dimension": "file_change_estimate", "reason": "diffstat reports 6 files changed (docs/ARCHITECTURE-PROXY.md, package.json, package-lock.json, connect-orchestrator.ts, index.ts, and two test files under __tests__/), all within the existing src/cli/commands/proxy/ directory — maps to M per the 4-6 file band." } + ], + "red_flags_applied": [], + "split_recommendation": null +} diff --git a/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/code-review-final.json b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/code-review-final.json new file mode 100644 index 000000000..e824d79ae --- /dev/null +++ b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/code-review-final.json @@ -0,0 +1,9 @@ +{ + "decision": "request-changes", + "rationale": "code-review-orchestrator could not dispatch any review lens or review-standards after two attempts: its session reports 'No such tool available: Task' (no Agent/Task dispatch tool enabled in that subagent context). review-triage was therefore never dispatched and no automated verdict could be produced. Diff was frozen successfully (1537 lines, 6 files, sha256 0927cea5f990ff892ac96252ef7492239493f32be264c5ca06fe282a56069c0d) but never reviewed.", + "confidence": "low", + "risk_flags": [], + "business_review": [], + "standards_review": [], + "findings": [] +} diff --git a/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/code-review.head b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/code-review.head new file mode 100644 index 000000000..7335591fa --- /dev/null +++ b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/code-review.head @@ -0,0 +1 @@ +3e9e0c7924f8ce39bedd39d7feea7a236f47237f diff --git a/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/decisions.jsonl b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/decisions.jsonl new file mode 100644 index 000000000..f2340896a --- /dev/null +++ b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/decisions.jsonl @@ -0,0 +1,2 @@ +{"ts":"2026-09-10T16:32:01Z","gate_id":"plan.approved","mode":"hitl","verdict":{"decision":"approve","rationale":"Plan reviewed and approved by user; leaves clean extension point for future --analytics flag","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} +{"ts":"2026-09-10T16:47:43Z","gate_id":"code-review.final","mode":"hitl","verdict":{"decision":"approve","rationale":"Automated review safe-failed (no dispatch tool available); user manually reviewed the frozen diff and approved","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} diff --git a/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/events.jsonl b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/events.jsonl new file mode 100644 index 000000000..2b1a0dfe6 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/events.jsonl @@ -0,0 +1,4 @@ +{"schema":1,"ts":"2026-09-10T16:32:01Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for plan.approved: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"plan.approved","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} +{"event":"lifecycle_emission","intent":"artifact_published","artifact_kind":"plan","status":"skipped"} +{"schema":1,"ts":"2026-09-10T16:47:43Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for code-review.final: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"code-review.final","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} +{"event":"lifecycle_emission","intent":"record_complexity_score","mode":"actual","status":"skipped"} diff --git a/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/gate-plan.local.json b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/gate-plan.local.json new file mode 100644 index 000000000..58066030a --- /dev/null +++ b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/gate-plan.local.json @@ -0,0 +1,16 @@ +{ + "schema": 1, + "runner": "npm", + "gates": [ + {"id": "license-check", "command": "npm run license-check", "source": "guide"}, + {"id": "lint", "command": "npm run lint", "source": "guide"}, + {"id": "typecheck", "command": "npm run typecheck", "source": "guide"}, + {"id": "build", "command": "npm run build", "source": "guide"}, + {"id": "unit", "command": "npx vitest run --project unit", "source": "guide"}, + {"id": "integration", "command": "npx vitest run --project cli", "source": "guide"}, + {"id": "secrets", "command": "npm run validate:secrets", "source": "hook"}, + {"id": "commitlint", "command": "npm run commitlint:last", "source": "guide"} + ], + "ui_globs": ["\\.(tsx|jsx|css|html|vue|svelte)$", "src/(ui|frontend|components)/"], + "detected_at": "2026-09-10T00:00:00Z" +} diff --git a/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/gate-run.json b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/gate-run.json new file mode 100644 index 000000000..233d0d943 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/gate-run.json @@ -0,0 +1,81 @@ +{ + "schema": 1, + "branch": "EPMCDME-14834/cursor-ide-otlp-integration", + "head": "3e9e0c7924f8ce39bedd39d7feea7a236f47237f", + "runner": "npm", + "started_at": "2026-09-10T16:50:00Z", + "completed_at": "2026-09-10T16:58:31Z", + "status": "PASSED", + "drift_detected": false, + "gates": [ + { + "id": "license-check", + "source": "guide", + "status": "PASS", + "duration_ms": 3647, + "command": "npm run license-check", + "exit_code": 0 + }, + { + "id": "lint", + "source": "guide", + "status": "PASS", + "duration_ms": 9567, + "command": "npm run lint", + "exit_code": 0 + }, + { + "id": "typecheck", + "source": "guide", + "status": "PASS", + "duration_ms": 8724, + "command": "npm run typecheck", + "exit_code": 0 + }, + { + "id": "build", + "source": "guide", + "status": "PASS", + "duration_ms": 9054, + "command": "npm run build", + "exit_code": 0 + }, + { + "id": "unit", + "source": "guide", + "status": "PASS", + "duration_ms": 78401, + "command": "npx vitest run --project unit", + "exit_code": 0, + "notes": "267 files / 3957 tests passed." + }, + { + "id": "integration", + "source": "guide", + "status": "PASS", + "duration_ms": 57650, + "command": "npx vitest run --project cli", + "exit_code": 0, + "notes": "First two attempts showed spurious failures unrelated to this branch's diff: (1) an ambient CODEMIE_MODEL env var set in this shell caused sso-claude-plugin.test.ts to fail because the test does not clean that var (fixed by rerunning with a clean env, matching CI's job env); (2) with clean env, version.test.ts and skills.test.ts hit hookTimeout/testTimeout failures from subprocess e2e CLI spawns under machine load, which passed in isolation and on a subsequent full clean rerun (37 files / 279 passed, 10 skipped). Recorded result is the final clean, non-flaky rerun: 289 tests, 0 failures." + }, + { + "id": "secrets", + "source": "hook", + "status": "SKIPPED", + "duration_ms": 1149, + "command": "npm run validate:secrets", + "exit_code": 0, + "notes": "Self-skip: script prints \"No staged changes to scan\" and exits 0 because validate-secrets.js only scans `git diff --staged` (scripts/validate-secrets.js:87-96); no files were staged during this run. To run it for real locally, `git add` the changed files before invoking `npm run validate:secrets`. CI's gitleaks-action scans the full PR diff unconditionally, so this check is still owed to CI." + }, + { + "id": "commitlint", + "source": "guide", + "status": "PASS", + "duration_ms": 1346, + "command": "npm run commitlint:last", + "exit_code": 0, + "notes": "Checks HEAD~1..HEAD per the guide; CI's validate-commits job instead checks the full PR base..head range and also lints the PR title, neither of which is reproducible outside a GitHub PR context (owed to CI)." + } + ], + "failures": {} +} diff --git a/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/implementation.jsonl b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/implementation.jsonl new file mode 100644 index 000000000..2d05c29f5 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/implementation.jsonl @@ -0,0 +1,2 @@ +{"task_id":"task-1","status":"done","commit":"2013498","test_command":"npx vitest run src/cli/commands/proxy/__tests__/connect-wiring.test.ts"} +{"task_id":"task-2","status":"done","commit":"3e9e0c7","test_command":"npx vitest run src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts"} diff --git a/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/plan.md b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/plan.md new file mode 100644 index 000000000..0d62dfe9d --- /dev/null +++ b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/plan.md @@ -0,0 +1,142 @@ +# Cursor IDE Connect Stub Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a real `--cursor-ide` Commander option to `codemie proxy connect` that, when it is the only target passed, prints "Only analytics is supported" and exits cleanly — no daemon, profile, or config-writing side effects. + +**Architecture:** Extend the existing unified-target model (`ConnectTargets`, `hasAnyTarget`, `describeTargets`, `TARGET_LIST` in `connect-orchestrator.ts`) with a `cursorIde` field so help text and the real target set never drift, then add one early-return branch inside `connectTargets()` — placed right after the existing `hasAnyTarget()` guard, before any daemon/profile work — that fires only when `cursorIde` is the sole selected target. + +**Tech Stack:** TypeScript, Commander, Vitest, chalk (existing project conventions — no new dependencies). + +**Requirements source:** inline (no spec.md) — see Acceptance criteria below and the full requirements text carried in this task's `technical-analysis.md`. + +**Technical analysis:** `docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/technical-analysis.md` + +## Global Constraints + +- Stub only: no real Cursor IDE connector, config writer, or OTLP/analytics integration in this task. +- `--analytics` does not exist and must not be invented here. +- The cursor-ide-only path must never call `resolveSsoProxyConfig` or `ensureDaemon`. +- Commit per task using the repository's existing convention (Conventional Commits per `.ai-run/guides/standards/git-workflow.md`); no commit commands are shown in this plan. +- No task in this plan runs the whole-suite quality gate, manual/browser verification, or code review — the calling flow owns those. + +## Acceptance criteria + +- [ ] `codemie proxy connect --help` lists `--cursor-ide` alongside the other targets. +- [ ] `codemie proxy connect --cursor-ide` (alone) prints a message containing "Only analytics is supported" and exits with code 0. +- [ ] `codemie proxy connect --cursor-ide` (alone) never calls `resolveSsoProxyConfig` or the daemon lifecycle (`checkStatus`/`spawnDaemon`/`ensureDaemon`). +- [ ] `--cursor-ide` combined with a real target (e.g. `--claude-desktop`) proceeds with the real target's normal flow; the cursor-ide branch is skipped, not an error. +- [ ] `hasAnyTarget`, `describeTargets`, and `TARGET_LIST` all recognize `cursorIde` so `--help`/target-list output and the real target set stay consistent. +- [ ] No `--analytics` flag, real connector, config writer, or OTLP integration is added. + +--- + +### Task 1: Add `cursorIde` to the target model and command wiring + +**Files:** +- Modify: `src/cli/commands/proxy/connect-orchestrator.ts:56-61` (`ConnectTargets`), `:265-280` (`TARGET_LIST`), `:282-284` (`hasAnyTarget`), `:287-296` (`describeTargets`) +- Modify: `src/cli/commands/proxy/index.ts:33-43` (`UnifiedConnectOptions`), `:290-299` (`.option(...)` chain), `:300-314` (`.action()` body) +- Test: `src/cli/commands/proxy/__tests__/connect-wiring.test.ts` + +**Interfaces:** +- Produces: `ConnectTargets.cursorIde?: boolean`, consumed by `hasAnyTarget`, `describeTargets`, and by Task 2's short-circuit branch in `connectTargets()`. + +- [ ] **Step 1: Write the failing test** + +Add to `connect-wiring.test.ts`, alongside the existing `'unified connect maps target flags to a ConnectTargets set'` test: + +```ts +it('unified connect maps --cursor-ide into the ConnectTargets set', async () => { + const { connectTargets } = await import('../connect-orchestrator.js'); + const { createProxyCommand } = await import('../index.js'); + + await createProxyCommand().parseAsync(['connect', '--cursor-ide'], { from: 'user' }); + + expect(connectTargets).toHaveBeenCalledWith( + expect.objectContaining({ targets: expect.objectContaining({ cursorIde: true }) }) + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/cli/commands/proxy/__tests__/connect-wiring.test.ts -t "cursor-ide"` +Expected: FAIL — `--cursor-ide` is not a recognized option (Commander throws) or `cursorIde` is `undefined`. + +- [ ] **Step 3: Implement** + +- `connect-orchestrator.ts`: add `cursorIde?: boolean;` to `ConnectTargets` (line ~61). Add `' --cursor-ide Cursor IDE — analytics only for now',` to `TARGET_LIST` (after the `--codex-desktop` line, before the blank line at ~272). Change `hasAnyTarget` to `Boolean(t.claudeDesktop || t.vscode || t.vscodeClaudeCode || t.codexDesktop || t.cursorIde);`. In `describeTargets`, add `if (t.cursorIde) { flags.push('--cursor-ide'); labels.push('Cursor IDE'); }` alongside the other branches. +- `index.ts`: add `cursorIde?: boolean;` to `UnifiedConnectOptions`. Add `.option('--cursor-ide', 'Configure Cursor IDE — analytics only for now')` to the `.option(...)` chain (after `--codex-desktop`, before `--model`). In the `.action()` body's `targets` object, add `cursorIde: Boolean(opts.cursorIde),`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/cli/commands/proxy/__tests__/connect-wiring.test.ts` +Expected: PASS (including the pre-existing tests in this file, unaffected by the new field being optional). + +Test-first: yes — connect-wiring test asserting `--cursor-ide` maps to `targets.cursorIde: true`. + +--- + +### Task 2: Short-circuit `connectTargets()` when `cursorIde` is the only target + +**Files:** +- Modify: `src/cli/commands/proxy/connect-orchestrator.ts:590-595` (inside `connectTargets()`, immediately after the `hasAnyTarget` guard and before the `--insiders`/`--model` Note branches at `:600-608`) +- Test: `src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts` + +**Interfaces:** +- Consumes: `ConnectTargets.cursorIde` (Task 1), `hasAnyTarget` (existing). +- Produces: no new exports — this is an internal branch in the existing `connectTargets()` export. + +- [ ] **Step 1: Write the failing test** + +Add to the `'connectTargets — no-write paths and daemon lifecycle'` describe block in `connect-orchestrator.test.ts`, mirroring the existing `'bare connect: prints the target list...'` test: + +```ts +it('--cursor-ide alone prints the analytics-only note and does no daemon/profile work', async () => { + const { ConfigLoader } = await import('../../../../utils/config.js'); + const { checkStatus, spawnDaemon } = await import('../daemon-manager.js'); + const { connectTargets } = await import('../connect-orchestrator.js'); + + await connectTargets({ targets: { cursorIde: true } }); + + expect(ConfigLoader.load).not.toHaveBeenCalled(); + expect(checkStatus).not.toHaveBeenCalled(); + expect(spawnDaemon).not.toHaveBeenCalled(); + expect(console_.log()).toHaveBeenCalledWith(expect.stringContaining('Only analytics is supported')); + expect(process.exitCode).toBe(0); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts -t "cursor-ide"` +Expected: FAIL — no "Only analytics is supported" message is printed; execution falls through toward `resolveSsoProxyConfig`. + +- [ ] **Step 3: Implement** + +In `connectTargets()`, right after the `hasAnyTarget` guard (`connect-orchestrator.ts:592-595`) and before the `verbose`/`insiders` Note branches, add: + +```ts +if (targets.cursorIde && !targets.claudeDesktop && !targets.vscode && !targets.vscodeClaudeCode && !targets.codexDesktop) { + console.log(chalk.yellow('Note: Only analytics is supported for --cursor-ide.')); + return; +} +``` + +This mirrors the existing `chalk.yellow('Note: ...')` / `console.log` convention used two branches below for `--insiders`/`--model`, and returns before `resolveSsoProxyConfig`/`ensureDaemon` are ever reached. When `cursorIde` is combined with a real target, this condition is false and execution proceeds normally into the real target's flow — the cursor-ide flag is simply inert for this run (no separate warning needed, since `describeTargets`/`hasAnyTarget` already account for it and no connector reads it). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts` +Expected: PASS (including all pre-existing tests in the file). + +Test-first: yes — connect-orchestrator test asserting the cursor-ide-only short-circuit prints the note and skips `ConfigLoader.load`/`checkStatus`/`spawnDaemon`. + +--- + +## Negative-constraint pass + +- "No real Cursor IDE connector/config writer/OTLP integration in this task" — honored: Task 1 only touches the target-model plumbing (types, help text, target-detection functions) and Task 2 only adds a `console.log` + `return`; neither task creates a file under `connectors/` or touches telemetry/OTel code. +- "`--analytics` does not exist and is out of scope" — honored: no task adds an `--analytics` option, field, or reference anywhere; Task 2's message is a static string, not conditioned on any analytics flag. +- "Cursor-ide-only path must never call `resolveSsoProxyConfig`/`ensureDaemon`" — honored and test-covered: Task 2's branch returns before the `resolveSsoProxyConfig` call at `connect-orchestrator.ts:627`, and its test explicitly asserts `ConfigLoader.load`/`checkStatus`/`spawnDaemon` are not called. +- Docs (`docs/ARCHITECTURE-PROXY.md`, `.ai-run/guides/integration/exposed-api.md`): checked both; neither contains a discrete "supported connect targets" list to extend without a disproportionate restructure (`exposed-api.md` has no matching content at all; `ARCHITECTURE-PROXY.md`'s target-related mentions are embedded in larger daemon/telemetry sections). Judged out of scope to keep this a small, single-file-cluster change per the requirements' own instruction to keep changes small — no doc task added. diff --git a/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/state.local.json b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/state.local.json new file mode 100644 index 000000000..deeae6701 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/state.local.json @@ -0,0 +1 @@ +{"schema":1,"flow":"sdlc-light","slug":"codemie-proxy-connect-cursor-ide","branch":"EPMCDME-14834/cursor-ide-otlp-integration","phase":"done","stage":9,"session_id":"e14956af-9b0e-462a-b33d-99ef3e123c26","started_at":"2026-09-10T16:06:26Z","updated_at":"2026-09-10T17:03:35Z","completed_at":"2026-09-10T17:03:35Z"} diff --git a/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/technical-analysis.md b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/technical-analysis.md new file mode 100644 index 000000000..6b8757b23 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-10-codemie-proxy-connect-cursor-ide/technical-analysis.md @@ -0,0 +1,129 @@ +# Technical Research + +**Task**: proxy connect cursor-ide analytics +**Generated**: 2026-09-10T00:00:00Z +**Research path**: filesystem + +--- + +## 1. Original Context + +"`codemie proxy connect --cursor-ide` command should just say that 'Only analytics is supported'" + +--- + +## 2. Codebase Findings + +### Existing Implementations + +- No `--cursor-ide` (or `cursorIde`) option currently exists anywhere in `src/`. A repo-wide grep for `cursor-ide` / `cursorIde` / `cursor_ide` returns no matches. This is a new flag, not a modification of existing behavior. +- The only unrelated "cursor" hits in `src/` are: terminal-cursor ANSI handling in `src/cli/commands/shared/selection/interactive-prompt.ts` (`cursorIndex`, `ANSI.CURSOR_HOME_CLEAR`), and an existing, unrelated "Cursor" **editor** concept used by the `codemie assistants`/`skills` setup flows (`src/cli/commands/shared/agent-targets.ts`, `src/cli/commands/assistants/setup/**`, `src/cli/commands/skills/lib/agent-detection.ts`) which detect a `.cursor/` directory for skill/rule installation. None of that code is wired to `proxy connect`. +- `.ai-run/guides/integration/external-integrations.md:287` documents "Best-effort agent auto-detection (`--agent cursor` if `.cursor/` present)" for the skills/assistants domain — a separate command family, not proxy. +- No existing "analytics-only" support for Cursor was found in `src/telemetry/` or elsewhere; a grep for `-i cursor` under `src/telemetry` and `src/analytics` returns nothing. The claim "Only analytics is supported" is the informational message the task wants printed, not a reference to already-implemented Cursor telemetry ingestion. + +### Architecture and Layers Affected + +- **CLI command layer**: `src/cli/commands/proxy/index.ts` — this is where the unified `connect` command (`createProxyCommand()`) declares all target flags (`--claude-desktop`, `--vscode`, `--vscode-claude-code`, `--codex-desktop`, plus shared `--profile`, `--force`, `--verbose`, `--insiders`, `--model`) via Commander `.option(...)` calls (lines 290-299) and dispatches to `connectTargets()` in its `.action()` (lines 300-314). +- **Orchestration layer**: `src/cli/commands/proxy/connect-orchestrator.ts` — `connectTargets()` (the single entry point all target flags funnel into) builds a `ConnectTargets` object, resolves the SSO profile, starts/reuses the proxy daemon, and dispatches to per-target runner functions (`runClaudeDesktop`, `runVscodeByok`, `runVscodeClaudeCode`, `runCodexDesktop`). `ConnectTargets` (lines 56-61) and `hasAnyTarget()` (line 282) currently enumerate exactly four targets; Cursor is not one of them. +- No repository/model or persistence layer is implicated — this is purely a CLI presentation/dispatch concern. + +### Integration Points + +- `UnifiedConnectOptions` interface in `src/cli/commands/proxy/index.ts` (lines 33-43) is the Commander options bag passed into the action; any new flag must be added both as a `.option(...)` declaration and as a field on this interface. +- `connect-orchestrator.ts` imports connectors under `./connectors/` (`desktop.ts`, `vscode.ts`, `vscode-claude-code.ts`, `codex-desktop.ts`, `managed-mcp-remote.ts`) — these are the actual config-writing integrations for each supported target. A Cursor target, if it ever did real work, would live here; the task explicitly says it should not. +- `daemon-manager.ts` (`checkStatus`, `spawnDaemon`, `stopDaemon`, `readState`, `writeState`) backs the shared local proxy daemon lifecycle that every real target shares via `ensureDaemon()`. + +### Patterns and Conventions + +- **Existing "no-op / informational note" pattern already present in this same file**, directly reusable for the new message: + - `connectTargets()` prints an "options with no effect" note without touching the daemon or profile logic, e.g. (`connect-orchestrator.ts:600-608`): + ```ts + if (insiders && !targets.vscode && !targets.vscodeClaudeCode) { + console.log(chalk.yellow('Note: --insiders has no effect without a VS Code target (--vscode / --vscode-claude-code).')); + } + if (opts.model && !targets.codexDesktop) { + console.log(chalk.yellow('Note: --model has no effect without --codex-desktop.')); + } + ``` + - Bare invocation with no target flags already short-circuits before any daemon/profile work: `hasAnyTarget()` check at the top of `connectTargets()` (lines 592-595) prints `TARGET_LIST` and returns immediately — this is the closest existing precedent for "flag recognized, but no proxy connect side effects occur." +- **Deprecation-notice pattern** as a second precedent for a flag that prints a message and does *not* do what the flag name might suggest at face value: `printConnectDeprecation()` (`index.ts:52-60`) prints a highlighted `chalk.bold.yellow(...)` line before delegating. A Cursor stub could follow the same "print, then return/skip" shape but without the delegation. +- **Message styling conventions observed** across this file: `chalk.green('✓ ...')` for success, `chalk.yellow('⚠ ...')` / `chalk.yellow('Note: ...')` for warnings/notes, `chalk.red('✗ ...')` for failure, `chalk.dim(...)` for supplementary detail, `chalk.cyan(...)` for informational file/profile lines. All output goes through `console.log`/`console.error`, not `logger.*`, for user-facing text; `logger.*` (from `src/utils/logger.ts`, imported at `connect-orchestrator.ts:18`) is reserved for the debug/audit log file and is paired with `sanitizeLogArgs()` (`src/utils/security.ts`) whenever structured context is logged. +- **Error/exit conventions**: `printProxyError()` (`connect-orchestrator.ts:250-261`) is the single place that logs via `logger.error`, prints a red `✗ ...` line, and calls `process.exit(1)` for command-level failures. Per-target partial failures instead set `process.exitCode = 1` (line 701) without exiting immediately, letting the summary print first. Since "Only analytics is supported" is informational rather than an error, neither of these exit paths is the right model — the closest fit is the plain `console.log` + `return` used for the no-target case and the "Note:" cases, i.e. exit code 0, no daemon interaction, no summary block. +- Commander wiring: options are declared with `.option('--flag-name', 'description')`; multi-word flags map camelCase automatically (`--vscode-claude-code` → `opts.vscodeClaudeCode`), so `--cursor-ide` would map to `opts.cursorIde` with no extra Commander config needed. + +--- + +## 3. Documentation Findings + +### Guides and Architecture Docs + +- `.ai-run/guides/integration/exposed-api.md` documents the CLI surface, including the proxy connect/disconnect endpoints — relevant to keep the new flag's help text and documented surface consistent, but it was not read in full here (this analysis focused on source-of-truth code rather than duplicating the guide's content). +- `.ai-run/guides/integration/external-integrations.md:287` documents Cursor only in the unrelated `--agent cursor` skills-detection context, confirming there is no existing Cursor-proxy documentation to reconcile. +- `docs/ARCHITECTURE-PROXY.md` exists and was recently refreshed (`106e829 docs: refresh ARCHITECTURE-PROXY.md`, current git log) — likely worth a follow-up read/update once the implementation approach is decided, since it documents the proxy's supported client targets. + +### Architectural Decisions + +- No ADR or inline `NOTE:`/`DECISION:` marker referencing Cursor was found. The existing "shared daemon, per-target dispatch" design (`connect-orchestrator.ts` header comment, lines 1-8) is the operative architectural decision for how targets are composed; a Cursor branch that does nothing to the daemon is consistent with, not a violation of, that design as long as it is short-circuited before `ensureDaemon()` is reached. + +### Derived Conventions + +- Flags that need to communicate "recognized but not actionable in this way" already exist in this exact file (the `--insiders`-without-VS Code-target and `--model`-without-`--codex-desktop` notes) and print via `chalk.yellow('Note: ...')` before any daemon work begins. This is the most directly reusable convention for the "Only analytics is supported" message. + +--- + +## 4. Testing Landscape + +### Existing Coverage + +- `src/cli/commands/proxy/__tests__/index.test.ts` (30.1K) — command wiring/help-text level tests for `createProxyCommand()`. +- `src/cli/commands/proxy/__tests__/connect-wiring.test.ts` — verifies flag-to-`ConnectTargets` mapping for the unified command and deprecated aliases (`unified connect maps target flags to a ConnectTargets set`, `--codex-desktop and --model` mapping test, etc.). This is the natural home for a new "`--cursor-ide` prints the analytics-only message and does not call `connectTargets`'s daemon path" test, following the existing pattern of asserting on the options object / mocked `connectTargets` call. +- `src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts` (24.7K) — exercises `connectTargets()` internals (`hasAnyTarget`, per-target runners, daemon lifecycle). No Cursor-related cases exist yet. +- `src/cli/commands/proxy/__tests__/daemon-status-contract.test.ts`, `daemon-manager.test.ts`, `disconnect-orchestrator.test.ts`, `health-check.test.ts`, `watcher.test.ts` — none reference Cursor. + +### Testing Framework and Patterns + +- Vitest (`describe`/`it`/`expect`/`vi` from `vitest`, per `connect-wiring.test.ts:7`). Tests mock `connectTargets`/`connect-orchestrator` module functions via `vi.mock` and assert on call arguments rather than exercising the real daemon, matching the layering already present in `connect-wiring.test.ts`. + +### Coverage Gaps + +- No test currently asserts on the "no target flags" (`hasAnyTarget` false) console output, though the code path exists — worth noting as the nearest analog test that a new Cursor test could mirror. +- No test exists for any "informational-only, non-error" flag path outputting a specific message and returning without side effects; the `--insiders`/`--model` "Note:" branches likewise appear untested. A `--cursor-ide` test will be first-of-kind for this exact shape (message-only, zero side effects, exit code 0) and should be added net-new rather than extended from an existing case. + +--- + +## 5. Configuration and Environment + +### Environment Variables + +- None specific to Cursor or to this flag. General proxy env/config resolution goes through `ConfigLoader` (`src/utils/config.js`) and `ProviderRegistry` (`src/providers/index.js`), both imported in `connect-orchestrator.ts`, but the analytics-only Cursor message would not need to reach either. + +### Configuration Files + +- No config file governs Cursor-specific behavior in this codebase today. The real targets' config outputs (`~/.codex/config.toml`, Claude Desktop's MCP servers file, VS Code's `settings.json`/`chatLanguageModels.json`) are unrelated to what this task needs. + +### Feature Flags and Deployment Concerns + +- None found. This is a pure CLI-output change with no deployment, secrets, or feature-flag surface. + +--- + +## 6. Risk Indicators + +- Speculative: The natural implementation point is a new `.option('--cursor-ide', ...)` on the `connect` command in `src/cli/commands/proxy/index.ts` plus a field on `UnifiedConnectOptions`, with the actual short-circuit logic living either directly in the command's `.action()` (checked before calling `connectTargets`) or as an early branch inside `connectTargets()` in `connect-orchestrator.ts` alongside the existing `hasAnyTarget()` check — whichever keeps parity with how `--insiders`/`--model` "Note:" messages are already gated ahead of daemon startup. +- Speculative: because `hasAnyTarget()` (line 282) and `describeTargets()` (line 287) both enumerate a fixed four-target list, if `--cursor-ide` is added to `ConnectTargets` at all (rather than being intercepted purely at the CLI-option level before reaching the orchestrator), those two functions and the `TARGET_LIST` help text (lines 265-280) would need to stay consistent with whatever choice is made, or the bare-invocation help text and the real target list will drift. +- Risk: no test currently covers a "message-only, no side effects" flag branch, so a naive implementation could accidentally fall through into `resolveSsoProxyConfig`/`ensureDaemon` if the early-return is misplaced — the existing `hasAnyTarget()` early-return at the very top of `connectTargets()` (before profile resolution) is the safest reference point to mirror. +- Risk (low): `docs/ARCHITECTURE-PROXY.md` and `.ai-run/guides/integration/exposed-api.md` document the proxy's supported connect targets; if `--cursor-ide` is added to the command's `--help` output, these docs may go stale unless updated in the same change (not verified against their exact current content in this pass). + +--- + +## 7. Summary for Complexity Assessment + +This task touches exactly one architectural layer in depth — the CLI command/dispatch layer (`src/cli/commands/proxy/index.ts` and `src/cli/commands/proxy/connect-orchestrator.ts`) — and does not require any new connector, daemon, config-writer, or persistence work, since the desired behavior is explicitly "print a message, do nothing else." No `--cursor-ide` flag exists today anywhere in the codebase; this is a greenfield flag addition, not a modification of an existing Cursor connect path. The repository already contains two directly reusable precedents for exactly this shape: the bare-invocation early return (`hasAnyTarget()` guard before any profile/daemon work) and the `chalk.yellow('Note: ...')` messages already used for flags that have "no effect" in certain combinations — both localized to `connect-orchestrator.ts`. + +Technical novelty is low: no new patterns, external integrations, or data models are needed, and the change is expressible as a small early-return branch plus a Commander option declaration. Test coverage for this specific shape (informational-only flag, zero side effects, exit code 0) does not exist yet, but the existing Vitest suites (`connect-wiring.test.ts`, `connect-orchestrator.test.ts`) provide a clear, already-established testing pattern to extend. The main risk is not technical difficulty but consistency: whether `--cursor-ide` is intercepted before or after entering `connectTargets()`, and whether the fixed four-target enumerations (`ConnectTargets`, `hasAnyTarget()`, `describeTargets()`, `TARGET_LIST`, and the command's `--help` text) need to acknowledge the new flag without implying it configures anything. Overall this reads as a small, low-risk, single-file-cluster change. + +--- + +## 8. External References + +None named by the task. diff --git a/src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts b/src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts index fd51145ae..712cf0aee 100644 --- a/src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts +++ b/src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts @@ -244,6 +244,20 @@ describe('connectTargets — no-write paths and daemon lifecycle', () => { expect(process.exitCode).toBe(0); }); + it('--cursor-ide alone prints the analytics-only note and does no daemon/profile work', async () => { + const { ConfigLoader } = await import('../../../../utils/config.js'); + const { checkStatus, spawnDaemon } = await import('../daemon-manager.js'); + const { connectTargets } = await import('../connect-orchestrator.js'); + + await connectTargets({ targets: { cursorIde: true } }); + + expect(ConfigLoader.load).not.toHaveBeenCalled(); + expect(checkStatus).not.toHaveBeenCalled(); + expect(spawnDaemon).not.toHaveBeenCalled(); + expect(console_.log()).toHaveBeenCalledWith(expect.stringContaining('Only analytics is supported')); + expect(process.exitCode).toBe(0); + }); + it('--insiders with only --claude-desktop warns and continues', async () => { await setupHappyMocks(); const { checkStatus, spawnDaemon } = await import('../daemon-manager.js'); diff --git a/src/cli/commands/proxy/__tests__/connect-wiring.test.ts b/src/cli/commands/proxy/__tests__/connect-wiring.test.ts index cc890c03e..0f9bb3ac3 100644 --- a/src/cli/commands/proxy/__tests__/connect-wiring.test.ts +++ b/src/cli/commands/proxy/__tests__/connect-wiring.test.ts @@ -44,11 +44,22 @@ describe('proxy connect — unified command and deprecated aliases', () => { expect(connectTargets).toHaveBeenCalledWith( expect.objectContaining({ - targets: { claudeDesktop: true, vscode: true, vscodeClaudeCode: true, codexDesktop: false }, + targets: { claudeDesktop: true, vscode: true, vscodeClaudeCode: true, codexDesktop: false, cursorIde: false }, }) ); }); + it('unified connect maps --cursor-ide into the ConnectTargets set', async () => { + const { connectTargets } = await import('../connect-orchestrator.js'); + const { createProxyCommand } = await import('../index.js'); + + await createProxyCommand().parseAsync(['connect', '--cursor-ide'], { from: 'user' }); + + expect(connectTargets).toHaveBeenCalledWith( + expect.objectContaining({ targets: expect.objectContaining({ cursorIde: true }) }) + ); + }); + it('deprecated `connect desktop` prints a notice and delegates to { claudeDesktop: true }', async () => { const { connectTargets } = await import('../connect-orchestrator.js'); const { createProxyCommand } = await import('../index.js'); @@ -175,7 +186,7 @@ describe('deprecated aliases forward their flags under real CLI nesting', () => expect(connectTargets).toHaveBeenCalledWith( expect.objectContaining({ - targets: { claudeDesktop: false, vscode: true, vscodeClaudeCode: false, codexDesktop: false }, + targets: { claudeDesktop: false, vscode: true, vscodeClaudeCode: false, codexDesktop: false, cursorIde: false }, profile: 'p', }) ); diff --git a/src/cli/commands/proxy/connect-orchestrator.ts b/src/cli/commands/proxy/connect-orchestrator.ts index fe5392cb7..552605961 100644 --- a/src/cli/commands/proxy/connect-orchestrator.ts +++ b/src/cli/commands/proxy/connect-orchestrator.ts @@ -58,6 +58,7 @@ export interface ConnectTargets { vscode?: boolean; vscodeClaudeCode?: boolean; codexDesktop?: boolean; + cursorIde?: boolean; } /** Options for a unified `connect` run (built by the command/alias wrappers). */ @@ -269,6 +270,7 @@ const TARGET_LIST = [ ' --vscode VS Code Copilot Chat models (BYOK)', ' --vscode-claude-code VS Code Claude Code extension', ' --codex-desktop Codex desktop app (writes ~/.codex/config.toml)', + ' --cursor-ide Cursor IDE — analytics only for now', '', 'Examples:', ' codemie proxy connect --claude-desktop', @@ -280,7 +282,7 @@ const TARGET_LIST = [ ].join('\n'); function hasAnyTarget(t: ConnectTargets): boolean { - return Boolean(t.claudeDesktop || t.vscode || t.vscodeClaudeCode || t.codexDesktop); + return Boolean(t.claudeDesktop || t.vscode || t.vscodeClaudeCode || t.codexDesktop || t.cursorIde); } /** A human label and the base command to echo in remediation messages. */ @@ -291,6 +293,7 @@ function describeTargets(t: ConnectTargets): { label: string; commandExample: st if (t.vscode) { flags.push('--vscode'); labels.push('VS Code'); } if (t.vscodeClaudeCode) { flags.push('--vscode-claude-code'); labels.push('VS Code Claude Code'); } if (t.codexDesktop) { flags.push('--codex-desktop'); labels.push('Codex Desktop'); } + if (t.cursorIde) { flags.push('--cursor-ide'); labels.push('Cursor IDE'); } const label = labels.length === 1 ? labels[0] : 'CodeMie'; return { label, commandExample: `codemie proxy connect ${flags.join(' ')}` }; } @@ -594,6 +597,11 @@ export async function connectTargets(opts: ConnectOptions): Promise { return; } + if (targets.cursorIde && !targets.claudeDesktop && !targets.vscode && !targets.vscodeClaudeCode && !targets.codexDesktop) { + console.log(chalk.yellow('Note: Only analytics is supported for --cursor-ide.')); + return; + } + const verbose = Boolean(opts.verbose); const insiders = Boolean(opts.insiders); diff --git a/src/cli/commands/proxy/index.ts b/src/cli/commands/proxy/index.ts index 7236e5643..bf811a574 100644 --- a/src/cli/commands/proxy/index.ts +++ b/src/cli/commands/proxy/index.ts @@ -35,6 +35,7 @@ interface UnifiedConnectOptions { vscode?: boolean; vscodeClaudeCode?: boolean; codexDesktop?: boolean; + cursorIde?: boolean; profile?: string; force?: boolean; verbose?: boolean; @@ -292,6 +293,7 @@ export function createProxyCommand(): Command { .option('--vscode', 'Configure VS Code Copilot Chat models — BYOK (writes chatLanguageModels.json)') .option('--vscode-claude-code', 'Configure the VS Code Claude Code extension (writes settings.json: ANTHROPIC_BASE_URL/token)') .option('--codex-desktop', 'Configure the Codex desktop app (writes ~/.codex/config.toml)') + .option('--cursor-ide', 'Configure Cursor IDE — analytics only for now') .option('--model ', 'Pin a specific model for --codex-desktop (default: best available)') .option('--profile ', 'Profile whose credentials to use') .option('--force', 'Stop any existing proxy and start a fresh one, even if it looks healthy') @@ -304,6 +306,7 @@ export function createProxyCommand(): Command { vscode: Boolean(opts.vscode), vscodeClaudeCode: Boolean(opts.vscodeClaudeCode), codexDesktop: Boolean(opts.codexDesktop), + cursorIde: Boolean(opts.cursorIde), }, profile: opts.profile, insiders: Boolean(opts.insiders), From f65363b0f45e8c920e40ea4a1f7e95cb9b99da58 Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Sat, 12 Sep 2026 11:42:52 +0300 Subject: [PATCH 05/46] feat(cli): add --agent flag and non-blocking exit gating to codemie hook Adds --agent to `codemie hook` so a hook process CodeMie did not spawn (Cursor's hooks.json has no env key) can identify its agent; precedence is flag beats CODEMIE_AGENT env. Splits resolveAgentName out of initializeLoggerContext and threads it through initializeHookContext. Reorders the CLI action body to resolve the agent before parsing stdin, removes the pre-transform duplicate session_id/hook_event_name checks (validateHookEvent already covers both), and adds two declarative AgentHookConfig flags - transcriptOptional and neverBlockingExit - so an agent can opt out of the transcript_path requirement and out of the blocking exit-2 behavior via AgentRegistry lookup, with no agent-name literal in the routing path. cursor-ide-hooks-analytics EPMCDME-14834, tasks 1-2 --- src/agents/core/types.ts | 17 ++++ src/cli/commands/hook.ts | 166 ++++++++++++++++++++++++++------------- 2 files changed, 129 insertions(+), 54 deletions(-) diff --git a/src/agents/core/types.ts b/src/agents/core/types.ts index d38c5b743..8edb23557 100644 --- a/src/agents/core/types.ts +++ b/src/agents/core/types.ts @@ -664,6 +664,23 @@ export interface AgentHookConfig { * } */ eventNameMapping?: Record; + + /** + * When true, `validateHookEvent` treats `transcript_path`/`transcript_paths` + * as optional for every event from this agent, not just SessionStart/SessionEnd. + * Set by agents (e.g. cursor-ide) whose native payloads frequently omit a + * transcript path. + */ + transcriptOptional?: boolean; + + /** + * When true, the `codemie hook` CLI path never exits non-zero for this + * agent: JSON-parse failures and `validateHookEvent` failures degrade to a + * non-blocking failure (thrown internally, caught, and turned into a + * successful exit) instead of `process.exit(2)`. Set by agents whose host + * treats a non-zero exit as a hard block on the user's action. + */ + neverBlockingExit?: boolean; } /** diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index 5e91b9fed..fd0b96f5e 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -130,8 +130,16 @@ function getConfigValue(envKey: string, config?: HookProcessingConfig): string | * @returns The CodeMie session ID from environment * @throws Error if required environment variables are missing */ -function initializeLoggerContext(): string { - const agentName = process.env.CODEMIE_AGENT; +/** + * Resolve the agent name for this hook invocation. + * + * Precedence: explicit `--agent ` flag beats `CODEMIE_AGENT` env; when + * neither is present the current throwing behavior is unchanged (a hook + * process CodeMie did not spawn, e.g. Cursor's `hooks.json` has no `env` key + * to inherit `CODEMIE_AGENT` through, so it must self-identify via the flag). + */ +function resolveAgentName(agentFlag?: string): string { + const agentName = agentFlag || process.env.CODEMIE_AGENT; if (!agentName) { // Debug: Log all environment variables that start with CODEMIE_ const codemieEnvVars = Object.keys(process.env) @@ -141,7 +149,23 @@ function initializeLoggerContext(): string { console.error(`[hook:debug] CODEMIE_AGENT missing. Available CODEMIE_* vars: ${codemieEnvVars || 'none'}`); throw new Error('CODEMIE_AGENT environment variable is required'); } + return agentName; +} +/** + * Initialize logger context using CODEMIE_SESSION_ID + * + * Uses CODEMIE_SESSION_ID from environment for: + * - Logging (logger.setSessionId) + * - Session files (~/.codemie/sessions/{sessionId}.json) + * - Metrics files (~/.codemie/sessions/{sessionId}_metrics.jsonl) + * - Conversation files (~/.codemie/sessions/{sessionId}_conversation.jsonl) + * + * @param agentName - Resolved agent name (flag, then CODEMIE_AGENT env) + * @returns The CodeMie session ID from environment + * @throws Error if required environment variables are missing + */ +function initializeLoggerContext(agentName: string): string { // Use CODEMIE_SESSION_ID from environment const sessionId = process.env.CODEMIE_SESSION_ID; if (!sessionId) { @@ -1298,58 +1322,93 @@ async function sendSessionEndMetrics(event: SessionEndEvent, sessionId: string, } } +/** + * Look up whether an agent has declared `neverBlockingExit` on its + * `metadata.hookConfig` — a fully declarative gate (no agent-name literal) + * that lets a hook path degrade to non-blocking failure instead of exiting + * non-zero. Set only by agents (e.g. cursor-ide) whose host never tolerates + * a blocking exit code. + */ +function agentNeverBlocks(agentName?: string): boolean { + if (!agentName) { + return false; + } + try { + const agent = AgentRegistry.getAgent(agentName); + return Boolean(agent?.metadata?.hookConfig?.neverBlockingExit); + } catch { + return false; + } +} + +/** + * Look up whether an agent has declared `transcriptOptional` on its + * `metadata.hookConfig` — lets `validateHookEvent` treat transcript_path as + * optional for every event for that agent, not just SessionStart/SessionEnd. + */ +function agentTranscriptOptional(agentName?: string): boolean { + if (!agentName) { + return false; + } + try { + const agent = AgentRegistry.getAgent(agentName); + return Boolean(agent?.metadata?.hookConfig?.transcriptOptional); + } catch { + return false; + } +} + /** * Validate hook event required fields * @param event - Hook event to validate * @param config - Optional configuration object (if provided, throws errors; otherwise sets exitCode) - * @throws Error if validation fails and config is provided + * @param agentName - Resolved agent name (CLI mode only); used to look up declarative + * `neverBlockingExit`/`transcriptOptional` hook-config flags via AgentRegistry + * @throws Error if validation fails and config is provided, or the resolved agent + * declares `neverBlockingExit` */ -function validateHookEvent(event: BaseHookEvent, config?: HookProcessingConfig): void { - if (!event.session_id) { - const error = new Error('Missing required field: session_id'); - if (config) { +function validateHookEvent(event: BaseHookEvent, config?: HookProcessingConfig, agentName?: string): void { + const neverBlocks = agentNeverBlocks(agentName); + + const fail = (message: string): void => { + const error = new Error(message); + if (config || neverBlocks) { throw error; } - logger.error('[hook] Missing required field: session_id'); + logger.error(`[hook] ${message}`); logger.debug(`[hook] Received event: ${JSON.stringify(event)}`); process.exitCode = 2; + }; + + if (!event.session_id) { + fail('Missing required field: session_id'); return; } if (!event.hook_event_name) { - const error = new Error('Missing required field: hook_event_name'); - if (config) { - throw error; - } - logger.error('[hook] Missing required field: hook_event_name'); - logger.debug(`[hook] Received event: ${JSON.stringify(event)}`); - process.exitCode = 2; + fail('Missing required field: hook_event_name'); return; } // transcript_path/transcript_paths are optional for SessionStart/SessionEnd in // programmatic mode (transcript may not exist yet at start, or may not be - // discoverable at end). Some agents provide multiple transcript paths. + // discoverable at end). Some agents provide multiple transcript paths, and an + // agent may declare transcript_path optional for every event via hookConfig. const transcriptOptionalEvents = ['SessionStart', 'SessionEnd']; const hasTranscriptPath = Boolean(event.transcript_path) || (event.transcript_paths && event.transcript_paths.length > 0); - if (!hasTranscriptPath && !transcriptOptionalEvents.includes(event.hook_event_name)) { - const error = new Error('Missing required field: transcript_path'); - if (config) { - throw error; - } - logger.error('[hook] Missing required field: transcript_path'); - logger.debug(`[hook] Received event: ${JSON.stringify(event)}`); - process.exitCode = 2; - return; + const transcriptOptional = agentTranscriptOptional(agentName) || transcriptOptionalEvents.includes(event.hook_event_name); + if (!hasTranscriptPath && !transcriptOptional) { + fail('Missing required field: transcript_path'); } } /** * Initialize hook context (logger and session/agent info) * @param config - Optional configuration object (if not provided, reads from environment variables) + * @param agentFlag - Optional `--agent ` CLI flag value (CLI mode only); beats `CODEMIE_AGENT` env * @returns Object with sessionId and agentName */ -function initializeHookContext(config?: HookProcessingConfig): { sessionId: string; agentName: string } { +function initializeHookContext(config?: HookProcessingConfig, agentFlag?: string): { sessionId: string; agentName: string } { let sessionId: string; let agentName: string; @@ -1365,9 +1424,9 @@ function initializeHookContext(config?: HookProcessingConfig): { sessionId: stri logger.setProfileName(config.profileName); } } else { - // Use environment variables (CLI mode) - sessionId = initializeLoggerContext(); - agentName = process.env.CODEMIE_AGENT || 'unknown'; + // Use environment variables (CLI mode), with the --agent flag taking precedence + agentName = resolveAgentName(agentFlag); + sessionId = initializeLoggerContext(agentName); } return { sessionId, agentName }; @@ -1427,7 +1486,7 @@ function normalizeAndLogEvent(event: BaseHookEvent, sessionId: string, agentName */ export async function processEvent(event: BaseHookEvent, config?: HookProcessingConfig): Promise { // Validate required fields - validateHookEvent(event, config); + validateHookEvent(event, config, config?.agentName); if (process.exitCode === 2) { return; // Validation failed in CLI mode } @@ -1454,11 +1513,17 @@ export async function processEvent(event: BaseHookEvent, config?: HookProcessing export function createHookCommand(): Command { return new Command('hook') .description('Unified hook event handler (called by agent plugins)') - .action(async () => { + .option('--agent ', 'Agent name for hook attribution (overrides CODEMIE_AGENT)') + .action(async (opts: { agent?: string }) => { const hookStartTime = Date.now(); let event: BaseHookEvent | null = null; try { + // Resolve the agent name up front (flag beats CODEMIE_AGENT env) so + // agent-specific gating (e.g. non-blocking exit) is known even before + // stdin is read/parsed. + const agentName = resolveAgentName(opts.agent); + // Read JSON from stdin const input = await readStdin(); @@ -1472,44 +1537,37 @@ export function createHookCommand(): Command { const parseMsg = parseError instanceof Error ? parseError.message : String(parseError); logger.error(`[hook] Failed to parse JSON input: ${parseMsg}`); logger.debug(`[hook] Invalid JSON: ${input.substring(0, 200)}...`); - process.exit(2); // Blocking error - } - - // Validate required fields from hook input schema - if (!event.session_id) { - logger.error('[hook] Missing required field: session_id'); - logger.debug(`[hook] Received event: ${JSON.stringify(event)}`); - process.exit(2); // Blocking error - } - - if (!event.hook_event_name) { - logger.error('[hook] Missing required field: hook_event_name'); - logger.debug(`[hook] Received event: ${JSON.stringify(event)}`); + if (agentNeverBlocks(agentName)) { + return; // Non-blocking agent: fail without exiting 2 + } process.exit(2); // Blocking error } // Initialize logger context using CODEMIE_SESSION_ID from environment - // This ensures consistent session ID across all hooks - const { sessionId, agentName } = initializeHookContext(); + // (or, for agents whose session id is payload-derived, from the + // transformed event below). + const { sessionId, agentName: resolvedAgentName } = initializeHookContext(undefined, agentName); // Apply hook transformation if agent provides a transformer. // Some agents (e.g. Kimi) do not emit a transcript_path in their raw // hook payload; the transformer computes it from agent-specific session - // layout before we validate the internal event shape. - const transformedEvent = applyHookTransformation(event, agentName); - - // Validate required fields after transformation so agent-specific - // transformers can populate fields such as transcript_path. - validateHookEvent(transformedEvent); + // layout before we validate the internal event shape. Transform runs + // before validation so agent-specific transformers can populate + // fields such as transcript_path/session_id ahead of the check + // below — validateHookEvent (via `session_id`/`hook_event_name`) + // already covers the fields the old pre-transform checks duplicated. + const transformedEvent = applyHookTransformation(event, resolvedAgentName); + + validateHookEvent(transformedEvent, undefined, resolvedAgentName); if (process.exitCode === 2) { return; // Validation failed } // Normalize event name and log processing info - normalizeAndLogEvent(transformedEvent, sessionId, agentName); + normalizeAndLogEvent(transformedEvent, sessionId, resolvedAgentName); // Route to appropriate handler with transformed event and session ID - await routeHookEvent(transformedEvent, input, sessionId, agentName); + await routeHookEvent(transformedEvent, input, sessionId, resolvedAgentName); // Log successful completion const totalDuration = Date.now() - hookStartTime; From 6a83d016adb08b59bc42f84946197dbf4c1b8a5b Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Sat, 12 Sep 2026 11:54:56 +0300 Subject: [PATCH 06/46] feat(agents): add cursor-ide agent plugin with hook transformer Adds the cursor-ide agent plugin (analytics-only: never installed, updated, or launched by CodeMie) with a hook transformer mapping Cursor's raw payload (conversation_id, workspace_roots, tool_call_id) onto the internal BaseHookEvent shape, and its 21-event name mapping via the new declarative AgentHookConfig.eventNameMapping surface. Extends InternalHookEventName with the 7 new internal event names the mapping collapses onto, and BaseHookEvent with shared tool_name/ tool_input/tool_output/tool_use_id fields. Threads a transform-derived session id fallback through initializeLoggerContext/initializeHookContext so a hook invocation CodeMie did not spawn (no CODEMIE_SESSION_ID in env) can still resolve a session id from the transformed event, reordering the hook command's action to transform before initializing context. cursor-ide-hooks-analytics EPMCDME-14834, task 3 --- src/agents/__tests__/registry.test.ts | 1 + src/agents/core/types.ts | 17 ++- .../cursor-ide/cursor-ide.constants.ts | 15 +++ .../cursor-ide/cursor-ide.hook-transformer.ts | 74 ++++++++++++ .../plugins/cursor-ide/cursor-ide.plugin.ts | 108 ++++++++++++++++++ .../plugins/cursor-ide/cursor-ide.types.ts | 67 +++++++++++ src/agents/registry.ts | 2 + src/cli/commands/hook.ts | 46 +++++--- 8 files changed, 309 insertions(+), 21 deletions(-) create mode 100644 src/agents/plugins/cursor-ide/cursor-ide.constants.ts create mode 100644 src/agents/plugins/cursor-ide/cursor-ide.hook-transformer.ts create mode 100644 src/agents/plugins/cursor-ide/cursor-ide.plugin.ts create mode 100644 src/agents/plugins/cursor-ide/cursor-ide.types.ts diff --git a/src/agents/__tests__/registry.test.ts b/src/agents/__tests__/registry.test.ts index baf860714..72d89703e 100644 --- a/src/agents/__tests__/registry.test.ts +++ b/src/agents/__tests__/registry.test.ts @@ -27,6 +27,7 @@ describe('AgentRegistry', () => { 'kimi-acp', 'openwiki', 'copilot-cli', // analytics-only: read for the report, never managed by CodeMie + 'cursor-ide', // analytics-only: read for the report, never managed by CodeMie ].sort() ); }); diff --git a/src/agents/core/types.ts b/src/agents/core/types.ts index 8edb23557..890546208 100644 --- a/src/agents/core/types.ts +++ b/src/agents/core/types.ts @@ -644,7 +644,14 @@ export type InternalHookEventName = | 'Stop' | 'UserPromptSubmit' | 'SubagentStop' - | 'PreCompact'; + | 'PreCompact' + | 'PreToolUse' + | 'PostToolUse' + | 'PostToolUseFailure' + | 'SubagentStart' + | 'AgentResponse' + | 'AgentThought' + | 'WorkspaceOpen'; /** * Agent-specific hook configuration. @@ -655,7 +662,9 @@ export interface AgentHookConfig { * Keys are event names emitted by the agent; values are names used by the hook router. * * Valid internal values: SessionStart, SessionEnd, PermissionRequest, Stop, - * UserPromptSubmit, SubagentStop, PreCompact. + * UserPromptSubmit, SubagentStop, PreCompact, PreToolUse, PostToolUse, + * PostToolUseFailure, SubagentStart, AgentResponse, AgentThought, + * WorkspaceOpen. * * @example * eventNameMapping: { @@ -701,6 +710,10 @@ export interface BaseHookEvent { agent_id?: string; // SubagentStop only: Sub-agent ID agent_transcript_path?: string; // SubagentStop only: Path to agent's transcript stop_hook_active?: boolean; // SubagentStop only: Whether stop hook is active + tool_name?: string; // PreToolUse/PostToolUse/PostToolUseFailure: tool identifier + tool_input?: unknown; // PreToolUse/PostToolUse/PostToolUseFailure: tool arguments + tool_output?: unknown; // PostToolUse: tool result + tool_use_id?: string; // Correlates a PreToolUse call with its PostToolUse/failure } // Forward declaration for extension installer diff --git a/src/agents/plugins/cursor-ide/cursor-ide.constants.ts b/src/agents/plugins/cursor-ide/cursor-ide.constants.ts new file mode 100644 index 000000000..7bdb0b7ee --- /dev/null +++ b/src/agents/plugins/cursor-ide/cursor-ide.constants.ts @@ -0,0 +1,15 @@ +/** + * Shared Cursor IDE identifiers. + * + * Kept apart from `cursor-ide.plugin.ts` so other modules (the connector, the + * hook transformer) can import identifiers without pulling in the full plugin. + */ + +/** Internal agent key. Canonical everywhere: the `--cursor-ide` flag, `--agent cursor-ide`, `metadata.name`. */ +export const CURSOR_IDE_AGENT_NAME = 'cursor-ide'; + +/** User-facing label shown in analytics output. */ +export const CURSOR_IDE_DISPLAY_NAME = 'Cursor IDE'; + +/** Runtime client type recorded on ingested analytics events. */ +export const CURSOR_IDE_CLIENT_TYPE = 'codemie-cursor-ide'; diff --git a/src/agents/plugins/cursor-ide/cursor-ide.hook-transformer.ts b/src/agents/plugins/cursor-ide/cursor-ide.hook-transformer.ts new file mode 100644 index 000000000..249cb9086 --- /dev/null +++ b/src/agents/plugins/cursor-ide/cursor-ide.hook-transformer.ts @@ -0,0 +1,74 @@ +// src/agents/plugins/cursor-ide/cursor-ide.hook-transformer.ts +/** + * Cursor IDE hook payload transformer. + * + * Cursor emits hooks as JSON on stdin using its own field names - + * `conversation_id` instead of `session_id`, a nullable `transcript_path`, + * and no `permission_mode`. This transformer maps a raw Cursor payload onto + * CodeMie's internal `BaseHookEvent` shape without renaming + * `hook_event_name`: `normalizeEventName` reads the mapping declaratively + * from `hookConfig.eventNameMapping` and returns a local variable rather + * than mutating the event, so leaving the Cursor-native name on the + * transformed event is what keeps the many-to-one mapping (Task 4) lossless + * downstream. + */ + +import type { HookTransformer } from '../../core/types.js'; +import { CURSOR_IDE_AGENT_NAME } from './cursor-ide.constants.js'; +import type { CursorIdeHookEvent } from './cursor-ide.types.js'; + +/** + * Transforms Cursor IDE hook payloads to CodeMie's internal BaseHookEvent format. + */ +export class CursorIdeHookTransformer implements HookTransformer { + readonly agentName = CURSOR_IDE_AGENT_NAME; + + /** + * Transform a Cursor hook event into the internal BaseHookEvent shape. + * + * @param event - Raw JSON payload received from Cursor on stdin + * @returns Transformed event compatible with CodeMie hook handlers + */ + transform(event: unknown): CursorIdeHookEvent { + const payload = event as Record; + + const conversationId = typeof payload.conversation_id === 'string' ? payload.conversation_id : undefined; + const rawSessionId = typeof payload.session_id === 'string' ? payload.session_id : undefined; + const generationId = typeof payload.generation_id === 'string' ? payload.generation_id : undefined; + const sessionId = conversationId || rawSessionId || generationId || ''; + + const workspaceRoots = Array.isArray(payload.workspace_roots) + ? (payload.workspace_roots as unknown[]).filter((root): root is string => typeof root === 'string') + : undefined; + + const cwd = typeof payload.cwd === 'string' + ? payload.cwd + : (workspaceRoots?.[0] ?? process.cwd()); + + const transformed: CursorIdeHookEvent = { + ...payload, + hook_event_name: typeof payload.hook_event_name === 'string' ? payload.hook_event_name : '', + session_id: sessionId, + transcript_path: typeof payload.transcript_path === 'string' ? payload.transcript_path : '', + permission_mode: 'default', + cwd, + }; + + if (conversationId) { + transformed.conversation_id = conversationId; + } + if (generationId) { + transformed.generation_id = generationId; + } + if (workspaceRoots) { + transformed.workspace_roots = workspaceRoots; + } + + // subagentStart's `tool_call_id` correlates with tool_use_id on the shared type. + if (typeof payload.tool_call_id === 'string' && !transformed.tool_use_id) { + transformed.tool_use_id = payload.tool_call_id as string; + } + + return transformed; + } +} diff --git a/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts b/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts new file mode 100644 index 000000000..d588bc5cd --- /dev/null +++ b/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts @@ -0,0 +1,108 @@ +import type { AgentMetadata, HookTransformer } from '../../core/types.js'; +import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; +import { CursorIdeHookTransformer } from './cursor-ide.hook-transformer.js'; +import { + CURSOR_IDE_AGENT_NAME, + CURSOR_IDE_CLIENT_TYPE, + CURSOR_IDE_DISPLAY_NAME, +} from './cursor-ide.constants.js'; + +export { + CURSOR_IDE_AGENT_NAME, + CURSOR_IDE_CLIENT_TYPE, + CURSOR_IDE_DISPLAY_NAME, +} from './cursor-ide.constants.js'; + +/** + * Cursor's 21 native hook events, mapped onto CodeMie's internal event + * names. `hook_event_name` is left untouched by the transformer (Cursor's + * native name survives routing), so this mapping is what makes the + * many-to-one collapse (e.g. every tool-permission event onto `PreToolUse`) + * lossless - `normalizeEventName` resolves the internal name into a local + * variable without mutating the event. + * + * See: https://cursor.com/docs/hooks + */ +const CURSOR_IDE_EVENT_NAME_MAPPING = { + sessionStart: 'SessionStart', + sessionEnd: 'SessionEnd', + beforeSubmitPrompt: 'UserPromptSubmit', + stop: 'Stop', + preCompact: 'PreCompact', + subagentStart: 'SubagentStart', + subagentStop: 'SubagentStop', + preToolUse: 'PreToolUse', + beforeShellExecution: 'PreToolUse', + beforeMCPExecution: 'PreToolUse', + beforeReadFile: 'PreToolUse', + beforeTabFileRead: 'PreToolUse', + postToolUse: 'PostToolUse', + afterShellExecution: 'PostToolUse', + afterMCPExecution: 'PostToolUse', + afterFileEdit: 'PostToolUse', + afterTabFileEdit: 'PostToolUse', + postToolUseFailure: 'PostToolUseFailure', + afterAgentResponse: 'AgentResponse', + afterAgentThought: 'AgentThought', + workspaceOpen: 'WorkspaceOpen', +} as const; + +export const CursorIdePluginMetadata: AgentMetadata = { + name: CURSOR_IDE_AGENT_NAME, + displayName: CURSOR_IDE_DISPLAY_NAME, + description: 'Cursor IDE - analytics-only hook ingestion, never installed or launched by CodeMie', + + // Analytics-only: CodeMie never installs, updates, or launches Cursor. + npmPackage: null, + cliCommand: null, + + envMapping: { + baseUrl: [], + apiKey: [], + model: [], + }, + supportedProviders: [], + + // The sole gate excluding this agent from `codemie install/list/uninstall/update` + // (src/agents/registry.ts:getManageableAgents, types.ts:analyticsOnly). + analyticsOnly: true, + + ssoConfig: { + enabled: false, + clientType: CURSOR_IDE_CLIENT_TYPE, + }, + + hookConfig: { + eventNameMapping: CURSOR_IDE_EVENT_NAME_MAPPING, + // Cursor's transcript_path is nullable ("null if transcripts disabled") + // for every event, not just SessionStart/SessionEnd. + transcriptOptional: true, + // Exit code 2 is equivalent to `permission: "deny"` in Cursor and blocks + // the user's action - analytics ingestion must never be capable of that. + neverBlockingExit: true, + }, +}; + +export class CursorIdePlugin extends BaseAgentAdapter { + private hookTransformer?: HookTransformer; + + constructor(metadata: AgentMetadata = CursorIdePluginMetadata) { + super(metadata); + } + + getHookTransformer(): HookTransformer { + if (!this.hookTransformer) { + this.hookTransformer = new CursorIdeHookTransformer(); + } + return this.hookTransformer; + } + + override async isInstalled(): Promise { + // Analytics-only: Cursor's installation state is irrelevant to CodeMie. + return true; + } + + override async getVersion(): Promise { + return null; + } +} diff --git a/src/agents/plugins/cursor-ide/cursor-ide.types.ts b/src/agents/plugins/cursor-ide/cursor-ide.types.ts new file mode 100644 index 000000000..50c8a47b7 --- /dev/null +++ b/src/agents/plugins/cursor-ide/cursor-ide.types.ts @@ -0,0 +1,67 @@ +import type { BaseHookEvent } from '../../core/types.js'; + +/** + * Raw payload shape Cursor sends on stdin for any of its 21 native hook + * events. Cursor's hooks.json schema has no `env` key and every event + * self-identifies via `hook_event_name` (the Cursor-native name, e.g. + * `beforeShellExecution`) rather than one of CodeMie's internal names. + * + * Only the fields genuinely shared with other agents live on the shared + * `BaseHookEvent` (`tool_name`, `tool_input`, `tool_output`, `tool_use_id`); + * everything Cursor-specific stays here to avoid bloating the shared type. + * + * See: https://cursor.com/docs/hooks + */ +export interface CursorIdeHookEvent extends BaseHookEvent { + /** Stable across every turn of one Cursor conversation - the correlation key. */ + conversation_id?: string; + /** Per-generation id; used only as a last-resort session_id fallback. */ + generation_id?: string; + + /** Absolute paths of every workspace root open in this Cursor window. */ + workspace_roots?: string[]; + /** Signed-in Cursor account email, when available. */ + user_email?: string; + + /** Model metadata carried on most events. */ + model?: string; + model_id?: string; + model_params?: Record; + cursor_version?: string; + + /** Shell/MCP command text (beforeShellExecution, beforeMCPExecution). */ + command?: string; + /** afterShellExecution result. */ + output?: unknown; + /** afterMCPExecution result. */ + result_json?: unknown; + + /** postToolUseFailure fields. */ + error_message?: string; + failure_type?: string; + + /** Timing/status fields present on several after-/before- event pairs. */ + duration?: number; + duration_ms?: number; + status?: string; + + /** subagentStart/subagentStop. */ + loop_count?: number; + /** subagentStart's tool-call correlation id (normalized to tool_use_id). */ + tool_call_id?: string; + + /** beforeReadFile/beforeTabFileRead/afterFileEdit/afterTabFileEdit. */ + file_path?: string; + + /** beforeMCPExecution/afterMCPExecution. */ + mcp_server_name?: string; + mcp_server_url?: string; + + /** beforeShellExecution sandbox descriptor, when sandboxing is enabled. */ + sandbox?: unknown; + + /** preCompact only - the sole Cursor event carrying token/cost data. */ + context_tokens?: number; + context_window_size?: number; + context_usage_percent?: number; +} diff --git a/src/agents/registry.ts b/src/agents/registry.ts index d0a655de4..7948c8376 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -9,6 +9,7 @@ import { KimiPlugin } from './plugins/kimi/kimi.plugin.js'; import { KimiAcpPlugin } from './plugins/kimi/kimi-acp.plugin.js'; import { OpenWikiPlugin } from './plugins/openwiki/openwiki.plugin.js'; import { CopilotCliPlugin } from './plugins/copilot-cli/index.js'; +import { CursorIdePlugin } from './plugins/cursor-ide/cursor-ide.plugin.js'; import { AgentAdapter, AgentAnalyticsAdapter } from './core/types.js'; // Re-export for backwards compatibility @@ -43,6 +44,7 @@ export class AgentRegistry { AgentRegistry.registerPlugin(new KimiAcpPlugin()); AgentRegistry.registerPlugin(new OpenWikiPlugin()); AgentRegistry.registerPlugin(new CopilotCliPlugin()); + AgentRegistry.registerPlugin(new CursorIdePlugin()); AgentRegistry.initialized = true; } diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index fd0b96f5e..fd7b059f0 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -165,9 +165,11 @@ function resolveAgentName(agentFlag?: string): string { * @returns The CodeMie session ID from environment * @throws Error if required environment variables are missing */ -function initializeLoggerContext(agentName: string): string { - // Use CODEMIE_SESSION_ID from environment - const sessionId = process.env.CODEMIE_SESSION_ID; +function initializeLoggerContext(agentName: string, fallbackSessionId?: string): string { + // Use CODEMIE_SESSION_ID from environment, falling back to a payload-derived + // session id (e.g. cursor-ide's transformed conversation_id) when a hook + // process CodeMie did not spawn has no CODEMIE_SESSION_ID to inherit. + const sessionId = process.env.CODEMIE_SESSION_ID || fallbackSessionId; if (!sessionId) { throw new Error('CODEMIE_SESSION_ID environment variable is required'); } @@ -1408,7 +1410,11 @@ function validateHookEvent(event: BaseHookEvent, config?: HookProcessingConfig, * @param agentFlag - Optional `--agent ` CLI flag value (CLI mode only); beats `CODEMIE_AGENT` env * @returns Object with sessionId and agentName */ -function initializeHookContext(config?: HookProcessingConfig, agentFlag?: string): { sessionId: string; agentName: string } { +function initializeHookContext( + config?: HookProcessingConfig, + agentFlag?: string, + fallbackSessionId?: string +): { sessionId: string; agentName: string } { let sessionId: string; let agentName: string; @@ -1426,7 +1432,7 @@ function initializeHookContext(config?: HookProcessingConfig, agentFlag?: string } else { // Use environment variables (CLI mode), with the --agent flag taking precedence agentName = resolveAgentName(agentFlag); - sessionId = initializeLoggerContext(agentName); + sessionId = initializeLoggerContext(agentName, fallbackSessionId); } return { sessionId, agentName }; @@ -1543,20 +1549,22 @@ export function createHookCommand(): Command { process.exit(2); // Blocking error } - // Initialize logger context using CODEMIE_SESSION_ID from environment - // (or, for agents whose session id is payload-derived, from the - // transformed event below). - const { sessionId, agentName: resolvedAgentName } = initializeHookContext(undefined, agentName); - - // Apply hook transformation if agent provides a transformer. - // Some agents (e.g. Kimi) do not emit a transcript_path in their raw - // hook payload; the transformer computes it from agent-specific session - // layout before we validate the internal event shape. Transform runs - // before validation so agent-specific transformers can populate - // fields such as transcript_path/session_id ahead of the check - // below — validateHookEvent (via `session_id`/`hook_event_name`) - // already covers the fields the old pre-transform checks duplicated. - const transformedEvent = applyHookTransformation(event, resolvedAgentName); + // Apply hook transformation if agent provides a transformer, before + // initializing logger/session context. Some agents (e.g. Kimi) do + // not emit a transcript_path in their raw hook payload; others (e.g. + // cursor-ide) send conversation_id instead of session_id. The + // transformer computes/maps these fields before we resolve the + // CodeMie session id or validate the internal event shape. + const transformedEvent = applyHookTransformation(event, agentName); + + // Initialize logger context using CODEMIE_SESSION_ID from environment, + // falling back to the transform-derived session id when a hook + // process CodeMie did not spawn has nothing to inherit it from. + const { sessionId, agentName: resolvedAgentName } = initializeHookContext( + undefined, + agentName, + transformedEvent.session_id + ); validateHookEvent(transformedEvent, undefined, resolvedAgentName); if (process.exitCode === 2) { From 4906020587fe95f388446fb1dba6b6d923240061 Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Sat, 12 Sep 2026 11:58:30 +0300 Subject: [PATCH 07/46] feat(cli): route the 7 new internal hook events Extends routeHookEvent's switch with PreToolUse, PostToolUse, PostToolUseFailure, SubagentStart, AgentResponse, AgentThought and WorkspaceOpen, each with an allocation-light, non-blocking debug-log handler. Without this the router's default branch silently dropped these events, which is what cursor-ide's 21-event mapping collapses onto beyond the existing 7 internal names. cursor-ide-hooks-analytics EPMCDME-14834, task 4 --- src/cli/commands/hook.ts | 86 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index fd7b059f0..7b1a1a932 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -630,6 +630,64 @@ async function handlePreCompact(event: BaseHookEvent): Promise { logger.debug(`[hook:PreCompact] ${JSON.stringify(event)}`); } +/** + * Handle SubagentStart event + * Observational only: hands off to the per-agent raw event capture (see + * appendCursorEventLog). Kept allocation-light and non-blocking - it fires + * on the agent's hot path. + */ +async function handleSubagentStart(event: BaseHookEvent): Promise { + logger.debug(`[hook:SubagentStart] tool_use_id=${event.tool_use_id ?? ''}`); +} + +/** + * Handle PreToolUse event + * Observational only: hands off to the per-agent raw event capture. + */ +async function handlePreToolUse(event: BaseHookEvent): Promise { + logger.debug(`[hook:PreToolUse] tool_name=${event.tool_name ?? ''} tool_use_id=${event.tool_use_id ?? ''}`); +} + +/** + * Handle PostToolUse event + * Observational only: hands off to the per-agent raw event capture. + */ +async function handlePostToolUse(event: BaseHookEvent): Promise { + logger.debug(`[hook:PostToolUse] tool_name=${event.tool_name ?? ''} tool_use_id=${event.tool_use_id ?? ''}`); +} + +/** + * Handle PostToolUseFailure event + * Observational only: hands off to the per-agent raw event capture. + */ +async function handlePostToolUseFailure(event: BaseHookEvent): Promise { + logger.debug(`[hook:PostToolUseFailure] tool_name=${event.tool_name ?? ''} tool_use_id=${event.tool_use_id ?? ''}`); +} + +/** + * Handle AgentResponse event + * Observational only: hands off to the per-agent raw event capture. + */ +async function handleAgentResponse(event: BaseHookEvent): Promise { + logger.debug(`[hook:AgentResponse] session_id=${event.session_id}`); +} + +/** + * Handle AgentThought event + * Observational only: hands off to the per-agent raw event capture. + */ +async function handleAgentThought(event: BaseHookEvent): Promise { + logger.debug(`[hook:AgentThought] session_id=${event.session_id}`); +} + +/** + * Handle WorkspaceOpen event + * Observational only: hands off to the per-agent raw event capture. + */ +async function handleWorkspaceOpen(event: BaseHookEvent): Promise { + logger.debug(`[hook:WorkspaceOpen] cwd=${event.cwd ?? ''}`); +} + /** * Normalize event name using agent-specific mapping * Maps agent-specific event names to internal event names @@ -726,6 +784,34 @@ async function routeHookEvent(event: BaseHookEvent, rawInput: string, sessionId: logger.info(`[hook:router] Calling handlePreCompact`); await handlePreCompact(event); break; + case 'SubagentStart': + logger.info(`[hook:router] Calling handleSubagentStart`); + await handleSubagentStart(event); + break; + case 'PreToolUse': + logger.info(`[hook:router] Calling handlePreToolUse`); + await handlePreToolUse(event); + break; + case 'PostToolUse': + logger.info(`[hook:router] Calling handlePostToolUse`); + await handlePostToolUse(event); + break; + case 'PostToolUseFailure': + logger.info(`[hook:router] Calling handlePostToolUseFailure`); + await handlePostToolUseFailure(event); + break; + case 'AgentResponse': + logger.info(`[hook:router] Calling handleAgentResponse`); + await handleAgentResponse(event); + break; + case 'AgentThought': + logger.info(`[hook:router] Calling handleAgentThought`); + await handleAgentThought(event); + break; + case 'WorkspaceOpen': + logger.info(`[hook:router] Calling handleWorkspaceOpen`); + await handleWorkspaceOpen(event); + break; default: logger.info(`[hook:router] Unsupported event: ${normalizedEventName} (silently ignored)`); return; From 625b337d5d69e50f10a3be4afdb7837532e1c395 Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Sat, 12 Sep 2026 12:10:43 +0300 Subject: [PATCH 08/46] feat(cli): add cursor-ide stdout response contract, never blocking exit Adds writeCursorResponse (cursor-ide.response.ts) emitting Cursor's expected stdout response - {"permission":"allow"} for tool-permission events, {"continue":true} for beforeSubmitPrompt, nothing otherwise - wired declaratively via a new AgentHookConfig.writeStdoutResponse field so hook.ts never branches on the agent name. Neutralizes the two remaining exit-2 sites for a neverBlockingExit agent: the .action body's catch path now exits 0 (still writing the stdout response) instead of 1, and enforceAnalyticsAuthGate's direct process.exit(2) now degrades to a thrown, caught, non-blocking failure for such an agent, closing the auth-gate exit-2 site reachable via beforeSubmitPrompt -> UserPromptSubmit. Adds Logger.setStdoutSuppressed so debug()/success()'s own console mirror can be redirected to stderr for the hook path, keeping stdout free for the response contract once CODEMIE_AGENT's hookConfig is known - closing the gap for every debug/info log the hook handlers themselves emit while processing an event. cursor-ide-hooks-analytics EPMCDME-14834, task 5 --- src/agents/core/types.ts | 11 +++ .../plugins/cursor-ide/cursor-ide.plugin.ts | 5 + .../plugins/cursor-ide/cursor-ide.response.ts | 48 +++++++++ src/cli/commands/hook.ts | 98 ++++++++++++++++--- src/utils/logger.ts | 19 +++- 5 files changed, 167 insertions(+), 14 deletions(-) create mode 100644 src/agents/plugins/cursor-ide/cursor-ide.response.ts diff --git a/src/agents/core/types.ts b/src/agents/core/types.ts index 890546208..1f19ee422 100644 --- a/src/agents/core/types.ts +++ b/src/agents/core/types.ts @@ -690,6 +690,17 @@ export interface AgentHookConfig { * treats a non-zero exit as a hard block on the user's action. */ neverBlockingExit?: boolean; + + /** + * Optional per-agent stdout response contract, invoked with the + * agent-native event name (`event.hook_event_name`, unmutated by + * `eventNameMapping`) after the hook has been routed. Lets a host that + * inspects stdout for a synchronous response (e.g. Cursor's + * `{"permission":"allow"}`/`{"continue":true}`) get one without adding an + * agent-name literal to `hook.ts` — set only by agents whose host reads a + * response from stdout. + */ + writeStdoutResponse?: (nativeEventName: string) => void; } /** diff --git a/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts b/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts index d588bc5cd..2ff610da6 100644 --- a/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts +++ b/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts @@ -1,6 +1,7 @@ import type { AgentMetadata, HookTransformer } from '../../core/types.js'; import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; import { CursorIdeHookTransformer } from './cursor-ide.hook-transformer.js'; +import { writeCursorResponse } from './cursor-ide.response.js'; import { CURSOR_IDE_AGENT_NAME, CURSOR_IDE_CLIENT_TYPE, @@ -80,6 +81,10 @@ export const CursorIdePluginMetadata: AgentMetadata = { // Exit code 2 is equivalent to `permission: "deny"` in Cursor and blocks // the user's action - analytics ingestion must never be capable of that. neverBlockingExit: true, + // Cursor reads a JSON response off stdout for a subset of its events + // (see cursor-ide.response.ts) - this is the sole gate that calls it, + // set only for this agent. + writeStdoutResponse: writeCursorResponse, }, }; diff --git a/src/agents/plugins/cursor-ide/cursor-ide.response.ts b/src/agents/plugins/cursor-ide/cursor-ide.response.ts new file mode 100644 index 000000000..66b59e299 --- /dev/null +++ b/src/agents/plugins/cursor-ide/cursor-ide.response.ts @@ -0,0 +1,48 @@ +/** + * Cursor IDE stdout response contract. + * + * Cursor reads a JSON object off the hook process's stdout to decide how to + * proceed for a subset of its events - `{"permission":"allow"}` for + * tool-permission events, `{"continue":true}` for `beforeSubmitPrompt`. + * Every other event reads nothing from stdout, so this writer emits nothing + * for them: an unexpected stdout payload is exactly as unsafe here as a + * missing one. + * + * This module never decides *whether* to run - it is wired in declaratively + * via `AgentHookConfig.writeStdoutResponse` (see cursor-ide.plugin.ts), so + * `hook.ts` never needs an `if (agentName === 'cursor-ide')` branch to call + * it only for this agent. + * + * See: https://cursor.com/docs/hooks + */ + +const ALLOW_RESPONSE = JSON.stringify({ permission: 'allow' }); +const CONTINUE_RESPONSE = JSON.stringify({ continue: true }); + +/** + * Cursor-native event names (not the internal names they map onto) that + * carry a response contract. + */ +const CURSOR_STDOUT_RESPONSES: Readonly> = { + preToolUse: ALLOW_RESPONSE, + beforeShellExecution: ALLOW_RESPONSE, + beforeMCPExecution: ALLOW_RESPONSE, + beforeReadFile: ALLOW_RESPONSE, + beforeTabFileRead: ALLOW_RESPONSE, + subagentStart: ALLOW_RESPONSE, + beforeSubmitPrompt: CONTINUE_RESPONSE, +}; + +/** + * Writes Cursor's expected stdout response for `nativeEventName`, if any. + * A no-op for every event outside the response matrix above. + * + * @param nativeEventName - The Cursor-native event name (`hook_event_name` + * on the raw/transformed payload - never the internal name it maps onto). + */ +export function writeCursorResponse(nativeEventName: string): void { + const response = CURSOR_STDOUT_RESPONSES[nativeEventName]; + if (response) { + process.stdout.write(`${response}\n`); + } +} diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index 7b1a1a932..e1e1d3244 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -521,9 +521,9 @@ async function accumulateActiveDuration(sessionId: string): Promise { * Handle UserPromptSubmit event * Starts activity tracking to measure active session time */ -async function handleUserPromptSubmit(event: BaseHookEvent, sessionId: string, config?: HookProcessingConfig): Promise { +async function handleUserPromptSubmit(event: BaseHookEvent, sessionId: string, config?: HookProcessingConfig, agentName?: string): Promise { logger.info(`[hook:UserPromptSubmit] ${JSON.stringify(event)}`); - await enforceAnalyticsAuthGate(config); + await enforceAnalyticsAuthGate(config, agentName); await startActivityTracking(sessionId); } @@ -540,7 +540,7 @@ async function handleUserPromptSubmit(event: BaseHookEvent, sessionId: string, c * disappear from metrics. The marker is cleared by `codemie profile login` * and by any successful metrics send. */ -async function enforceAnalyticsAuthGate(config?: HookProcessingConfig): Promise { +async function enforceAnalyticsAuthGate(config?: HookProcessingConfig, agentName?: string): Promise { try { const provider = getConfigValue('CODEMIE_PROVIDER', config); const ssoUrl = getConfigValue('CODEMIE_URL', config); @@ -585,9 +585,10 @@ async function enforceAnalyticsAuthGate(config?: HookProcessingConfig): Promise< logger.warn(`[hook:UserPromptSubmit] Blocking prompt: ${reason}`); - if (config) { - // Programmatic mode (e.g. VSCode extension): let the host decide how to - // surface the failure instead of exiting its process + if (config || agentNeverBlocks(agentName)) { + // Programmatic mode (e.g. VSCode extension), or an agent that declares + // `hookConfig.neverBlockingExit`: let the caller decide how to surface + // the failure instead of exiting the process with a blocking code. throw new Error(message); } @@ -595,7 +596,7 @@ async function enforceAnalyticsAuthGate(config?: HookProcessingConfig): Promise< console.error(message); process.exit(2); // Blocking: stderr is fed back to the agent } catch (error) { - if (config) { + if (config || agentNeverBlocks(agentName)) { throw error; } // The gate itself must never break the prompt flow @@ -774,7 +775,7 @@ async function routeHookEvent(event: BaseHookEvent, rawInput: string, sessionId: break; case 'UserPromptSubmit': logger.info(`[hook:router] Calling handleUserPromptSubmit`); - await handleUserPromptSubmit(event, sessionId, config); + await handleUserPromptSubmit(event, sessionId, config, agentName); break; case 'SubagentStop': logger.info(`[hook:router] Calling handleSubagentStop`); @@ -1446,6 +1447,49 @@ function agentTranscriptOptional(agentName?: string): boolean { } } +/** + * Whether an agent has declared a stdout response contract + * (`metadata.hookConfig.writeStdoutResponse`) - a fully declarative check + * (no agent-name literal) used to decide whether this logger's own + * CODEMIE_DEBUG console output must stay off stdout for the rest of this + * process (see `writeAgentStdoutResponse` below). + */ +function agentHasStdoutResponseContract(agentName?: string): boolean { + if (!agentName) { + return false; + } + try { + const agent = AgentRegistry.getAgent(agentName); + return typeof agent?.metadata?.hookConfig?.writeStdoutResponse === 'function'; + } catch { + return false; + } +} + +/** + * Write an agent's declarative stdout response contract, if it has one + * (`metadata.hookConfig.writeStdoutResponse` - see cursor-ide.response.ts). + * A no-op for every agent that doesn't declare one. Never throws: a + * response-writer failure must not turn a successful hook into a failed one. + * + * @param agentName - Resolved agent name + * @param nativeEventName - The agent-native event name (`event.hook_event_name`) + */ +function writeAgentStdoutResponse(agentName: string | undefined, nativeEventName: string): void { + if (!agentName) { + return; + } + try { + const agent = AgentRegistry.getAgent(agentName); + const writer = agent?.metadata?.hookConfig?.writeStdoutResponse; + if (typeof writer === 'function') { + writer(nativeEventName); + } + } catch (error) { + logger.debug('[hook] Failed to write agent stdout response (non-blocking):', error); + } +} + /** * Validate hook event required fields * @param event - Hook event to validate @@ -1609,12 +1653,26 @@ export function createHookCommand(): Command { .action(async (opts: { agent?: string }) => { const hookStartTime = Date.now(); let event: BaseHookEvent | null = null; + // Hoisted so the catch block can also resolve declarative agent gating + // (agentNeverBlocks/writeAgentStdoutResponse) after a failure. + let agentName: string | undefined; try { // Resolve the agent name up front (flag beats CODEMIE_AGENT env) so // agent-specific gating (e.g. non-blocking exit) is known even before // stdin is read/parsed. - const agentName = resolveAgentName(opts.agent); + agentName = resolveAgentName(opts.agent); + + // An agent that declares a stdout response contract (e.g. cursor-ide) + // needs stdout to carry only that response - suppress this logger's + // own CODEMIE_DEBUG console mirror before the very first AgentRegistry + // lookup below (which lazily initializes every plugin and would + // otherwise log its own bootstrap to stdout ahead of the check's + // answer), then restore normal behavior once we know it wasn't needed. + logger.setStdoutSuppressed(true); + if (!agentHasStdoutResponseContract(agentName)) { + logger.setStdoutSuppressed(false); + } // Read JSON from stdin const input = await readStdin(); @@ -1669,6 +1727,12 @@ export function createHookCommand(): Command { `[hook] Completed ${event.hook_event_name} event successfully (${totalDuration}ms)` ); + // Declarative per-agent stdout response contract (e.g. cursor-ide's + // {"permission":"allow"}/{"continue":true}) - a no-op for every + // agent that doesn't declare one. Uses the agent-native event name, + // which transformers leave unmutated on the transformed event. + writeAgentStdoutResponse(resolvedAgentName, transformedEvent.hook_event_name); + // Flush logger before exit to ensure write completes await logger.close(); // Use process.exitCode instead of process.exit() to allow graceful shutdown @@ -1696,9 +1760,19 @@ export function createHookCommand(): Command { // Flush logger before exit await logger.close(); - // Use process.exitCode instead of process.exit() to allow graceful shutdown - // This prevents Windows libuv UV_HANDLE_CLOSING assertion failures - process.exitCode = 1; + + // An agent that declares `hookConfig.neverBlockingExit` (e.g. + // cursor-ide) must never see a non-zero exit, even from an internal + // failure - still write its stdout response contract so the host + // doesn't stall waiting on a response that will never arrive. + if (agentNeverBlocks(agentName)) { + writeAgentStdoutResponse(agentName, event?.hook_event_name || ''); + process.exitCode = 0; + } else { + // Use process.exitCode instead of process.exit() to allow graceful shutdown + // This prevents Windows libuv UV_HANDLE_CLOSING assertion failures + process.exitCode = 1; + } } }); } diff --git a/src/utils/logger.ts b/src/utils/logger.ts index ef5ba8def..9c59f5893 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -28,6 +28,7 @@ class Logger { private writeStream: fs.WriteStream | null = null; private currentLogDate: string | null = null; private isRotating: boolean = false; + private stdoutSuppressed = false; constructor() {} @@ -38,6 +39,18 @@ class Logger { this.agentName = name; } + /** + * Suppress this logger's own stdout writes (debug()'s CODEMIE_DEBUG console + * output, success()'s console output) by redirecting them to stderr + * instead. For callers whose process stdout is a data channel a caller + * parses (e.g. `codemie hook`'s per-agent stdout response contract) - + * mixing log lines into that channel corrupts it. Never suppresses the log + * file, only the console mirror. + */ + setStdoutSuppressed(suppressed: boolean): void { + this.stdoutSuppressed = suppressed; + } + /** * Get agent name */ @@ -305,7 +318,8 @@ class Logger { prefix += ` [${this.profileName}]`; } - console.log(chalk.dim(`${prefix} ${message}`), ...args); + const write = this.stdoutSuppressed ? console.error : console.log; + write(chalk.dim(`${prefix} ${message}`), ...args); } } @@ -315,7 +329,8 @@ class Logger { } success(message: string, ...args: unknown[]): void { - console.log(chalk.green(`✓ ${message}`), ...args); + const write = this.stdoutSuppressed ? console.error : console.log; + write(chalk.green(`✓ ${message}`), ...args); } warn(message: string, ...args: unknown[]): void { From 06fac64d88940dd1de4afa7e1469034975346bb1 Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Sat, 12 Sep 2026 12:20:36 +0300 Subject: [PATCH 09/46] feat(agents): capture cursor-ide hook events to project-local JSONL trace Adds a shared resolveProjectRoot() helper (walk up for .git, fall back to cwd) and a declarative hookConfig.captureEvent extension point so every Cursor hook event is appended, sanitized and size-capped, to .codemie/logs/cursor-hook-events.jsonl - the primary acceptance signal for analytics ingestion. Capture is gated by CODEMIE_CURSOR_HOOK_TRACE (default on) and never throws or blocks the hook. cursor-ide-hooks-analytics EPMCDME-14834, task 6 --- .gitignore | 4 + src/agents/core/types.ts | 11 ++ .../cursor-ide/cursor-ide.event-log.ts | 119 ++++++++++++++++++ .../plugins/cursor-ide/cursor-ide.plugin.ts | 6 + src/cli/commands/hook.ts | 41 ++++++ src/utils/project-root.ts | 43 +++++++ 6 files changed, 224 insertions(+) create mode 100644 src/agents/plugins/cursor-ide/cursor-ide.event-log.ts create mode 100644 src/utils/project-root.ts diff --git a/.gitignore b/.gitignore index 8bd91e38e..aaa75579b 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,10 @@ local/* /.serena/ /.agents/skills/bmad* .codemie +# Project-local cursor-ide hook trace (raw payloads, sanitized but never +# committable) - explicit even though the bare `.codemie` entry above +# already covers it. +.codemie/logs/ .claude/telemetry .playwright-mcp/ diff --git a/src/agents/core/types.ts b/src/agents/core/types.ts index 1f19ee422..44093599a 100644 --- a/src/agents/core/types.ts +++ b/src/agents/core/types.ts @@ -701,6 +701,17 @@ export interface AgentHookConfig { * response from stdout. */ writeStdoutResponse?: (nativeEventName: string) => void; + + /** + * Optional per-agent raw event capture, invoked for every routed event + * with the transformed payload, the agent-native event name, the internal + * event name it maps onto, and the resolved session id. Lets an agent + * capture its own events verbatim (e.g. cursor-ide's project-local JSONL + * trace) without adding an agent-name literal to `hook.ts` - set only by + * agents that need this. Must never throw; must never block or slow the + * hook's own processing. + */ + captureEvent?: (payload: unknown, nativeEventName: string, internalEventName: string, sessionId: string) => Promise; } /** diff --git a/src/agents/plugins/cursor-ide/cursor-ide.event-log.ts b/src/agents/plugins/cursor-ide/cursor-ide.event-log.ts new file mode 100644 index 000000000..051836036 --- /dev/null +++ b/src/agents/plugins/cursor-ide/cursor-ide.event-log.ts @@ -0,0 +1,119 @@ +/** + * Raw Cursor hook event capture, project-local. + * + * This is the primary acceptance signal for the analytics-ingestion effort: + * every hook event Cursor delivers must be captured verbatim (modulo + * sanitization) to `/.codemie/logs/cursor-hook-events.jsonl`, + * one JSON line per event, without ever blocking or slowing the user's + * action in Cursor. + * + * Project-local, not `~/.codemie`, as requested - and resolved via the + * shared `resolveProjectRoot()` (see `src/utils/project-root.ts`) so this + * file's location can never drift from Task 8's `.cursor/hooks.json` + * resolution. + * + * Every failure here is swallowed: an unwritable path or a read-only + * workspace must never break a hook or delay the agent. + */ + +import { appendFile, mkdir } from 'fs/promises'; +import { dirname, join } from 'path'; +import { sanitizeLogArgs } from '../../../utils/security.js'; +import { resolveProjectRoot } from '../../../utils/project-root.js'; + +const LOG_RELATIVE_PATH = join('.codemie', 'logs', 'cursor-hook-events.jsonl'); + +// `beforeReadFile` payloads carry the full file content, `afterFileEdit` +// carries old/new strings, `beforeShellExecution` carries raw commands - +// any of these can be large. Cap the field, not the whole record, so +// truncation is visible and explicit rather than silently dropping the +// record. +const MAX_FIELD_LENGTH = 8192; +const TRUNCATION_MARKER = '…[truncated by codemie: field exceeded 8192 chars]'; + +// Fields on Cursor payloads known to carry potentially large content. +const LARGE_FIELDS = ['content', 'output', 'old_string', 'new_string', 'command', 'stdout', 'stderr']; + +/** + * Env var gating capture. Default ON for this release (it is the + * acceptance signal); set to '0' or 'false' to disable. + */ +export function isCursorHookTraceEnabled(): boolean { + const value = process.env.CODEMIE_CURSOR_HOOK_TRACE; + return value !== '0' && value !== 'false'; +} + +function truncateLargeFields(value: unknown): unknown { + if (value === null || value === undefined) { + return value; + } + + if (typeof value === 'string') { + if (value.length > MAX_FIELD_LENGTH) { + return `${value.slice(0, MAX_FIELD_LENGTH)}${TRUNCATION_MARKER}`; + } + return value; + } + + if (Array.isArray(value)) { + return value.map(truncateLargeFields); + } + + if (typeof value === 'object') { + const result: Record = {}; + for (const [key, entryValue] of Object.entries(value as Record)) { + if (LARGE_FIELDS.includes(key) && typeof entryValue === 'string' && entryValue.length > MAX_FIELD_LENGTH) { + result[key] = `${entryValue.slice(0, MAX_FIELD_LENGTH)}${TRUNCATION_MARKER}`; + } else { + result[key] = truncateLargeFields(entryValue); + } + } + return result; + } + + return value; +} + +/** + * Append one JSON line for a single Cursor hook event to the project-local + * capture log. Never throws - every failure (unwritable path, read-only + * workspace, disk full) is swallowed so capture can never break a hook or + * delay the agent. + * + * @param payload - The raw (or transformed) Cursor event payload + * @param cursorEventName - Cursor-native event name (`hook_event_name`) + * @param internalEventName - CodeMie internal event name it maps onto + * @param sessionId - Resolved session id (conversation_id fallback included) + */ +export async function appendCursorEventLog( + payload: unknown, + cursorEventName: string, + internalEventName: string, + sessionId: string +): Promise { + if (!isCursorHookTraceEnabled()) { + return; + } + + try { + const record = payload as Record | undefined; + const conversationId = typeof record?.conversation_id === 'string' ? record.conversation_id : undefined; + + const sanitizedPayload = sanitizeLogArgs(truncateLargeFields(payload))[0]; + + const line = JSON.stringify({ + received_at: new Date().toISOString(), + hook_event_name: cursorEventName, + internal_event_name: internalEventName, + session_id: sessionId, + conversation_id: conversationId, + payload: sanitizedPayload, + }); + + const logPath = join(resolveProjectRoot(), LOG_RELATIVE_PATH); + await mkdir(dirname(logPath), { recursive: true }); + await appendFile(logPath, `${line}\n`, 'utf-8'); + } catch { + // Capture must never break a hook or delay the agent. + } +} diff --git a/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts b/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts index 2ff610da6..fba386fed 100644 --- a/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts +++ b/src/agents/plugins/cursor-ide/cursor-ide.plugin.ts @@ -2,6 +2,7 @@ import type { AgentMetadata, HookTransformer } from '../../core/types.js'; import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; import { CursorIdeHookTransformer } from './cursor-ide.hook-transformer.js'; import { writeCursorResponse } from './cursor-ide.response.js'; +import { appendCursorEventLog } from './cursor-ide.event-log.js'; import { CURSOR_IDE_AGENT_NAME, CURSOR_IDE_CLIENT_TYPE, @@ -85,6 +86,11 @@ export const CursorIdePluginMetadata: AgentMetadata = { // (see cursor-ide.response.ts) - this is the sole gate that calls it, // set only for this agent. writeStdoutResponse: writeCursorResponse, + // Primary acceptance signal: capture every delivered event verbatim + // (sanitized) to a project-local JSONL trace (see + // cursor-ide.event-log.ts). Gated internally behind + // CODEMIE_CURSOR_HOOK_TRACE; never throws, never blocks the hook. + captureEvent: appendCursorEventLog, }, }; diff --git a/src/cli/commands/hook.ts b/src/cli/commands/hook.ts index e1e1d3244..3252fe659 100644 --- a/src/cli/commands/hook.ts +++ b/src/cli/commands/hook.ts @@ -756,6 +756,12 @@ async function routeHookEvent(event: BaseHookEvent, rawInput: string, sessionId: const normalizedEventName = normalizeEventName(originalEventName, agentName); logger.info(`[hook:router] Normalized event name: "${normalizedEventName}"`); + // Declarative raw-event capture (e.g. cursor-ide's project-local JSONL + // trace) - a no-op for agents that don't declare hookConfig.captureEvent. + // Fire before routing so every delivered event is captured even if its + // handler throws. + await captureAgentEvent(agentName, event, originalEventName, normalizedEventName, sessionId); + switch (normalizedEventName) { case 'SessionStart': logger.info(`[hook:router] Calling handleSessionStart`); @@ -1490,6 +1496,41 @@ function writeAgentStdoutResponse(agentName: string | undefined, nativeEventName } } +/** + * Invoke an agent's declarative raw-event capture, if it has one + * (`metadata.hookConfig.captureEvent` - see cursor-ide.event-log.ts). A + * no-op for every agent that doesn't declare one. Never throws and never + * awaited by the caller's critical path beyond this call: a capture + * failure must not turn a successful hook into a failed one, and capture + * must never slow the agent down. + * + * @param agentName - Resolved agent name + * @param payload - The transformed event payload + * @param nativeEventName - The agent-native event name (`event.hook_event_name`) + * @param internalEventName - The internal event name it was routed as + * @param sessionId - Resolved session id + */ +async function captureAgentEvent( + agentName: string | undefined, + payload: unknown, + nativeEventName: string, + internalEventName: string, + sessionId: string +): Promise { + if (!agentName) { + return; + } + try { + const agent = AgentRegistry.getAgent(agentName); + const capture = agent?.metadata?.hookConfig?.captureEvent; + if (typeof capture === 'function') { + await capture(payload, nativeEventName, internalEventName, sessionId); + } + } catch (error) { + logger.debug('[hook] Failed to capture agent event (non-blocking):', error); + } +} + /** * Validate hook event required fields * @param event - Hook event to validate diff --git a/src/utils/project-root.ts b/src/utils/project-root.ts new file mode 100644 index 000000000..b69d4b3e4 --- /dev/null +++ b/src/utils/project-root.ts @@ -0,0 +1,43 @@ +/** + * Project root resolution shared by any feature that must agree on "the + * project", independent of the current working directory a hook or command + * happens to run from. + * + * `resolveLocalTargetPath('.codemie')` (`src/utils/paths.ts:106`) is purely + * CWD-relative (`path.join(process.cwd(), baseTargetDir)`). That is fine for + * commands invoked from the project root, but Cursor sets `cwd` per-event and + * it need not be the workspace root for every hook (e.g. a monorepo + * subpackage) - two features that each derive "project root" from `cwd()` + * independently could silently diverge on where that is. This module is the + * single shared resolver both the event-log writer (Task 6) and the + * `.cursor/hooks.json` connector (Task 8) call, so their file locations can + * never drift apart. + */ + +import { existsSync } from 'fs'; +import { dirname, join } from 'path'; + +/** + * Resolve the project root by walking up from `startDir` looking for a + * `.git` entry (directory for a normal checkout, file for a git worktree or + * submodule). Falls back to `startDir` itself if no `.git` is found before + * reaching the filesystem root. + * + * @param startDir - Directory to start the walk from. Defaults to `process.cwd()`. + */ +export function resolveProjectRoot(startDir: string = process.cwd()): string { + let current = startDir; + + while (true) { + if (existsSync(join(current, '.git'))) { + return current; + } + + const parent = dirname(current); + if (parent === current) { + // Reached the filesystem root without finding `.git`. + return startDir; + } + current = parent; + } +} From c1824090800ce3b3507ee11b02f7617cf327a2d7 Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Sat, 12 Sep 2026 12:28:05 +0300 Subject: [PATCH 10/46] feat(proxy): add cursor-ide.ts connector writing .cursor/hooks.json Adds writeCursorIdeHooksConfig/writeCursorIdeHooksConfigAtPath, mirroring the vscode-claude-code.ts read-merge-write-atomically shape and codex-desktop.ts's backup-on-first-modification. Wires all 21 Cursor native hook events to an absolute-path-resolved `codemie hook --agent cursor-ide`, upserting by a command substring so re-runs are idempotent and foreign hook entries/config keys are never clobbered. cursor-ide-hooks-analytics EPMCDME-14834, task 8 --- .../commands/proxy/connectors/cursor-ide.ts | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 src/cli/commands/proxy/connectors/cursor-ide.ts diff --git a/src/cli/commands/proxy/connectors/cursor-ide.ts b/src/cli/commands/proxy/connectors/cursor-ide.ts new file mode 100644 index 000000000..88612244f --- /dev/null +++ b/src/cli/commands/proxy/connectors/cursor-ide.ts @@ -0,0 +1,236 @@ +/** + * Writes and merges `.cursor/hooks.json` at the project root, wiring Cursor's + * full native hook surface (21 events) onto `codemie hook --agent cursor-ide`. + * + * Mirrors the read-merge-write-atomically shape of `vscode-claude-code.ts` + * (`{written, path}` result, a `...AtPath` test seam) and the backup-on-first- + * modification shape of `codex-desktop.ts`. Unlike those two, `.cursor/hooks.json` + * is merged additively by Cursor from every config source, so this connector + * must upsert - never clobber a user's existing file or foreign hook entries. + */ + +import { existsSync } from 'node:fs'; +import { copyFile, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ConfigurationError } from '@/utils/errors.js'; +import { logger } from '@/utils/logger.js'; +import { sanitizeLogArgs } from '@/utils/security.js'; +import { resolveCodemieBinary } from '@/utils/hook-command.js'; +import { resolveProjectRoot } from '@/utils/project-root.js'; +import { writeAtomically } from './vscode.js'; + +export const CURSOR_IDE_HOOKS_BACKUP_SUFFIX = '.codemie-backup'; + +/** + * Cursor's full native hook surface (21 events, per + * `cursor-ide.plugin.ts`'s `CURSOR_IDE_EVENT_NAME_MAPPING`). Kept in sync + * with that mapping's keys so the connector and the internal router always + * agree on Cursor's event surface. + */ +export const CURSOR_IDE_HOOK_EVENTS: readonly string[] = [ + 'sessionStart', + 'sessionEnd', + 'beforeSubmitPrompt', + 'stop', + 'preCompact', + 'subagentStart', + 'subagentStop', + 'preToolUse', + 'beforeShellExecution', + 'beforeMCPExecution', + 'beforeReadFile', + 'beforeTabFileRead', + 'postToolUse', + 'afterShellExecution', + 'afterMCPExecution', + 'afterFileEdit', + 'afterTabFileEdit', + 'postToolUseFailure', + 'afterAgentResponse', + 'afterAgentThought', + 'workspaceOpen', +]; + +// Identifies a CodeMie-authored entry so re-runs are idempotent even after +// the resolved binary path changes (absolute paths vary by machine/install). +const CODEMIE_COMMAND_MARKER = 'hook --agent cursor-ide'; + +interface CursorHookEntry { + [key: string]: unknown; + command?: unknown; + timeout?: unknown; + failClosed?: unknown; +} + +interface CursorHooksConfig { + [key: string]: unknown; + version?: unknown; + hooks?: Record; +} + +export interface WriteCursorIdeHooksResult { + written: boolean; + path: string; + backupPath: string | null; + events: string[]; +} + +function isCodemieEntry(entry: unknown): boolean { + return ( + typeof entry === 'object' && + entry !== null && + typeof (entry as CursorHookEntry).command === 'string' && + (entry as CursorHookEntry).command!.toString().includes(CODEMIE_COMMAND_MARKER) + ); +} + +async function readHooksConfig(configPath: string): Promise { + if (!existsSync(configPath)) return {}; + + let raw: string; + try { + raw = await readFile(configPath, 'utf-8'); + } catch (error) { + throw new ConfigurationError( + `Failed to read Cursor hooks config at ${configPath}: ` + + `${error instanceof Error ? error.message : String(error)}` + ); + } + + if (raw.trim().length === 0) return {}; + + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new ConfigurationError(`Cursor hooks config must contain a JSON object: ${configPath}`); + } + return parsed as CursorHooksConfig; + } catch (error) { + if (error instanceof ConfigurationError) throw error; + throw new ConfigurationError( + `Cursor hooks config at ${configPath} is not valid JSON and was not changed.` + ); + } +} + +/** + * Backup on first modification only - keyed on whether a codemie entry is + * already present, mirroring `codex-desktop.ts`'s `backupIfUnmanaged`. A + * config that already carries our entries has its true pre-CodeMie state in + * the existing backup (if any); a fresh backup at that point would enshrine + * our own entries as "the original". + */ +async function backupIfUnmanaged(configPath: string, config: CursorHooksConfig): Promise { + if (!existsSync(configPath)) return null; + + const backupPath = `${configPath}${CURSOR_IDE_HOOKS_BACKUP_SUFFIX}`; + const hooks = config.hooks ?? {}; + const alreadyManaged = Object.values(hooks).some((entries) => + Array.isArray(entries) && entries.some(isCodemieEntry) + ); + + if (alreadyManaged) { + if (existsSync(backupPath)) { + return backupPath; + } + // Managed but the backup is gone - nothing safe to reconstruct here + // (unlike Codex's TOML, we cannot cheaply strip just our entries out of + // an arbitrary hooks.json without already having done the merge), so + // skip rather than risk enshrining our own entries as the "original". + return null; + } + + await copyFile(configPath, backupPath); + return backupPath; +} + +/** + * Upsert exactly one CodeMie entry per event key, preserving every foreign + * entry and every foreign top-level key. Never deletes a user entry. + */ +function mergeHooksConfig( + existing: CursorHooksConfig, + command: string +): { config: CursorHooksConfig; events: string[] } { + const hooks: Record = { ...(existing.hooks ?? {}) }; + const events: string[] = []; + + for (const eventName of CURSOR_IDE_HOOK_EVENTS) { + const existingEntries: unknown[] = Array.isArray(hooks[eventName]) ? (hooks[eventName] as unknown[]) : []; + const foreignEntries = existingEntries.filter((entry) => !isCodemieEntry(entry)); + const codemieEntry: CursorHookEntry = { + command, + timeout: 10, + failClosed: false, + }; + hooks[eventName] = [...foreignEntries, codemieEntry]; + events.push(eventName); + } + + return { + config: { + ...existing, + version: existing.version ?? 1, + hooks, + }, + events, + }; +} + +/** + * Write/merge `.cursor/hooks.json` at an explicit path. Test seam mirroring + * `writeVsCodeClaudeCodeConfigAtPath` - the resolving wrapper below is the + * one every real caller uses. + */ +export async function writeCursorIdeHooksConfigAtPath( + configPath: string +): Promise { + const existing = await readHooksConfig(configPath); + const backupPath = await backupIfUnmanaged(configPath, existing); + + const binary = await resolveCodemieBinary(); + const command = `${binary} hook --agent cursor-ide`; + const { config: merged, events } = mergeHooksConfig(existing, command); + + try { + await writeAtomically(configPath, `${JSON.stringify(merged, null, 2)}\n`); + } catch (error) { + throw new ConfigurationError( + `Failed to write Cursor hooks config at ${configPath}: ` + + `${error instanceof Error ? error.message : String(error)}` + ); + } + + logger.info( + '[proxy] Configured Cursor IDE hooks', + ...sanitizeLogArgs({ configPath, backupPath, eventCount: events.length }) + ); + + return { written: true, path: configPath, backupPath, events }; +} + +export interface WriteCursorIdeHooksOptions { + /** Project root to resolve `.cursor/hooks.json` under. Defaults to `resolveProjectRoot()`. */ + projectRoot?: string; + /** + * Accepted for signature parity with the other connectors' `{force}` option + * (e.g. `writeCodexDesktopConfig`) and with `connectTargets`'s per-target + * dispatch, which passes `opts.force` uniformly. The merge here is always + * additive/idempotent and never refuses to write, so this is currently a + * no-op - there is no unsafe state for `--force` to override. + */ + force?: boolean; +} + +/** + * Write/merge `.cursor/hooks.json` at `/.cursor/hooks.json`, + * where `projectRoot` resolves via the same shared `resolveProjectRoot()` + * Task 6's event log uses, so the two locations can never drift apart. + */ +export async function writeCursorIdeHooksConfig( + options: WriteCursorIdeHooksOptions = {} +): Promise { + const projectRoot = options.projectRoot ?? resolveProjectRoot(); + const configPath = join(projectRoot, '.cursor', 'hooks.json'); + return writeCursorIdeHooksConfigAtPath(configPath); +} From 4a238da2cf1d6d9f58381c305a3ef9f592d81003 Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Sat, 12 Sep 2026 12:31:20 +0300 Subject: [PATCH 11/46] feat(proxy): add --analytics flag gating cursor-ide connect target Wires --cursor-ide --analytics to run the Task 8 connector, restructures connectTargets() to run cursor-ide standalone before the daemon-lifecycle block (it has no daemon of its own - hooks POST directly to /v1/metrics), and rejects --cursor-ide without --analytics with a clear re-run hint. cursor-ide-hooks-analytics EPMCDME-14834, task 7 --- .../__tests__/connect-orchestrator.test.ts | 4 +- .../commands/proxy/connect-orchestrator.ts | 75 ++++++++++++++++++- src/cli/commands/proxy/index.ts | 5 +- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts b/src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts index 712cf0aee..7b561ae75 100644 --- a/src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts +++ b/src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts @@ -244,7 +244,7 @@ describe('connectTargets — no-write paths and daemon lifecycle', () => { expect(process.exitCode).toBe(0); }); - it('--cursor-ide alone prints the analytics-only note and does no daemon/profile work', async () => { + it('--cursor-ide alone (without --analytics) explains --analytics is required and does no daemon/profile work', async () => { const { ConfigLoader } = await import('../../../../utils/config.js'); const { checkStatus, spawnDaemon } = await import('../daemon-manager.js'); const { connectTargets } = await import('../connect-orchestrator.js'); @@ -254,7 +254,7 @@ describe('connectTargets — no-write paths and daemon lifecycle', () => { expect(ConfigLoader.load).not.toHaveBeenCalled(); expect(checkStatus).not.toHaveBeenCalled(); expect(spawnDaemon).not.toHaveBeenCalled(); - expect(console_.log()).toHaveBeenCalledWith(expect.stringContaining('Only analytics is supported')); + expect(console_.log()).toHaveBeenCalledWith(expect.stringContaining('--cursor-ide requires --analytics')); expect(process.exitCode).toBe(0); }); diff --git a/src/cli/commands/proxy/connect-orchestrator.ts b/src/cli/commands/proxy/connect-orchestrator.ts index 552605961..96303ac4b 100644 --- a/src/cli/commands/proxy/connect-orchestrator.ts +++ b/src/cli/commands/proxy/connect-orchestrator.ts @@ -47,6 +47,7 @@ import { selectCodexModel, writeCodexDesktopConfig, } from './connectors/codex-desktop.js'; +import { writeCursorIdeHooksConfig } from './connectors/cursor-ide.js'; export const DEFAULT_DAEMON_PORT = 4001; @@ -70,6 +71,8 @@ export interface ConnectOptions { verbose?: boolean; /** Pin a specific model for the Codex desktop target. */ model?: string; + /** Gate for the cursor-ide target — analytics-only hook ingestion. */ + analytics?: boolean; } /** Effective client type used by `daemonMatchesRequest`. */ @@ -270,13 +273,14 @@ const TARGET_LIST = [ ' --vscode VS Code Copilot Chat models (BYOK)', ' --vscode-claude-code VS Code Claude Code extension', ' --codex-desktop Codex desktop app (writes ~/.codex/config.toml)', - ' --cursor-ide Cursor IDE — analytics only for now', + ' --cursor-ide Cursor IDE — writes .cursor/hooks.json (requires --analytics)', '', 'Examples:', ' codemie proxy connect --claude-desktop', ' codemie proxy connect --codex-desktop', ' codemie proxy connect --vscode --vscode-claude-code', ' codemie proxy connect --claude-desktop --vscode --insiders', + ' codemie proxy connect --cursor-ide --analytics', '', "Run 'codemie proxy connect --help' for all options.", ].join('\n'); @@ -590,6 +594,43 @@ async function runCodexDesktop( /** Test seam \u2014 the runner is otherwise only reachable through `connectTargets`. */ export const runCodexDesktopForTest = runCodexDesktop; +interface CursorIdeRunOptions { + force?: boolean; +} + +/** + * Writes/merges `.cursor/hooks.json`. Unlike every other target, cursor-ide + * needs no daemon: hooks POST directly to `/v1/metrics` via the metrics API + * client, not through the proxy (spec \u00a7Task 7 "Daemon"). Callable standalone, + * before any daemon lifecycle. + */ +async function runCursorIde(options: CursorIdeRunOptions): Promise { + const label = 'Cursor IDE'; + try { + const result = await writeCursorIdeHooksConfig({ force: options.force }); + console.log(chalk.green(`\u2713 Cursor IDE hooks configured (${result.path})`)); + console.log(chalk.dim(` ${result.events.length} event(s) wired to codemie hook --agent cursor-ide`)); + if (result.backupPath) { + console.log(chalk.dim(` Backup written: ${result.backupPath}`)); + } + console.log(chalk.yellow(' Workspace must be trusted for project hooks to run in Cursor.')); + console.log(chalk.dim(' Cursor hot-reloads hooks.json; restart Cursor if hooks do not pick up.')); + console.log(chalk.dim(' Enable transcripts in Cursor for transcript_path to be populated.')); + console.log(chalk.dim( + ' Cloud agents skip sessionStart/sessionEnd, the MCP hooks, the Tab hooks, and workspaceOpen.' + )); + return { label, ok: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn('[proxy] Failed to configure Cursor IDE hooks', ...sanitizeLogArgs({ error: message })); + console.log(chalk.yellow(` Could not configure Cursor IDE hooks: ${message}`)); + return { label, ok: false, error: message }; + } +} + +/** Test seam \u2014 the runner is otherwise only reachable through `connectTargets`. */ +export const runCursorIdeForTest = runCursorIde; + export async function connectTargets(opts: ConnectOptions): Promise { const { targets } = opts; if (!hasAnyTarget(targets)) { @@ -597,11 +638,38 @@ export async function connectTargets(opts: ConnectOptions): Promise { return; } - if (targets.cursorIde && !targets.claudeDesktop && !targets.vscode && !targets.vscodeClaudeCode && !targets.codexDesktop) { - console.log(chalk.yellow('Note: Only analytics is supported for --cursor-ide.')); + const analytics = Boolean(opts.analytics); + + if (targets.cursorIde && !analytics) { + console.log(chalk.yellow( + 'Note: --cursor-ide requires --analytics. Re-run with --cursor-ide --analytics.' + )); return; } + if (analytics && !targets.cursorIde) { + console.log(chalk.yellow('Note: --analytics has no effect without --cursor-ide.')); + } + + const otherTargets = Boolean( + targets.claudeDesktop || targets.vscode || targets.vscodeClaudeCode || targets.codexDesktop + ); + + // cursor-ide needs no daemon \u2014 run it standalone, before any daemon lifecycle. + // When it is the sole target, print the summary and return without ever + // calling resolveSsoProxyConfig/ensureDaemon. + let cursorIdeResult: TargetResult | undefined; + if (targets.cursorIde) { + cursorIdeResult = await runCursorIde({ force: Boolean(opts.force) }); + if (!otherTargets) { + printSummary([cursorIdeResult]); + if (!cursorIdeResult.ok) { + process.exitCode = 1; + } + return; + } + } + const verbose = Boolean(opts.verbose); const insiders = Boolean(opts.insiders); @@ -687,6 +755,7 @@ export async function connectTargets(opts: ConnectOptions): Promise { // Per-target dispatch (spec §3.4) — each writer runs independently. const results: TargetResult[] = []; + if (cursorIdeResult) results.push(cursorIdeResult); if (targets.claudeDesktop) results.push(await runClaudeDesktop(state, verbose)); if (targets.vscode) results.push(await runVscodeByok(state, insiders, config, verbose)); if (targets.vscodeClaudeCode) results.push(await runVscodeClaudeCode(state, insiders)); diff --git a/src/cli/commands/proxy/index.ts b/src/cli/commands/proxy/index.ts index bf811a574..0c989d5a4 100644 --- a/src/cli/commands/proxy/index.ts +++ b/src/cli/commands/proxy/index.ts @@ -36,6 +36,7 @@ interface UnifiedConnectOptions { vscodeClaudeCode?: boolean; codexDesktop?: boolean; cursorIde?: boolean; + analytics?: boolean; profile?: string; force?: boolean; verbose?: boolean; @@ -293,7 +294,8 @@ export function createProxyCommand(): Command { .option('--vscode', 'Configure VS Code Copilot Chat models — BYOK (writes chatLanguageModels.json)') .option('--vscode-claude-code', 'Configure the VS Code Claude Code extension (writes settings.json: ANTHROPIC_BASE_URL/token)') .option('--codex-desktop', 'Configure the Codex desktop app (writes ~/.codex/config.toml)') - .option('--cursor-ide', 'Configure Cursor IDE — analytics only for now') + .option('--cursor-ide', 'Configure Cursor IDE — writes .cursor/hooks.json (requires --analytics)') + .option('--analytics', 'Enable analytics-only hook ingestion (applies to --cursor-ide)') .option('--model ', 'Pin a specific model for --codex-desktop (default: best available)') .option('--profile ', 'Profile whose credentials to use') .option('--force', 'Stop any existing proxy and start a fresh one, even if it looks healthy') @@ -313,6 +315,7 @@ export function createProxyCommand(): Command { force: Boolean(opts.force), verbose: Boolean(opts.verbose), model: opts.model, + analytics: Boolean(opts.analytics), }); }); From 74a6bfa01e5c0c803a91494836a7f236c9b43508 Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Sat, 12 Sep 2026 12:37:37 +0300 Subject: [PATCH 12/46] feat(proxy): add --cursor-ide to proxy disconnect Adds removeCursorIdeHooksConfig to the cursor-ide connector: strips only codemie-authored entries from .cursor/hooks.json, drops an event key once its entry list is empty, and restores the .codemie-backup file when codemie's entries were the file's only content (or deletes a file we created from scratch when no backup exists). Wires a --cursor-ide flag onto proxy disconnect alongside --codex-desktop. cursor-ide-hooks-analytics EPMCDME-14834, task 9 --- .../proxy/__tests__/connect-wiring.test.ts | 4 +- .../commands/proxy/connectors/cursor-ide.ts | 101 +++++++++++++++++- .../commands/proxy/disconnect-orchestrator.ts | 48 +++++++-- src/cli/commands/proxy/index.ts | 10 +- 4 files changed, 153 insertions(+), 10 deletions(-) diff --git a/src/cli/commands/proxy/__tests__/connect-wiring.test.ts b/src/cli/commands/proxy/__tests__/connect-wiring.test.ts index 0f9bb3ac3..4806d7531 100644 --- a/src/cli/commands/proxy/__tests__/connect-wiring.test.ts +++ b/src/cli/commands/proxy/__tests__/connect-wiring.test.ts @@ -219,6 +219,8 @@ describe('proxy connect --codex-desktop and proxy disconnect', () => { await createProxyCommand().parseAsync(['disconnect', '--codex-desktop'], { from: 'user' }); - expect(disconnectTargets).toHaveBeenCalledWith({ targets: { codexDesktop: true } }); + expect(disconnectTargets).toHaveBeenCalledWith({ + targets: expect.objectContaining({ codexDesktop: true }), + }); }); }); diff --git a/src/cli/commands/proxy/connectors/cursor-ide.ts b/src/cli/commands/proxy/connectors/cursor-ide.ts index 88612244f..ea9b037b6 100644 --- a/src/cli/commands/proxy/connectors/cursor-ide.ts +++ b/src/cli/commands/proxy/connectors/cursor-ide.ts @@ -10,7 +10,7 @@ */ import { existsSync } from 'node:fs'; -import { copyFile, readFile } from 'node:fs/promises'; +import { copyFile, readFile, unlink } from 'node:fs/promises'; import { join } from 'node:path'; import { ConfigurationError } from '@/utils/errors.js'; import { logger } from '@/utils/logger.js'; @@ -234,3 +234,102 @@ export async function writeCursorIdeHooksConfig( const configPath = join(projectRoot, '.cursor', 'hooks.json'); return writeCursorIdeHooksConfigAtPath(configPath); } + +export interface RemoveCursorIdeHooksResult { + removed: boolean; + usedBackup: boolean; + path: string | null; +} + +/** + * Everything that remains once codemie's own entries are gone. `hooks` is + * "empty" once every event key it held has been dropped, and the config is + * empty once no foreign top-level key survives either - `version` doesn't + * count, since `mergeHooksConfig` sets it unconditionally (defaulting to 1) + * even for a file we created from scratch. + */ +function isConfigEmpty(config: CursorHooksConfig): boolean { + const hooks = config.hooks ?? {}; + const hasRemainingHookEntries = Object.values(hooks).some( + (entries) => Array.isArray(entries) && entries.length > 0 + ); + if (hasRemainingHookEntries) return false; + + const otherKeys = Object.keys(config).filter((key) => key !== 'version' && key !== 'hooks'); + return otherKeys.length === 0; +} + +/** + * Remove only codemie-authored entries at an explicit path - the disconnect + * counterpart to `writeCursorIdeHooksConfigAtPath`. Test seam mirroring the + * write side; the resolving wrapper below is the one every real caller uses. + */ +export async function removeCursorIdeHooksConfigAtPath( + configPath: string +): Promise { + if (!existsSync(configPath)) { + return { removed: false, usedBackup: false, path: null }; + } + + const existing = await readHooksConfig(configPath); + const hooks = existing.hooks ?? {}; + const hadCodemieEntry = Object.values(hooks).some( + (entries) => Array.isArray(entries) && entries.some(isCodemieEntry) + ); + + if (!hadCodemieEntry) { + return { removed: false, usedBackup: false, path: configPath }; + } + + // Strip our entries, dropping an event key entirely once its list is empty + // rather than leaving `"eventName": []` behind. + const nextHooks: Record = {}; + for (const [eventName, entries] of Object.entries(hooks)) { + if (!Array.isArray(entries)) { + nextHooks[eventName] = entries; + continue; + } + const remaining = entries.filter((entry) => !isCodemieEntry(entry)); + if (remaining.length > 0) { + nextHooks[eventName] = remaining; + } + } + + const stripped: CursorHooksConfig = { ...existing, hooks: nextHooks }; + const backupPath = `${configPath}${CURSOR_IDE_HOOKS_BACKUP_SUFFIX}`; + let usedBackup = false; + + if (isConfigEmpty(stripped)) { + if (existsSync(backupPath)) { + // Codemie's entries were the file's only content - restore the true + // pre-connect original rather than writing a near-empty shell. + const backupContent = await readFile(backupPath, 'utf-8'); + await writeAtomically(configPath, backupContent); + usedBackup = true; + } else { + // No pre-connect backup exists, so we created this file ourselves; + // nothing else to preserve, so remove it entirely. + await unlink(configPath); + } + } else { + await writeAtomically(configPath, `${JSON.stringify(stripped, null, 2)}\n`); + } + + logger.info( + '[proxy] Removed Cursor IDE hooks', + ...sanitizeLogArgs({ configPath, usedBackup }) + ); + + return { removed: true, usedBackup, path: configPath }; +} + +/** + * Remove/merge `.cursor/hooks.json` at `/.cursor/hooks.json`, + * resolved the same way `writeCursorIdeHooksConfig` does. + */ +export async function removeCursorIdeHooksConfig( + projectRoot: string = resolveProjectRoot() +): Promise { + const configPath = join(projectRoot, '.cursor', 'hooks.json'); + return removeCursorIdeHooksConfigAtPath(configPath); +} diff --git a/src/cli/commands/proxy/disconnect-orchestrator.ts b/src/cli/commands/proxy/disconnect-orchestrator.ts index 5f1d9808f..86c5ea0c3 100644 --- a/src/cli/commands/proxy/disconnect-orchestrator.ts +++ b/src/cli/commands/proxy/disconnect-orchestrator.ts @@ -10,9 +10,11 @@ import { logger } from '@/utils/logger.js'; import { sanitizeLogArgs } from '@/utils/security.js'; import { removeCodexDesktopConfig } from './connectors/codex-desktop.js'; +import { removeCursorIdeHooksConfig } from './connectors/cursor-ide.js'; export interface DisconnectTargets { codexDesktop?: boolean; + cursorIde?: boolean; } export interface DisconnectOptions { @@ -23,17 +25,13 @@ const DISCONNECT_TARGET_LIST = [ 'Select at least one target to disconnect:', '', ' --codex-desktop Codex desktop app (removes the CodeMie block from ~/.codex/config.toml)', + ' --cursor-ide Cursor IDE (removes codemie-authored entries from .cursor/hooks.json)', '', 'Example:', ' codemie proxy disconnect --codex-desktop', ].join('\n'); -export async function disconnectTargets(opts: DisconnectOptions): Promise { - if (!opts.targets.codexDesktop) { - console.log(DISCONNECT_TARGET_LIST); - return; - } - +async function disconnectCodexDesktop(): Promise { try { const result = await removeCodexDesktopConfig(); @@ -56,3 +54,41 @@ export async function disconnectTargets(opts: DisconnectOptions): Promise process.exitCode = 1; } } + +async function disconnectCursorIde(): Promise { + try { + const result = await removeCursorIdeHooksConfig(); + + if (!result.removed) { + console.log(chalk.dim('Cursor IDE: nothing to disconnect.')); + return; + } + + console.log(chalk.green(`✓ Cursor IDE hooks removed (${result.path})`)); + if (result.usedBackup) { + console.log(chalk.yellow( + "⚠ Restored the pre-connect backup because CodeMie's entries were the file's only content." + )); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn('[proxy] Cursor IDE disconnect failed', ...sanitizeLogArgs({ error: message })); + console.error(chalk.red(`✗ Cursor IDE — ${message}`)); + process.exitCode = 1; + } +} + +export async function disconnectTargets(opts: DisconnectOptions): Promise { + if (!opts.targets.codexDesktop && !opts.targets.cursorIde) { + console.log(DISCONNECT_TARGET_LIST); + return; + } + + if (opts.targets.codexDesktop) { + await disconnectCodexDesktop(); + } + + if (opts.targets.cursorIde) { + await disconnectCursorIde(); + } +} diff --git a/src/cli/commands/proxy/index.ts b/src/cli/commands/proxy/index.ts index 0c989d5a4..be69f3879 100644 --- a/src/cli/commands/proxy/index.ts +++ b/src/cli/commands/proxy/index.ts @@ -323,8 +323,14 @@ export function createProxyCommand(): Command { .command('disconnect') .description('Remove CodeMie proxy configuration from a client') .option('--codex-desktop', 'Remove the CodeMie block from ~/.codex/config.toml') - .action(async (opts: { codexDesktop?: boolean }) => { - await disconnectTargets({ targets: { codexDesktop: Boolean(opts.codexDesktop) } }); + .option('--cursor-ide', 'Remove codemie-authored entries from .cursor/hooks.json') + .action(async (opts: { codexDesktop?: boolean; cursorIde?: boolean }) => { + await disconnectTargets({ + targets: { + codexDesktop: Boolean(opts.codexDesktop), + cursorIde: Boolean(opts.cursorIde), + }, + }); }); // Deprecated aliases — kept working, mapped onto the unified target flags. From 763dbf6d98825bbd80345cfc12bea4c06f335a5c Mon Sep 17 00:00:00 2001 From: Uladzislau Mamantau Date: Sat, 12 Sep 2026 12:56:45 +0300 Subject: [PATCH 13/46] docs: add cursor-ide-hooks-analytics planning artifacts Stage 9 commit of sdlc-standard run artifacts: technical-analysis, spec, plan, complexity assessments, implementation ledger, code-review verdict, qa-gates result, and decision/event ledgers. --- .../actual-complexity.json | 40 +++ .../code-review-final.json | 1 + .../code-review.head | 1 + .../complexity-assessment.json | 36 +++ .../decisions.jsonl | 3 + .../events.jsonl | 6 + .../gate-run.json | 88 ++++++ .../implementation.jsonl | 9 + .../plan.md | 255 ++++++++++++++++++ .../spec.md | 97 +++++++ .../technical-analysis.md | 187 +++++++++++++ 11 files changed, 723 insertions(+) create mode 100644 docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/actual-complexity.json create mode 100644 docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/code-review-final.json create mode 100644 docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/code-review.head create mode 100644 docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/complexity-assessment.json create mode 100644 docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/decisions.jsonl create mode 100644 docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/events.jsonl create mode 100644 docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/gate-run.json create mode 100644 docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/implementation.jsonl create mode 100644 docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/plan.md create mode 100644 docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/spec.md create mode 100644 docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/technical-analysis.md diff --git a/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/actual-complexity.json b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/actual-complexity.json new file mode 100644 index 000000000..59d556bc6 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/actual-complexity.json @@ -0,0 +1,40 @@ +{ + "schema": 1, + "generated": "2026-09-12T00:00:00Z", + "dimensions": { + "component_scope": { "score": 6, "label": "XXL" }, + "requirements_clarity": { "score": 2, "label": "S" }, + "technical_risk": { "score": 4, "label": "L" }, + "file_change_estimate": { "score": 6, "label": "XXL" }, + "dependencies": { "score": 1, "label": "XS" }, + "affected_layers": { "score": 5, "label": "XL" } + }, + "total": 24, + "size": "L", + "band_range": "21-26", + "files_changed": 20, + "routing": "brainstorming", + "key_reasoning": [ + { + "dimension": "component_scope", + "reason": "Touches 4+ subsystems: the new cursor-ide agent plugin (6 files - plugin, types, constants, hook-transformer, response contract, event-log capture), the CLI proxy connect/disconnect orchestrators plus a new connectors/cursor-ide.ts hooks.json writer, the shared hook.ts routing core (new declarative hookConfig fields - captureEvent, writeStdoutResponse, neverBlockingExit, transcriptOptional - consumed generically for every agent, not just cursor-ide), and registry.ts registration. This is genuinely new cross-cutting abstraction added to shared core, not a localized change." + }, + { + "dimension": "file_change_estimate", + "reason": "20 files changed per diffstat (2316 insertions, 347 deletions) across src/agents/plugins/cursor-ide/ (6 new files), src/cli/commands/proxy/ (connectors, orchestrators, tests), src/cli/commands/hook.ts, src/agents/registry.ts, src/agents/core/types.ts, src/utils/project-root.ts (new), src/utils/logger.ts, plus docs and package-lock.json - the 16+ file band maps directly to XXL." + }, + { + "dimension": "affected_layers", + "reason": "CLI (hook.ts routing, proxy connect/disconnect commands), Service/agent-plugin core (cursor-ide plugin, hook transformer, registry), Infrastructure (new project-root.ts resolver, logger.ts stdout-suppression change), and External (Cursor IDE's own .cursor/hooks.json config file and its 21-event hook contract) - four layers including a genuine external-integration layer with cross-cutting declarative additions in the shared hook core." + }, + { + "dimension": "technical_risk", + "reason": "No exact precedent for a generic, declarative per-agent hook-config extension (captureEvent/writeStdoutResponse/neverBlockingExit/transcriptOptional) layered onto hook.ts, which is the shared hot path every existing agent's hooks already route through - a regression here risks every agent, not just cursor-ide. Mitigated by reusing the established read-merge-write-atomically connector pattern (vscode-claude-code.ts, codex-desktop.ts) for the hooks.json writer, and by reusing sanitizeLogArgs for the event-log capture rather than inventing new sanitization." + } + ], + "red_flags_applied": [ + "Component Scope bumped to XXL (capped at 6): Cursor IDE hook ingestion is a new external-service integration spanning the agent-plugin subsystem, CLI proxy connectors, and the shared hook-routing core (also touches core shared utilities: hook.ts, registry.ts, logger.ts).", + "Affected Layers bumped from L (4) to XL (5): the new external-service integration (.cursor/hooks.json) adds an External layer alongside CLI/Service/Infrastructure." + ], + "split_recommendation": null +} diff --git a/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/code-review-final.json b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/code-review-final.json new file mode 100644 index 000000000..d244b65f1 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/code-review-final.json @@ -0,0 +1 @@ +{"decision":"request-changes","rationale":"code-review-orchestrator could not dispatch review-triage or any lens agent: two attempts (initial + one re-dispatch per policy) both reported that no Agent/Task subagent-dispatch tool was available inside that agent's session in this environment. Diff was verified frozen and matching (3382 lines, sha256 976801f...), run_dir was writable, and profile=full was confirmed eligible, but no lens or standards review actually ran. This is an environment/tooling limitation, not a finding about the diff.","confidence":"low","risk_flags":[],"business_review":[],"standards_review":[],"findings":[]} diff --git a/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/code-review.head b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/code-review.head new file mode 100644 index 000000000..8a208bbbf --- /dev/null +++ b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/code-review.head @@ -0,0 +1 @@ +74a6bfa01e5c0c803a91494836a7f236c9b43508 diff --git a/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/complexity-assessment.json b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/complexity-assessment.json new file mode 100644 index 000000000..8fedbb93e --- /dev/null +++ b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/complexity-assessment.json @@ -0,0 +1,36 @@ +{ + "schema": 1, + "task": "Wire Cursor IDE's full native hook surface into codemie hook via a new --agent flag, a cursor-ide agent plugin/transformer, extended internal event routing, a zero-blocking stdout contract, a project-local JSONL event-capture log, and a new --analytics connector on proxy connect/disconnect.", + "generated": "2026-09-12T00:00:00Z", + "dimensions": { + "component_scope": { "score": 6, "label": "XXL" }, + "requirements_clarity": { "score": 1, "label": "XS" }, + "technical_risk": { "score": 6, "label": "XXL" }, + "file_change_estimate": { "score": 5, "label": "XL" }, + "dependencies": { "score": 1, "label": "XS" }, + "affected_layers": { "score": 5, "label": "XL" } + }, + "total": 24, + "size": "L", + "size_legend": { + "XS": "6-9 — < half day — plan directly", + "S": "10-14 — 1 day — plan directly", + "M": "15-20 — 2-3 days — brainstorm first", + "L": "21-26 — 4-5 days — brainstorm first", + "XL": "27-31 — > 1 sprint — recommend splitting", + "XXL": "32-36 — > 1 sprint — must split" + }, + "routing": "brainstorming", + "key_reasoning": [ + { "dimension": "component_scope", "reason": "Six architectural areas touched: CLI command layer (hook.ts flag + control-flow reordering, proxy/index.ts option), orchestration layer (connect-orchestrator.ts, disconnect-orchestrator.ts), a brand-new connector (cursor-ide.ts), a brand-new agent plugin (src/agents/plugins/cursor-ide/, 4 files + registry registration), a core shared-contract extension (InternalHookEventName 7->14 values in types.ts, consumed by every existing agent plugin), and two new shared utilities (project-root.ts, stdout response contract). Bumped from XL to XXL: touches a core shared contract (types.ts) consumed across the whole agent-plugin family." }, + { "dimension": "technical_risk", "reason": "Three genuinely novel mechanisms with no in-repo precedent (project-root-detection helper shared by two consumers, raw-payload JSONL capture with size-capping/truncation for payloads that can contain full file contents, a stdout-as-response-channel contract that must guarantee zero non-JSON bytes across success/error paths of a 1552-line file). hook.ts has three independent process.exit(2) sites to scope-skip for cursor-ide with no dedicated unit test file identified for that module. Bumped from XL to XXL: security-sensitive requirement to sanitize/size-cap raw shell-command and full-file-content payloads before writing them to a project-local log, with no prior in-repo precedent confirming sanitization sufficiency at this payload scale." }, + { "dimension": "file_change_estimate", "reason": "Roughly 9 modified files (hook.ts, types.ts, registry.ts, proxy/index.ts, connect-orchestrator.ts, disconnect-orchestrator.ts, plus the two proxy tests that must be updated to stay green, .gitignore) and ~7 new files (4-file cursor-ide plugin dir, stdout-response module, project-root.ts, cursor-ide connector) across 6 directories/subsystems (cli/commands, cli/commands/proxy, cli/commands/proxy/connectors, agents/plugins/cursor-ide, agents/core, utils) — matches the XL band (11-15 modified, 4-6 new, multiple subsystems) closely enough given the subsystem spread." }, + { "dimension": "affected_layers", "reason": "CLI/API (hook.ts, proxy/index.ts), Service/orchestration (connect-orchestrator.ts, connector, plugin logic), and an External-integration surface (Cursor IDE reads .cursor/hooks.json and drives the CLI via real subprocess hooks) — no persistence/schema migration involved. Bumped from L to XL: registering a brand-new external agent-plugin integration surface (cursor-ide) is treated as new-external-service integration per the red-flag rule, even though the plugin-registration pattern itself is precedented by Gemini/Kimi/Copilot." } + ], + "red_flags_applied": [ + "Component Scope bumped from XL (5) to XXL (6): touches core shared utilities/contract (InternalHookEventName extension in src/agents/core/types.ts, consumed by every registered agent plugin).", + "Technical Risk bumped from XL (5) to XXL (6): security requirement to sanitize and size-cap raw payloads (full file contents, shell commands) before persisting them to a project-local JSONL log, with the stdout channel doubling as Cursor's response contract.", + "Component Scope and Affected Layers bumped for integration with a new external service: registering the cursor-ide agent plugin and its .cursor/hooks.json connector as a new external-integration surface (Component Scope already capped at XXL; Affected Layers bumped from L (4) to XL (5))." + ], + "split_recommendation": null +} diff --git a/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/decisions.jsonl b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/decisions.jsonl new file mode 100644 index 000000000..23edbe6fb --- /dev/null +++ b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/decisions.jsonl @@ -0,0 +1,3 @@ +{"ts":"2026-09-12T08:05:40Z","gate_id":"spec.approved","mode":"hitl","verdict":{"decision":"approve","rationale":"spec accurately reflects plan.md scope, user approved","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} +{"ts":"2026-09-12T08:21:48Z","gate_id":"plan.approved","mode":"hitl","verdict":{"decision":"approve","rationale":"plan matches scope and task breakdown, user approved","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} +{"ts":"2026-09-12T09:47:29Z","gate_id":"code-review.final","mode":"hitl","verdict":{"decision":"approve","rationale":"Automated review could not run (environment tool-dispatch limitation, verified via two attempts). User explicitly chose to skip the code-review gate and proceed to QA gates, accepting the risk given diff was verified frozen/correct.","follow_ups":[],"confidence":"high","source":"hitl"},"escalated":false} diff --git a/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/events.jsonl b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/events.jsonl new file mode 100644 index 000000000..119cbceb9 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/events.jsonl @@ -0,0 +1,6 @@ +{"event":"lifecycle_emission","intent":"record_complexity_score","mode":"initial","status":"skipped"} +{"schema":1,"ts":"2026-09-12T08:05:40Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for spec.approved: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"spec.approved","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} +{"event":"lifecycle_emission","intent":"artifact_published","artifact_kind":"spec","status":"skipped"} +{"schema":1,"ts":"2026-09-12T08:21:48Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for plan.approved: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"plan.approved","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} +{"event":"lifecycle_emission","intent":"artifact_published","artifact_kind":"plan","status":"skipped"} +{"schema":1,"ts":"2026-09-12T09:47:29Z","event":"decision.recorded","phase":0,"actor":"sdlc-gate","summary":"Decision recorded for code-review.final: approve","artifacts":["decisions.jsonl"],"data":{"gate_id":"code-review.final","mode":"hitl","decision":"approve","source":"hitl","escalated":false}} diff --git a/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/gate-run.json b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/gate-run.json new file mode 100644 index 000000000..7794f27e8 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/gate-run.json @@ -0,0 +1,88 @@ +{ + "schema": 1, + "branch": "EPMCDME-14834/cursor-ide-otlp-integration", + "head": "74a6bfa01e5c0c803a91494836a7f236c9b43508", + "runner": "npm", + "started_at": "2026-09-12T12:48:00Z", + "completed_at": "2026-09-12T12:52:00Z", + "status": "PASSED", + "drift_detected": false, + "gates": [ + { + "id": "license-check", + "source": "guide", + "status": "PASS", + "duration_ms": 2873, + "command": "npm run license-check", + "exit_code": 0 + }, + { + "id": "lint", + "source": "guide", + "status": "PASS", + "duration_ms": 4666, + "command": "npm run lint", + "exit_code": 0 + }, + { + "id": "typecheck", + "source": "guide", + "status": "PASS", + "duration_ms": 5494, + "command": "npm run typecheck", + "exit_code": 0 + }, + { + "id": "build", + "source": "guide", + "status": "PASS", + "duration_ms": 8497, + "command": "npm run build", + "exit_code": 0 + }, + { + "id": "unit", + "source": "guide", + "status": "PASS", + "duration_ms": 34519, + "command": "npx vitest run --project unit", + "exit_code": 0, + "notes": "267 test files, 3957 tests passed." + }, + { + "id": "integration", + "source": "guide", + "status": "PASS", + "duration_ms": 30574, + "command": "npx vitest run --project cli", + "exit_code": 0, + "notes": "First attempt (exit 1, 4 failed specs in tests/integration/sso-claude-plugin.test.ts) was caused by ANTHROPIC_MODEL/ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN env vars leaking from this agent session's own shell (proxy-injected for the running Claude Code process), not by the branch diff. Re-ran with those vars unset: 37 passed | 1 skipped (38 files), 279 passed | 10 skipped (289 tests). This is an environment-contamination false failure specific to running gates from inside an active codemie proxy session, not a code defect." + }, + { + "id": "secrets", + "source": "hook", + "status": "SKIPPED", + "duration_ms": 484, + "command": "npm run validate:secrets", + "exit_code": 0, + "notes": "Self-skip: script output 'No staged changes to scan' - scans `git diff --staged` only (scripts/validate-secrets.js:87-96), and the working tree has no staged changes (all branch changes are already committed). To exercise it locally, stage a diff (e.g. `git diff HEAD~1..HEAD | git apply --cached` equivalent, or re-run mid-development before committing). CI's gitleaks-action step scans the full PR diff unconditionally regardless of staging state." + }, + { + "id": "lint-staged-affected", + "source": "hook", + "status": "N/A", + "command": "npx lint-staged (eslint --max-warnings=0 on staged *.ts, vitest related --run on staged *.ts, npm run license-check on staged package.json)", + "notes": "No staged changes present (git status shows a clean tree aside from this run's own task artifacts); lint-staged/vitest-related only has a diff to act on at commit time. Underlying eslint and license-check are already covered PASS by the standalone lint/license-check gates above; the 'vitest related' affected-test slice specifically could not be exercised without a staged diff." + }, + { + "id": "commitlint", + "source": "hook", + "status": "PASS", + "duration_ms": 609, + "command": "npm run commitlint:last", + "exit_code": 0, + "notes": "Checked HEAD~1..HEAD (single most recent commit per gate-plan scope); found 0 problems, 0 warnings." + } + ], + "failures": {} +} diff --git a/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/implementation.jsonl b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/implementation.jsonl new file mode 100644 index 000000000..a0df21e08 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/implementation.jsonl @@ -0,0 +1,9 @@ +{"task_id":"1","status":"done","commit":"f65363b","test_command":"npx vitest run src/cli/commands/__tests__/hook.lock.test.ts src/cli/commands/__tests__/hook-routing-contract.test.ts src/cli/commands/__tests__/hook.session-origin.test.ts"} +{"task_id":"2","status":"done","commit":"f65363b","test_command":"npx vitest run src/cli/commands/__tests__/hook.lock.test.ts src/cli/commands/__tests__/hook-routing-contract.test.ts src/cli/commands/__tests__/hook.session-origin.test.ts"} +{"task_id":"3","status":"done","commit":"6a83d01","test_command":"npx vitest run src/agents/__tests__/registry.test.ts src/agents/plugins/copilot-cli/__tests__/copilot-cli.registry.test.ts src/cli/commands/__tests__/hook.lock.test.ts src/cli/commands/__tests__/hook-routing-contract.test.ts src/cli/commands/__tests__/hook.session-origin.test.ts"} +{"task_id":"4","status":"done","commit":"4906020","test_command":"npx vitest run src/cli/commands/__tests__/hook.lock.test.ts src/cli/commands/__tests__/hook-routing-contract.test.ts src/cli/commands/__tests__/hook.session-origin.test.ts"} +{"task_id":"5","status":"done","commit":"625b337","test_command":"npx vitest run src/cli/commands/__tests__/hook.lock.test.ts src/cli/commands/__tests__/hook-routing-contract.test.ts src/cli/commands/__tests__/hook.session-origin.test.ts src/agents/__tests__/registry.test.ts","ruling":"Stdout purity for cursor-ide's response contract is guaranteed for the entire hook-processing path (post agent-resolution) and for default (CODEMIE_DEBUG unset) operation, which is stdout-clean end to end. Under CODEMIE_DEBUG=true, ~16 one-time bootstrap debug lines (provider/SSO-proxy plugin registration via cli/index.ts's unconditional `import '../providers/index.js'`, executed for every CLI subcommand before hook.ts's action ever runs) still print to stdout ahead of the response. Fixing that requires deferring cli/index.ts's eager provider-plugin imports until after argv is inspected - a cross-cutting bootstrap change outside Task 5's declared file list (hook.ts, cursor-ide.response.ts) and outside this plan's scope. Cost if wrong: a user who explicitly sets CODEMIE_DEBUG=true while running cursor-ide hooks could see Cursor mis-parse the stdout response on that one-time-per-process bootstrap window; normal (non-debug) operation is unaffected."} +{"task_id":"6","status":"done","commit":"06fac64","test_command":"npx vitest run src/cli/commands/__tests__/hook.lock.test.ts src/cli/commands/__tests__/hook-routing-contract.test.ts src/cli/commands/__tests__/hook.session-origin.test.ts src/agents/__tests__/registry.test.ts","ruling":"No optional-TDD failing test was authored for this task (plan Step 1 is optional; AGENTS.md rule 2 gates new test authoring on an explicit user request, which was not given). Verified instead via manual end-to-end smoke tests: session-id correlation via conversation_id, .git-relative project-root walk-up from a nested cwd, per-field truncation at 8192 chars with an explicit marker, sanitizeLogArgs() redacting a fake secret, and CODEMIE_CURSOR_HOOK_TRACE=0 disabling capture entirely. Added a new declarative AgentHookConfig.captureEvent extension point (mirroring Task 5's writeStdoutResponse) rather than an agentName literal in hook.ts's routeHookEvent, consistent with the plan's Architecture constraint 2."} +{"task_id":"8","status":"done","commit":"c182409","test_command":"npx vitest run src/cli/commands/proxy","ruling":"Implemented before Task 7 despite plan numbering, per the plan's own Task 7 text (\"--cursor-ide --analytics runs the Task 8 connector\") - Task 7's runCursorIde requires writeCursorIdeHooksConfig to exist first. No optional-TDD failing test was authored (plan Step 1 is optional; AGENTS.md rule 2 gates new test authoring on explicit user request). Verified via manual smoke tests: fresh write with all 21 events wired, idempotent re-run (byte-identical), foreign-entry preservation, backup-on-first-modification."} +{"task_id":"7","status":"done","commit":"4a238da","test_command":"npx vitest run src/cli/commands/proxy","ruling":"Depends on Task 8's writeCursorIdeHooksConfig (see Task 8 ledger entry). Manually verified all three gating combinations: --cursor-ide --analytics runs the connector; --cursor-ide alone warns and no-ops (exit 0); --analytics alone or combined with another target warns but doesn't block the other target. Updated the one plan-sanctioned pre-existing test assertion in connect-orchestrator.test.ts (old message text \"Only analytics is supported\" -> \"--cursor-ide requires --analytics\"); connect-wiring.test.ts needed no change since analytics is a new top-level ConnectOptions field, not part of ConnectTargets."} +{"task_id":"9","status":"done","commit":"74a6bfa","test_command":"npx vitest run src/cli/commands/proxy","ruling":"No optional-TDD failing test authored (plan Step 1 optional; AGENTS.md rule 2). Verified manually: foreign entries and other event keys preserved across disconnect; dropped-to-empty event keys removed entirely; backup restored when codemie's entries were the file's only content; file deleted entirely when it was created fresh by connect (no pre-connect backup existed); no-op reported cleanly when nothing was connected. Updated one pre-existing exact-equality assertion in connect-wiring.test.ts (disconnectTargets call now always includes cursorIde:false) to expect.objectContaining, mirroring the sibling connect-side assertion already in that file."} diff --git a/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/plan.md b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/plan.md new file mode 100644 index 000000000..f52bc8cb1 --- /dev/null +++ b/docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/plan.md @@ -0,0 +1,255 @@ +# Cursor IDE Hooks + Analytics Ingestion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `codemie proxy connect --cursor-ide --analytics` writes `.cursor/hooks.json` wiring Cursor's full native hook surface (21 events) to `codemie hook --agent cursor-ide`; every delivered event is normalized into the internal hook pipeline and captured verbatim to a project-local JSONL log, without ever blocking or slowing the user's action in Cursor. + +**Architecture:** Three seams, no new architectural patterns: (1) `--agent ` on `codemie hook`; (2) a registered `cursor-ide` agent plugin supplying `hookConfig.eventNameMapping` and a `HookTransformer`; (3) a `cursor-ide.ts` connector in the existing per-target dispatch writing/merging `.cursor/hooks.json`. No `if (agentName === 'cursor-ide')` branch anywhere — both seams 1 and 2 are consumed through existing `AgentRegistry` lookups (`applyHookTransformation`, `normalizeEventName`) with no call-site changes. + +**Tech Stack:** TypeScript, Commander, chalk, Vitest. No new dependencies. + +**Spec:** `docs/superpowers/tasks/2026-09-12-cursor-ide-hooks-analytics/spec.md` (distills `/Users/Uladzislau_Mamantau/projects/epam/codemie-code-fork/plan.md`, the original 10-task human-authored plan and canonical design record — full precedent detail, mapping tables, and file/line references live there and are not re-derived here). + +Commit per task using the repository's existing convention (Conventional Commits per `.ai-run/guides/standards/git-workflow.md`). + +Per AGENTS.md rule 2 (this repo): no new test authoring unless the user explicitly asks. Every task below carries a `Test-first: no` line noting TDD is optional per that rule; where an existing test must be updated to stay green (Task 7), that is fixing/updating a pre-existing test, not authoring a new one, and stays in scope. + +## Global Constraints + +- **stdout is Cursor's response channel.** Nothing but the deliberate response object (or nothing) may reach it on the cursor-ide path — includes `logger.debug()`'s and `logger.success()`'s `console.log` calls (`src/utils/logger.ts:308`, `:317-319`). +- **The cursor-ide path must never exit non-zero.** Three exit-2 sites must each be scoped to skip cursor-ide only: `.action` body's pre-transform parse/validate checks, `validateHookEvent`'s `process.exitCode = 2` assignments, `enforceAnalyticsAuthGate`'s `process.exit(2)` (`hook.ts:570`). All other agents keep today's blocking behavior unchanged. +- `transcript_path` is nullable; every handler and `validateHookEvent` (via new `AgentHookConfig.transcriptOptional?`) must degrade cleanly with no transcript. +- `.cursor/hooks.json` is merged additively by Cursor from all config sources — the connector must upsert, never clobber, and back up on first modification. +- No token/cost data exists on any Cursor hook payload except `preCompact`. +- Canonical agent name is `cursor-ide` everywhere (flag, `--agent` value, `metadata.name`, metrics `agent` field). + +--- + +### Task 1: Add `--agent ` to `codemie hook` + +**Files:** +- Modify: `src/cli/commands/hook.ts:1454-1457` (`createHookCommand`), `:133-164` (`initializeLoggerContext`), `:1352-1374` (`initializeHookContext`) + +**Interfaces:** +- Produces: `codemie hook --agent ` CLI flag, resolved agent name threaded into `initializeHookContext`. Precedence: flag beats `CODEMIE_AGENT` env; absent both, current throwing behavior unchanged. +- Consumed by: Task 8's generated `hooks.json` command string. + +Test-first: no — TDD optional per AGENTS.md rule 2; if written, failing test would assert `--agent cursor-ide` resolves `agentName` with no `CODEMIE_AGENT` set. + +- [ ] Step 1: Add `.option('--agent ', 'Agent name for hook attribution (overrides CODEMIE_AGENT)')` to `createHookCommand()`. +- [ ] Step 2: Split `initializeLoggerContext`'s two fused responsibilities — agent-name resolution (flag, then env, then throw) and session-id resolution (env, then payload-derived per Task 3) — and thread the resolved agent name into `initializeHookContext`. +- [ ] Step 3: Verify `echo '{...}' | CODEMIE_AGENT=claude codemie hook` is byte-for-byte unchanged in behavior (manual check, not an automated test). + +--- + +### Task 2: Transform before validate; stop exiting 2 for cursor-ide + +**Files:** +- Modify: `src/cli/commands/hook.ts:1461-1512` (`.action` body), `:1307-1345` (`validateHookEvent`) +- Modify: `src/agents/core/types.ts:652-667` (`AgentHookConfig`) + +**Why:** `session_id` is validated at `hook.ts:1479` and again at `:1308`, both before `applyHookTransformation` (`:1499`) runs. Cursor sends `conversation_id`, not `session_id`, so the payload is rejected with `process.exit(2)` before any transformer can map it. + +**Interfaces:** +- New order: parse -> resolve agent -> transform -> derive session id -> validate -> normalize -> route. +- Produces: `AgentHookConfig.transcriptOptional?: boolean`. + +Test-first: no — TDD optional per AGENTS.md rule 2; if written, failing test would assert a payload with `conversation_id` and no `session_id` routes successfully for `cursor-ide`. + +- [ ] Step 1: Delete the pre-transform duplicate `session_id`/`hook_event_name` checks at `:1479-1489`; `validateHookEvent` already covers both fields. +- [ ] Step 2: Move `applyHookTransformation` ahead of validation; reorder `initializeHookContext`'s `logger.setSessionId` call to run after the transform, using the transform-derived session id (Task 3 Step 5). +- [ ] Step 3: Scope the JSON-parse-failure exit code: for `cursor-ide` (known pre-stdin via Task 1's `--agent` flag), fail without exiting 2; for every other agent, keep the current `process.exit(2)` unchanged. +- [ ] Step 4: Add `transcriptOptional` to `AgentHookConfig`; honor it in `validateHookEvent`. Scope `validateHookEvent`'s three `process.exitCode = 2` assignments (`:1315`, `:1326`, `:1342`) the same way as Step 3 — for `cursor-ide`, degrade to a non-blocking failure instead. +- [ ] Step 5: Confirm no regression for Claude, Gemini, Kimi, Copilot — all four rely on this ordering and the exit-2 behavior staying intact for them. + +--- + +### Task 3: `cursor-ide` agent plugin and hook transformer + +**Files:** +- Create: `src/agents/plugins/cursor-ide/cursor-ide.constants.ts`, `cursor-ide.plugin.ts`, `cursor-ide.hook-transformer.ts`, `cursor-ide.types.ts` +- Modify: `src/agents/registry.ts:35-45` (register the plugin) +- Modify: `src/agents/core/types.ts:675-687` (`BaseHookEvent`) + +**Interfaces:** +- Produces: `CursorIdePlugin` exposing `metadata.hookConfig.eventNameMapping` (Task 4's table) and `getHookTransformer()`; `CursorIdeHookEvent extends BaseHookEvent`. +- Consumed by: `applyHookTransformation` (`hook.ts:1382-1403`) and `normalizeEventName` (`hook.ts:615-651`) via existing registry lookups — no call-site changes. + +**Transformer mapping** (verbatim from source plan, `plan.md:108-117`): `session_id` = `conversation_id` (fallback `session_id`, then `generation_id`); `transcript_path` = `transcript_path ?? ''`; `permission_mode` = `'default'`; `cwd` = `cwd ?? workspace_roots[0] ?? process.cwd()`; `hook_event_name` left as the Cursor-native name (not renamed by the transformer). + +Test-first: no — TDD optional per AGENTS.md rule 2; if written, failing test would cover `conversation_id` -> `session_id` and null `transcript_path` handling. + +- [ ] Step 1: Add constants — `CURSOR_IDE_AGENT_NAME = 'cursor-ide'`, display name, client type; set `metadata.analyticsOnly = true` (the sole gate excluding it from `codemie install/list/uninstall/update`, `types.ts:338`, `registry.ts:92-97` — leaving `cliCommand` unset is not sufficient on its own). +- [ ] Step 2: Add `CursorIdeHookEvent` type; add only `tool_name`, `tool_input`, `tool_output`, `tool_use_id` to shared `BaseHookEvent`, keeping Cursor-only fields in the plugin's own type. +- [ ] Step 3: Implement the transformer per the mapping above (`HookTransformer`, `types.ts:622-634`). +- [ ] Step 4: Write plugin metadata with `hookConfig` and `getHookTransformer()`; register in `src/agents/registry.ts`; confirm `AgentRegistry.getAgent('cursor-ide')` resolves. +- [ ] Step 5: Derive the CodeMie session id from the transformed `session_id` when `CODEMIE_SESSION_ID` is absent (pairs with Task 1 Step 2). + +--- + +### Task 4: Extend the internal event surface to cover all 21 Cursor events + +**Files:** +- Modify: `src/agents/core/types.ts:640-647` (`InternalHookEventName`), `:653-658` (doc comment) +- Modify: `src/cli/commands/hook.ts:663-739` (`routeHookEvent` switch), new handlers near `:600-605` + +**New internal event names:** `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `SubagentStart`, `AgentResponse`, `AgentThought`, `WorkspaceOpen`. Full 21-event mapping table: spec.md "Behavior notes" / source `plan.md:140-165`. None may fall through to `default:` (`hook.ts:703-705`). + +Test-first: no — TDD optional per AGENTS.md rule 2; if written, failing test would assert each of the 21 Cursor event names routes to a non-default branch. + +- [ ] Step 1: Extend `InternalHookEventName` with the 7 new names; update its doc comment and `AgentHookConfig`'s JSDoc listing valid mapping values together (per the analysis's noted convention that these two must stay in sync). +- [ ] Step 2: Add the 7 new handlers, observational only this run (debug-log and hand off to Task 6's capture); keep allocation-light, no awaited network I/O — these fire on the agent's hot path. +- [ ] Step 3: Add the corresponding switch cases in `routeHookEvent`; confirm no existing agent's routing changes. +- [ ] Step 4: Verify the post-switch transcript-marker block at `:711-725` stays inert for cursor-ide (already guarded by `event.transcript_path &&`). + +--- + +### Task 5: Cursor stdout response contract + +**Files:** +- Create: `src/agents/plugins/cursor-ide/cursor-ide.response.ts` +- Modify: `src/cli/commands/hook.ts:1461-1551` (`.action` body's success and catch paths) + +**Interfaces:** +- Produces: `writeCursorResponse(cursorEventName: string): void`, applied only when the resolved agent is `cursor-ide`. +- Response matrix: `preToolUse`/`beforeShellExecution`/`beforeMCPExecution`/`beforeReadFile`/`beforeTabFileRead`/`subagentStart` emit `{"permission":"allow"}`; `beforeSubmitPrompt` emits `{"continue":true}`; every other event emits nothing; always exit 0, including from the `catch` at `:1526-1550` (currently sets `process.exitCode = 1`). + +Test-first: no — TDD optional per AGENTS.md rule 2; if written, failing test would assert a thrown internal error still yields allow + exit 0. + +- [ ] Step 1: Implement the response writer per the matrix above. +- [ ] Step 2: Wire it into both the success and catch paths of the `.action` body, gated on `agentName === 'cursor-ide'`. +- [ ] Step 3: Audit stdout purity and every exit-2 site: confirm `logger.debug()`'s and `logger.success()`'s `console.log` (`src/utils/logger.ts:308`, `:317-319`) cannot reach stdout for `cursor-ide` (redirect to stderr or suppress on this path); confirm all three exit-2 sites from Task 2 are neutralized for `cursor-ide`, including `enforceAnalyticsAuthGate`'s `process.exit(2)` at `hook.ts:570` (reached via `handleUserPromptSubmit` at `:500` for `beforeSubmitPrompt` -> `UserPromptSubmit`) — a stale/missing analytics auth token must degrade to allow, not block. +- [ ] Step 4: Manual check — with `CODEMIE_DEBUG=true`, pipe a payload, assert stdout is exactly the response object or empty. + +--- + +### Task 6: Raw event capture to the project folder (primary AC) + +**Files:** +- Create: `src/agents/plugins/cursor-ide/cursor-ide.event-log.ts` +- Create: `src/utils/project-root.ts` (`resolveProjectRoot(startDir = process.cwd()): string`) +- Modify: `.gitignore` + +**Why shared helper:** `resolveLocalTargetPath('.codemie')` (`src/utils/paths.ts:106`) is CWD-relative, not project-root-detecting. Both this task's log path and Task 8's connector path must resolve project root identically so the two locations can never diverge. + +```ts +// src/utils/project-root.ts +function resolveProjectRoot(startDir = process.cwd()): string { + // walk up from startDir looking for a `.git` entry; fall back to startDir if none found +} +``` + +```ts +// JSONL record shape, one line per delivered event +interface CursorEventLogRecord { + received_at: string; + hook_event_name: string; // Cursor-native name + internal_event_name: string; // resolved via eventNameMapping + session_id: string; + conversation_id: string; + payload: unknown; // sanitized, size-capped raw event +} +``` + +**Interfaces:** +- Produces: `appendCursorEventLog(payload, cursorEventName, internalEventName, sessionId): Promise`, called from the routing path for every event. +- Path: `resolveProjectRoot()` + `.codemie/logs/cursor-hook-events.jsonl`. + +Test-first: no — TDD optional per AGENTS.md rule 2; if written, failing test would assert one line is appended per event with the required keys. + +- [ ] Step 1: Implement `resolveProjectRoot()` (walk up for `.git`, fall back to `cwd()`). +- [ ] Step 2: Implement the appender on top of it: create parent directories on demand, append-only (never rewrite). +- [ ] Step 3: Apply `sanitizeLogArgs()` (`src/utils/security.ts`) to every record; cap per-record size and truncate oversized `content`/`output` fields with an explicit truncation marker. +- [ ] Step 4: Swallow all failures (unwritable path, read-only workspace) — capture must never break a hook or delay the agent. +- [ ] Step 5: Gate behind `CODEMIE_CURSOR_HOOK_TRACE` (default on for this release), with a size cap and rotation. +- [ ] Step 6: Add `.codemie/logs/` to `.gitignore` (check first whether the existing bare `.codemie`/`/.cursor/` ignore lines already cover it, per the technical analysis's note, to avoid a redundant duplicate line). + +--- + +### Task 7: `--analytics` flag on `codemie proxy connect` + +**Files:** +- Modify: `src/cli/commands/proxy/index.ts:38` (`UnifiedConnectOptions`), `:292-301` (option chain), `:302-317` (action body) +- Modify: `src/cli/commands/proxy/connect-orchestrator.ts:65-73` (`ConnectOptions`), `:266-282` (`TARGET_LIST`), `:593-714` (`connectTargets`) +- Update (existing tests, not new authoring): `src/cli/commands/proxy/__tests__/connect-orchestrator.test.ts:247-259`, `connect-wiring.test.ts:47,52-62` — both currently assert the `--cursor-ide`-alone short-circuit this task replaces; update their assertions to match the new per-target runner so the suite stays green. + +**Gating:** `--cursor-ide --analytics` runs the Task 8 connector; `--cursor-ide` alone stays the existing no-op (do not touch); `--analytics` without `--cursor-ide` warns it applies only to `--cursor-ide`, mirroring the existing `--insiders`/`--model` warnings at `:608-616`. + +**Daemon:** cursor-ide requires none — run the connector before daemon lifecycle; if cursor-ide is the only target, print the summary and return without `resolveSsoProxyConfig`/`ensureDaemon`. It is already absent from `deriveDaemonIdentity` (`:99-107`) — keep it that way. + +Test-first: no — TDD optional per AGENTS.md rule 2; the two existing tests listed above must still be updated (not newly authored) so the suite stays green — that update is in scope regardless of TDD choice. + +- [ ] Step 1: Add the `--analytics` option and `ConnectOptions.analytics` field. +- [ ] Step 2: Replace the short-circuit at `:600-603` with a real `runCursorIde(...)` returning a `TargetResult` (`:379-383`) so the target appears in `printSummary`. +- [ ] Step 3: Register it in the per-target dispatch at `:689-700`, plus its own pre-daemon path when it is the sole target. +- [ ] Step 4: Update `TARGET_LIST` help text, dropping "analytics only for now". +- [ ] Step 5: Update `connect-orchestrator.test.ts:247-259` and `connect-wiring.test.ts:47,52-62` to assert the new gating behavior instead of the old short-circuit. + +--- + +### Task 8: `cursor-ide.ts` connector writing `.cursor/hooks.json` + +**Files:** +- Create: `src/cli/commands/proxy/connectors/cursor-ide.ts` + +**Precedent:** mirror `vscode-claude-code.ts` (read/merge/atomic-write, `{written, path}` shape) and `codex-desktop.ts` (backup: `BACKUP_SUFFIX = '.codemie-backup'`, `backupIfUnmanaged`). + +```ts +// .cursor/hooks.json per-event entry +interface CursorIdeHookEntry { + command: string; // " hook --agent cursor-ide" + timeout: 10; + failClosed: false; +} + +// Connector result +interface WriteCursorIdeHooksResult { + written: boolean; + path: string; + backupPath: string | null; + events: string[]; +} +``` + +**Interfaces:** +- Produces: `writeCursorIdeHooksConfig({ projectRoot, force }): Promise` and a `writeCursorIdeHooksConfigAtPath(configPath, ...)` test seam, mirroring `vscode-claude-code.ts:137,181`. +- Target: `/.cursor/hooks.json`, `projectRoot` resolved via Task 6's `resolveProjectRoot()` (`src/utils/project-root.ts`) — the same helper Task 6 uses, so the two file locations can never diverge. + +**File shape:** `version: 1`; one entry per event key of the form above; no `matcher`, no per-event argv. `failClosed: false` explicitly so analytics can never block the agent. `timeout: 10` matches Claude's `hooks.json`. + +**Merge semantics:** preserve `version` if present (else set `1`); preserve every foreign hook entry under every key; upsert exactly one codemie entry per event key identified by a `hook --agent cursor-ide` substring (idempotent across binary-path changes); never delete a user entry; backup to `.cursor/hooks.json.codemie-backup` on first modification; atomic write via `writeAtomically` from `./vscode.js` (`vscode.ts:204`). + +Test-first: no — TDD optional per AGENTS.md rule 2; if written, failing tests would cover fresh write, merge-with-foreign-entries, and idempotent re-run. + +- [ ] Step 1: Implement path resolution (via `resolveProjectRoot()`) and the read-merge-write cycle. +- [ ] Step 2: Implement backup-before-modify and atomic write per the merge semantics above. +- [ ] Step 3: Resolve the command binary via `resolveCodemieBinary()` (`src/utils/hook-command.ts:28`) for PATH-independent absolute resolution, including the Windows `node -@@ -83,3 +83,29 @@ describe('renderReportHtml', () => { - expect(JSON.parse(m![1]).sessions[0].sessionId).toBe('