From f52c76c8c1916f1b2cbb39c2d9f2ce2fafac5d6e Mon Sep 17 00:00:00 2001 From: mykolanehrych Date: Tue, 28 Jul 2026 14:25:27 +0300 Subject: [PATCH] feat(analytics): attribute Claude Desktop sessions by repository and branch Resolve and attribute the correct repository/branch per Claude Desktop session (TCP process lookup, per-request X-CodeMie-Repository header, session-to-repository map with await-poll, X-CodeMie-Branch header injection), fix Cowork vs Code tab session attribution and branch fallback, add api-key auth support for local dev/Code integration sessions, rename Desktop sandbox repository label to 'Cowork', and harden --add-dir path handling and lsof/repo-resolution logic. Ref: EPMCDME-12687 --- .../code-review-final.json | 86 ++++ .../plan.md | 238 +++++++++++ .../spec.md | 167 ++++++++ .../technical-analysis.md | 197 +++++++++ src/bin/proxy-daemon.ts | 9 +- .../proxy/plugins/header-injection.plugin.ts | 384 +++++++++++++++++- .../plugins/sso/proxy/proxy-types.ts | 5 + src/providers/plugins/sso/proxy/sso.proxy.ts | 1 + .../processors/metrics/metrics-aggregator.ts | 13 +- .../metrics/metrics-sync-processor.ts | 3 +- .../claude-desktop.discovery.ts | 4 +- .../runtime/DesktopTelemetryRuntime.ts | 89 +++- src/telemetry/runtime/types.ts | 1 + src/utils/__tests__/paths.test.ts | 12 +- src/utils/paths.ts | 6 +- 15 files changed, 1192 insertions(+), 23 deletions(-) create mode 100644 docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/code-review-final.json create mode 100644 docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/plan.md create mode 100644 docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/spec.md create mode 100644 docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/technical-analysis.md diff --git a/docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/code-review-final.json b/docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/code-review-final.json new file mode 100644 index 000000000..6187f49ed --- /dev/null +++ b/docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/code-review-final.json @@ -0,0 +1,86 @@ +{ + "decision": "approve", + "rationale": "Four findings from self-review. Three (concurrent-resolution race, dual-write overwrite, stale comment) confirmed real and fixed. One (numbered 'Fix N' comment references pointing to an external, uncommitted document) confirmed as a documentation hazard and removed. No outstanding findings.", + "confidence": "high", + "risk_flags": [], + "business_review": [ + { + "criterion": "Repository and branch are resolved per Desktop LLM request, not once per proxy daemon lifetime.", + "status": "pass", + "notes": "header-injection.plugin.ts resolves per cliSessionId via a three-strategy chain (process CWD lookup, session file scan, process tree descent), cached in config.sessionRepositoryMap." + }, + { + "criterion": "Cowork and Code tab sessions are distinguishable in analytics.", + "status": "pass", + "notes": "isCodeIntegration flag tracked through the resolution chain; Cowork sessions explicitly assert X-CodeMie-Client=claude-desktop and are tracked in sessionCoworkMap for subsequent cache-hit requests; Code tab sessions inherit the config.clientType default (also claude-desktop, verified against live data — see CR-003)." + }, + { + "criterion": "A user switching between Cowork, a Cowork project, and the Code tab (potentially a different project) within a single Desktop session sees each segment attributed separately.", + "status": "pass", + "notes": "Each such switch corresponds to a new cliSessionId; the per-session cache (sessionRepositoryMap) resolves and stores each independently. Verified by code-structure reasoning (local debug logs from the original development period were unavailable — see technical-analysis.md 'Log Investigation')." + } + ], + "standards_review": [ + { + "standard": "commit-format", + "status": "na", + "notes": "Changes not yet committed at time of this review (working tree only)." + }, + { + "standard": "code-quality", + "status": "pass", + "notes": "npx tsc --noEmit and npx eslint --max-warnings=0 both clean on header-injection.plugin.ts and DesktopTelemetryRuntime.ts after all fixes." + }, + { + "standard": "security-patterns", + "status": "pass", + "notes": "All lsof/ps shell-outs use fixed command templates with only a numeric pid/port interpolated (never raw user/request input) — no injection surface. macOS-only gate prevents unexpected behavior on other platforms." + } + ], + "findings": [ + { + "id": "CR-001", + "severity": "minor", + "triage": "patch", + "outcome": "fixed", + "problem": "Concurrent requests for the same brand-new cliSessionId (subprocess + orchestrator, which the code's own lastDesktopRepo comment notes arrive within ~200ms of each other) each independently checked '!config.sessionRepositoryMap.has(cliSessionId)' and, finding it unset, each kicked off its own lsof/ps resolution chain.", + "impact": "Redundant lsof/ps process spawns for the same session (minor CPU/latency cost), and a narrow theoretical window where the two independent resolutions could disagree if process state changed between the two lookups.", + "recommendation": "Extract the resolution chain into resolveDesktopSessionRepository(), backed by an in-flight-promise cache (Map>) so a second concurrent request awaits the first's in-progress resolution instead of starting its own.", + "file": "src/providers/plugins/sso/proxy/plugins/header-injection.plugin.ts", + "line": 97 + }, + { + "id": "CR-002", + "severity": "minor", + "triage": "patch", + "outcome": "fixed", + "problem": "DesktopTelemetryRuntime.ensureSession() wrote into the shared config.sessionRepositoryMap unconditionally on first session discovery, with no check for whether header-injection.plugin.ts's per-request resolution (which runs first, in real time, via process-level CWD lookup) had already resolved a value for that session.", + "impact": "The telemetry poll (every ~10s) could silently overwrite a more precise per-request resolution with its own, session-file-derived one — no data corruption expected in the common case (both usually resolve the same directory), but no correctness guarantee either, and no coordination between the two independent writers.", + "recommendation": "Add the same 'don't overwrite if already set' guard the header-injection side already uses: '!this.config.sessionRepositoryMap?.has(discovered.agentSessionId)' before the .set() call.", + "file": "src/telemetry/runtime/DesktopTelemetryRuntime.ts", + "line": 158 + }, + { + "id": "CR-003", + "severity": "minor", + "triage": "patch", + "outcome": "fixed", + "problem": "In-code comment claimed 'For Code tab (isCodeIntegration=true): client stays as CLI' — but no code path in the file ever sets X-CodeMie-Client to a CLI value for this case; the header simply isn't touched, leaving config.clientType's Desktop-mode default (claude-desktop) in place.", + "impact": "Misleading documentation for future maintainers — could lead someone to 'fix' correctly-working code because it doesn't match what the comment claims, or to misdiagnose an unrelated bug.", + "recommendation": "Verified against live preview Elasticsearch (client_type=claude-desktop documents with a non-empty branch — the Code tab signature — found: 86 matches, e.g. epm-cdme/codemie-ui | EPMCDME-12687 | claude-desktop). Corrected the comment to describe actual, verified behavior.", + "file": "src/providers/plugins/sso/proxy/plugins/header-injection.plugin.ts", + "line": 176 + }, + { + "id": "CR-004", + "severity": "info", + "triage": "patch", + "outcome": "fixed", + "problem": "Multiple comments referenced a 'Fix 3' / 'Fix 4' / 'Fix 4B' numbering scheme from a personal working spec that was never committed to this repository (confirmed: not present anywhere in docs/, moved to the ticket owner's local ~/Downloads/spec.md outside any repo during an earlier cleanup pass).", + "impact": "Dangling references — a future reader grepping for 'Fix 4B' or trying to find what 'Fix 3' means would find nothing in the repository.", + "recommendation": "Rewrote all such comments to describe the code directly, with no external numbering scheme to maintain. Confirmed via grep that no repo in this ticket's scope (codemie, codemie-ui, codemie-code) has any other instance of this pattern introduced by this branch.", + "file": "src/providers/plugins/sso/proxy/plugins/header-injection.plugin.ts", + "line": 89 + } + ] +} diff --git a/docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/plan.md b/docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/plan.md new file mode 100644 index 000000000..29928002d --- /dev/null +++ b/docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/plan.md @@ -0,0 +1,238 @@ +# EPMCDME-12687: Claude Desktop Repository/Branch Attribution — Implementation Plan + +**Goal:** Resolve `X-CodeMie-Repository`/`X-CodeMie-Branch`/`X-CodeMie-Client` per LLM request +in Desktop mode, instead of once per proxy daemon lifetime, so each Cowork/Code-tab session — +and each project switch within a Desktop session — attributes correctly in analytics. + +**Architecture:** A three-strategy resolution chain (process CWD lookup → session file scan → +process tree descent) in `header-injection.plugin.ts`, results cached per `cliSessionId` in a +`Map` shared via `ProxyConfig`. A parallel, independent write path in +`DesktopTelemetryRuntime.ts`'s poll loop populates the same cache for sessions discovered +before any LLM request arrives. + +**Tech Stack:** TypeScript, Node.js `child_process`/`fs` (macOS `lsof`/`ps` shell-outs), +Vitest. + +--- + +## File Structure + +| File | Action | Responsibility | +|---|---|---| +| `src/providers/plugins/sso/proxy/plugins/header-injection.plugin.ts` | Modify | Per-request resolution chain, in-flight dedup, header injection | +| `src/providers/plugins/sso/proxy/proxy-types.ts` | Modify | Add `sessionRepositoryMap`, `sessionCoworkMap`, `lastDesktopRepo`, `remotePort` to shared config/context types | +| `src/providers/plugins/sso/proxy/sso.proxy.ts` | Modify | Populate `context.remotePort` from the incoming socket | +| `src/bin/proxy-daemon.ts` | Modify | Wire the shared maps into `ProxyConfig` and `DesktopTelemetryRuntime` at daemon startup | +| `src/telemetry/runtime/DesktopTelemetryRuntime.ts` | Modify | Poll-based session discovery also resolves/caches repository; final poll on stop | +| `src/telemetry/runtime/types.ts` | Modify | Add `sessionRepositoryMap` to `DesktopTelemetryRuntimeConfig` | +| `src/telemetry/clients/claude-desktop/claude-desktop.discovery.ts` | Modify | `userSelectedFolders` fallback; export `walk()` | +| `src/utils/paths.ts` | Modify | `extractRepository()` sandbox fallback: `'Claude Desktop'` → `'Cowork'` | +| `src/providers/plugins/sso/session/processors/metrics/metrics-aggregator.ts` | Modify | Delta branch fallback to `session.gitBranch` | +| `src/providers/plugins/sso/session/processors/metrics/metrics-sync-processor.ts` | Modify | Same fallback for the branch-matching filter | +| `src/utils/__tests__/paths.test.ts` | Modify | Update expected label to `Cowork` | + +--- + +## Task 1: Per-request repository resolution chain + +**Test-first: no.** No existing unit harness mocks `lsof`/`ps` process output or a live +Desktop proxy session for this plugin; verified via `CODEMIE_DEBUG=true` runs against a real +Claude Desktop instance connected through `codemie proxy connect desktop`, cross-checked +against Elasticsearch (`codemie_metrics_logs`) for the resulting `repository`/`branch`/ +`client_type` attribution. + +**Files:** +- Modify: `src/providers/plugins/sso/proxy/plugins/header-injection.plugin.ts` +- Modify: `src/providers/plugins/sso/proxy/proxy-types.ts` +- Modify: `src/providers/plugins/sso/proxy/sso.proxy.ts` +- Modify: `src/bin/proxy-daemon.ts` + +--- + +- [x] **Step 1: Add shared resolution state to `ProxyConfig`/`ProxyContext`** + +```ts +// proxy-types.ts +sessionRepositoryMap?: Map; +sessionCoworkMap?: Set; +lastDesktopRepo?: { repo: string; branch: string | null; ts: number }; +// ProxyContext: +remotePort?: number; +``` + +- [x] **Step 2: Populate `remotePort` from the incoming socket** + +`sso.proxy.ts`: `remotePort: req.socket?.remotePort` when building `ProxyContext`. + +- [x] **Step 3: Wire the maps at daemon startup** + +`proxy-daemon.ts`, inside the `telemetryMode === 'claude-desktop'` branch: + +```ts +const sessionRepositoryMap = new Map(); +config.sessionRepositoryMap = sessionRepositoryMap; +config.sessionCoworkMap = new Set(); +``` + +- [x] **Step 4: Implement the three resolution strategies** + +In `header-injection.plugin.ts`, three helper functions, each `macOS`-gated +(`process.platform !== 'darwin'` short-circuits to `null`): + +1. `getPidForRemotePort(remotePort)` — `lsof -n -P -i 4TCP@127.0.0.1:${remotePort}`, matches + the outbound-direction line, excludes the proxy's own PID. +2. `findWorkingDirViaProcess(pid)` — `ps -p ${pid} -o args=`, extracts `--add-dir ` via + lazy regex (stops at the next `--` flag, so paths with spaces work); falls back to + `lsof -a -d cwd -p ${pid} -Fn` for the OS-level cwd, rejecting `$HOME`/`/Library/`/ + `.app/Contents` paths (default launcher locations, not real project dirs). +3. `findWorkingDirForSession(cliSessionId)` — walks both Desktop session-file roots + (`getClaudeDesktopLocalSessionsRoot()`, `getClaudeDesktopCodeSessionsRoot()`), parses each + JSON file, matches on `cliSessionId`, reads `originCwd`/`worktreePath`/ + `userSelectedFolders[0]`/`cwd` in that priority order. +4. `findWorkingDirForDesktopDirectRequest(connectingPid)` — for orchestrator requests + (`context.url?.includes('beta=true')`): builds a `pid → {ppid, args}` map from + `ps -axww -o pid,ppid,args`, walks up from the renderer PID to the `Claude.app` root, then + BFS-descends looking for a subprocess with `--add-dir` or `--output-format stream-json` + (resolving the latter's cwd via `lsof`, same rejection rules as step 2). + +- [x] **Step 5: Compose the chain and inject headers** + +```ts +if (cliSessionId && !config.sessionRepositoryMap.has(cliSessionId)) { + const resolved = await resolveDesktopSessionRepository(cliSessionId, context); + if (resolved) { + config.sessionRepositoryMap.set(cliSessionId, resolved.repository); + config.lastDesktopRepo = { repo: resolved.repository, branch: resolved.branch, ts: Date.now() }; + if (resolved.branch) context.headers['X-CodeMie-Branch'] = resolved.branch; + if (!resolved.isCodeIntegration) { + config.sessionCoworkMap?.add(cliSessionId); + context.headers['X-CodeMie-Client'] = 'claude-desktop'; + } + } else { + const last = config.lastDesktopRepo; + if (last && Date.now() - last.ts < 30_000) { + config.sessionRepositoryMap.set(cliSessionId, last.repo); + if (last.branch) context.headers['X-CodeMie-Branch'] = last.branch; + } + } +} +const resolvedRepository = cliSessionId + ? (config.sessionRepositoryMap.get(cliSessionId) ?? config.repository ?? 'Cowork') + : (config.repository ?? 'Cowork'); +context.headers['X-CodeMie-Repository'] = resolvedRepository; +if (cliSessionId && config.sessionCoworkMap?.has(cliSessionId)) { + context.headers['X-CodeMie-Client'] = 'claude-desktop'; +} +``` + +- [x] **Step 6: Verify against a real Desktop session** + +```bash +codemie proxy connect desktop +# Open Desktop, exercise Cowork (no folder), Cowork+folder, and Code tab scenarios +codemie proxy stop +``` + +Cross-check `cli-insights-user-repositories` for the test user/period against the three +scenarios documented in spec.md. + +--- + +## Task 2: Supporting resolution fixes + +**Test-first: partial** — `paths.test.ts` updated alongside the `paths.ts` label change +(existing test file, not new coverage). + +**Files:** +- Modify: `src/telemetry/clients/claude-desktop/claude-desktop.discovery.ts` +- Modify: `src/utils/paths.ts` +- Modify: `src/utils/__tests__/paths.test.ts` +- Modify: `src/providers/plugins/sso/session/processors/metrics/metrics-aggregator.ts` +- Modify: `src/providers/plugins/sso/session/processors/metrics/metrics-sync-processor.ts` +- Modify: `src/telemetry/runtime/DesktopTelemetryRuntime.ts` +- Modify: `src/telemetry/runtime/types.ts` + +--- + +- [x] **Step 1: `userSelectedFolders` fallback + export `walk()`** + +`claude-desktop.discovery.ts`: add `userSelectedFolders?: string[]` to `DesktopMetadata`, +insert before `cwd` in the working-directory priority chain; `export async function walk(...)` +so the header-injection session-file scan can reuse it. + +- [x] **Step 2: Rename Desktop sandbox label to `Cowork`** + +`paths.ts`: `extractRepository()` returns `'Cowork'` (was `'Claude Desktop'`) for the sandbox +path regex match. Update all 5 assertions in `paths.test.ts` to match. + +- [x] **Step 3: Branch fallback in metric aggregation** + +`metrics-aggregator.ts`: `const branch = delta.gitBranch || session.gitBranch || '';` (was +`delta.gitBranch || ''`) when grouping deltas — individual Desktop deltas never carry +`gitBranch`, only the session object does. + +`metrics-sync-processor.ts`: same fallback in the branch-matching filter that pairs pending +deltas with their metric for sync. + +- [x] **Step 4: Telemetry runtime resolves project + writes to the shared map** + +`DesktopTelemetryRuntime.ts` `ensureSession()`: resolve `project` via +`ConfigLoader.load(discovered.workingDirectory)` alongside the existing `gitBranch`/ +`repository` `Promise.all`; write into `config.sessionRepositoryMap` (guarded, see Task 3 Fix +B). `stop()`: run one final `poll()` before finalizing tracked sessions. + +- [x] **Step 5: Lint + typecheck** + +```bash +npx eslint src/telemetry/clients/claude-desktop/claude-desktop.discovery.ts src/utils/paths.ts src/providers/plugins/sso/session/processors/metrics/ src/telemetry/runtime/ +npx tsc --noEmit +``` + +--- + +## Task 3: Review fixes + +**Test-first: no.** + +**Files:** +- Modify: `src/providers/plugins/sso/proxy/plugins/header-injection.plugin.ts` +- Modify: `src/telemetry/runtime/DesktopTelemetryRuntime.ts` + +--- + +- [x] **Step 1 (Fix A): In-flight promise dedup for concurrent resolution** + +Extracted the resolution chain (Task 1 Step 4-5's lookup logic) into +`resolveDesktopSessionRepositoryUncached(cliSessionId, context)`, wrapped by +`resolveDesktopSessionRepository()` which checks/populates a module-level +`inFlightSessionResolutions: Map>` keyed by +`cliSessionId`, removing the entry in a `finally` block once settled. + +- [x] **Step 2 (Fix B): Guard the telemetry-poll write against overwriting a per-request result** + +```ts +// DesktopTelemetryRuntime.ts ensureSession() +if (!discovered.agentSessionId.startsWith('local_') && !this.config.sessionRepositoryMap?.has(discovered.agentSessionId)) { + this.config.sessionRepositoryMap?.set(discovered.agentSessionId, repository || 'Cowork'); +} +``` + +- [x] **Step 3 (Fix C): Correct the stale "client stays as CLI" comment** + +Rewrote to describe verified actual behavior (client inherits `config.clientType` = +`claude-desktop` for Code tab; only explicitly reasserted for Cowork). See spec.md +"Client Attribution" for the live-data verification. + +- [x] **Step 4 (Fix D): Remove numbered "Fix N" comment references** + +All `// Fix 3`, `// Fix 4`, `// Fix 4B` comment prefixes rewritten to describe the code +directly, since the numbering scheme they referenced was never committed to this repo. + +- [x] **Step 5: Typecheck + lint** + +```bash +npx tsc --noEmit +npx eslint src/providers/plugins/sso/proxy/plugins/header-injection.plugin.ts src/telemetry/runtime/DesktopTelemetryRuntime.ts --max-warnings=0 --no-warn-ignored +``` + +Both clean. diff --git a/docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/spec.md b/docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/spec.md new file mode 100644 index 000000000..a3eb6fc3d --- /dev/null +++ b/docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/spec.md @@ -0,0 +1,167 @@ +# Spec — EPMCDME-12687: Claude Desktop Per-Request Repository/Branch Attribution + +## Problem + +Claude Desktop sends all LLM traffic through the local proxy (`codemie proxy connect +desktop`, listening on `127.0.0.1:4001`). Before this change, the proxy could only inject a +single, static `X-CodeMie-Repository` value (`config.repository`, resolved once at proxy +startup from the CLI's own launch directory) into every outgoing request header. Since +Desktop is a single long-running app that a user can point at many different projects across +the day — Cowork chat with no folder, Cowork with a folder open, or the Code tab (Claude +Code running inside Desktop) — a static per-daemon repository value cannot distinguish any of +that. Every Desktop LLM cost landed under one repository (or `Cowork`), making the CLI +Insights Repositories table useless for a Desktop user's real activity. + +--- + +## Approach + +Resolve repository (and branch) **per request**, keyed by the `x-claude-code-session-id` +header Claude Desktop sends on every LLM call (`cliSessionId`), and cache the result in a +`Map` shared across requests for the life of the proxy daemon (`config.sessionRepositoryMap`). +Three independent resolution strategies run in order, each covering a gap the previous one +can't: + +1. **Process CWD lookup** — resolve the PID of the process holding the TCP connection at + `context.remotePort` (via `lsof`), then read that process's `--add-dir` argument (Cowork + + folder) or OS-level current working directory (Code tab). Works from the very first + message, before Desktop has written any session file to disk. macOS only, ~50ms. +2. **Session file scan** — Desktop writes a session JSON file (`local_.json`) to one of + two roots (`local-agent-mode-sessions/` for Cowork, `claude-code-sessions/` for Code tab) + **after** the first LLM response, not before the request. Scan both roots for a file whose + `cliSessionId` matches, and read its `cwd` / `originCwd` / `worktreePath` / + `userSelectedFolders[0]`. Only useful from the session's second message onward. +3. **Process tree descent** — for orchestrator requests (identified by `?beta=true` in the + URL), the connecting process is the Desktop renderer itself, not the `claude` subprocess, + so strategy 1 fails (renderer's cwd is generic, not the project folder). Walk up from the + renderer to the Claude app root process, then BFS down the process tree looking for a + `claude` subprocess with `--add-dir` (Cowork+folder) or `--output-format stream-json` + (Code tab, resolved via its own `lsof` cwd). + +If none of the three resolve a working directory, fall back to the most recently resolved +Desktop repository (`config.lastDesktopRepo`, 30s TTL — covers the common case of a subprocess +and its paired orchestrator request arriving ~200ms apart for the same user turn), then +finally to the static `Cowork` label. + +Once a working directory is found, the actual repository name and branch are read directly +from the local filesystem (`.git/config` remote origin URL, `.git/HEAD`) rather than shelling +out to `git` — cheaper and avoids spawning a process per request. + +--- + +## Client Attribution (Cowork vs. Code Tab) + +Every Desktop-mode request defaults to `X-CodeMie-Client: claude-desktop` (set once from +`config.clientType` at proxy startup). The per-request resolution additionally distinguishes +**which kind** of Desktop session it is, via `isCodeIntegration` (true = Code tab session, +resolved from the `claude-code-sessions/` root or a subprocess without `--add-dir`; false = +Cowork): + +- **Cowork** (`isCodeIntegration=false`): branch is injected (when resolvable) and + `X-CodeMie-Client` is explicitly (re)asserted as `claude-desktop` — necessary so that both + the orchestrator and subprocess requests for the same Cowork turn land in the same + `(repository, branch, claude-desktop)` bucket on the backend, instead of splitting into + separate rows. +- **Code tab** (`isCodeIntegration=true`): client is left as whatever `config.clientType` + already set — which for a Desktop-mode proxy is `claude-desktop`. **Verified against live + preview Elasticsearch data** during review: Code tab sessions (real `.git/HEAD` branch + values, `cli_request: true`) do carry `client_type: claude-desktop`, not `CLI`. An earlier + in-code comment claimed "client stays as CLI" for this case — that was stale/incorrect + documentation, not actual behavior; corrected during review (see [Review Findings](#review-findings)). + +Branch is only injected when a working directory was found via one of the three strategies +above, or from the `lastDesktopRepo` cache — never for the plain Desktop-default fallback +(`Cowork`, no resolution). `local-agent-mode-sessions` (Cowork chat mode) sessions have no git +hooks, so a tool-usage delta for that session type never carries `gitBranch` on the backend +side either — injecting a branch header there would create a spurious second bucket +`{repo, branch}` alongside the real `{repo, ""}` bucket. + +--- + +## Supporting Changes + +- **`claude-desktop.discovery.ts`**: `DesktopMetadata.userSelectedFolders` added as a + higher-priority working-directory source than `cwd` (Desktop sets this when the user + explicitly selects a project folder in Cowork, which may differ from the process's actual + `cwd`). `walk()` exported so `header-injection.plugin.ts` can reuse it for the session file + scan instead of duplicating directory-walking logic. +- **`paths.ts` / `extractRepository()`**: sandbox paths (Cowork with no resolvable project) + now return `'Cowork'` instead of `'Claude Desktop'` — the label needed to match what the + rest of the pipeline (and the backend's own `Cowork` fallback, see the `codemie` repo's + companion change) expects. +- **`metrics-aggregator.ts` / `metrics-sync-processor.ts`**: individual metric deltas parsed + from Desktop session files never carry a `gitBranch` field (only the session object does, + via `detectGitBranch`). Both now fall back to `session.gitBranch` when `delta.gitBranch` is + `undefined`, so Desktop deltas bucket under the session's real branch instead of always + landing in an empty-branch bucket. +- **`DesktopTelemetryRuntime.ts`**: `ensureSession()` now also resolves and stores + `config.codeMieProject` (via `ConfigLoader`) and writes into the same + `config.sessionRepositoryMap` the header-injection plugin reads/writes, so a session + discovered by polling (before any LLM request arrives) still gets a `Cowork`-vs-real-repo + entry. `stop()` now runs one final `poll()` before finalizing tracked sessions, to catch + transcripts written between the last regular poll tick and proxy shutdown (common for Code + tab sessions, where the transcript write is async relative to the LLM response). + +--- + +## Review Findings + +Three issues surfaced during self-review and were fixed before this change was considered +complete; a fourth was investigated live and turned out not to be a functional bug: + +### Fix A — Concurrent resolution race for a brand-new session + +The subprocess and orchestrator requests for the same Desktop user turn can arrive within +~200ms of each other (per the `lastDesktopRepo` TTL comment). Before the fix, both would +independently see `!config.sessionRepositoryMap.has(cliSessionId)` and each kick off its own +`lsof`/`ps` resolution chain — redundant work, and a narrow window where the two could +theoretically disagree if process state changed between the two lookups. + +**Fix:** extracted the resolution chain into `resolveDesktopSessionRepository()`, backed by an +in-flight-promise cache (`inFlightSessionResolutions: Map>`) keyed by +`cliSessionId`. A second concurrent request for the same new session awaits the same promise +instead of starting its own resolution; each request still independently applies the shared +result to its own `context.headers`. + +### Fix B — Telemetry poll could silently overwrite a more precise per-request resolution + +`DesktopTelemetryRuntime.ensureSession()` wrote into `config.sessionRepositoryMap` +unconditionally on first discovery of a session, with no check for whether the header-injection +plugin's per-request lookup (which runs first, in real time, and is generally more precise — +process-level CWD vs. this poll's session-file-derived `workingDirectory`) had already resolved +a value. The two writers had no coordination. + +**Fix:** added the same "don't overwrite if already set" guard `DesktopTelemetryRuntime.ts` +already existed on the header-injection side, so whichever writer resolves first wins and the +other becomes a no-op for that session. + +### Fix C — Stale/incorrect comment ("Code tab: client stays as CLI") + +See [Client Attribution](#client-attribution-cowork-vs-code-tab) above — verified against live +data that this claim was wrong; the comment has been corrected to describe actual behavior +(client stays `claude-desktop`, inherited from `config.clientType`) instead of asserting a +distinction the code never implemented. + +### Fix D — Numbered "Fix N" comment references removed + +The original implementation's comments referenced "Fix 3", "Fix 4", "Fix 4B" — numbers from a +personal working document that was never committed to this repository (moved to the ticket +owner's local notes outside any repo during cleanup). Left in place, these numbers would be +dangling references with no way for a future reader to resolve what they meant. All such +comments were rewritten to describe what the code does directly, with no external numbering +scheme to maintain. + +--- + +## Known Gaps + +- Repository/branch resolution here (`readGitRemoteLocal`/`readGitBranchLocal`, direct + `.git/config`/`.git/HEAD` reads) is a **separate mechanism** from the backend's own + branch-merge/display logic (`codemie` repo, `classification_engine.py`). Both independently + arrived at `Cowork` as the fallback label for "no resolvable context" — kept consistent by + convention, not by a shared constant across repos (there is none; each repo defines its own + `'Cowork'` string). +- macOS only (`process.platform !== 'darwin'` short-circuits every `lsof`/`ps`-based + resolution strategy to `null`). No fallback resolution path exists for other platforms — + Desktop-mode proxy on Windows/Linux would only ever see the `lastDesktopRepo` cache or the + static `Cowork` default. diff --git a/docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/technical-analysis.md b/docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/technical-analysis.md new file mode 100644 index 000000000..0cd1c2171 --- /dev/null +++ b/docs/superpowers/tasks/2026-07-22-epmcdme-12687-claude-desktop-repository-attribution/technical-analysis.md @@ -0,0 +1,197 @@ +# Technical Research + +**Task**: EPMCDME-12687 — Claude Desktop per-request repository/branch attribution +**Generated**: 2026-07-22 +**Research path**: filesystem (this repo) + live Elasticsearch query (preview cluster) + +local debug logs (`~/.codemie/logs/`) + cross-repo context (`codemie`, `codemie-ui`) + +--- + +## 1. Original Context + +Claude Desktop is a single long-running application; a user can have it pointed at different +projects across a day — a Cowork chat with no folder, Cowork with a folder open, or the Code +tab (Claude Code running inside Desktop, potentially in a different project than whatever +Cowork last had open). All of this traffic flows through one local proxy daemon +(`codemie proxy connect desktop`, port 4001) for the lifetime of that daemon. Before this +change, `X-CodeMie-Repository` was set once from `config.repository` — resolved at daemon +startup from wherever `codemie proxy connect desktop` happened to be run — so every request, +regardless of which Desktop tab/project it actually came from, got the same repository +attribution. + +The ticket owner's stated goal (confirmed directly): be able to switch between Cowork (no +folder) → Cowork with project A → Code tab (project B) → back to Cowork, and see each segment +attributed correctly and separately in CLI Insights analytics. + +--- + +## 2. Codebase Findings + +### Existing Implementations + +- `src/providers/plugins/sso/proxy/plugins/header-injection.plugin.ts` — the sole per-request + header-injection point for the local proxy. Runs at priority 20 (after auth). Owns the new + three-strategy resolution chain. +- `src/providers/plugins/sso/proxy/sso.proxy.ts` — `CodeMieProxy`, builds the `ProxyContext` + per incoming request; now also captures `req.socket?.remotePort`, the one piece of + request-level state the process-lookup strategy needs (the client's ephemeral TCP port, + used as the `lsof` search key since that port is bound locally by the connecting process, + not the proxy). +- `src/telemetry/runtime/DesktopTelemetryRuntime.ts` — a **second, independent** discovery + path: polls Desktop's session-file directories every `pollIntervalMs` (default 10s), + parses transcripts, and syncs conversation/metrics history directly (not through the proxy's + per-request header injection at all). Runs in parallel with the header-injection plugin for + the entire life of the daemon. +- `src/telemetry/clients/claude-desktop/claude-desktop.discovery.ts` — `discoverClaudeDesktopSessions()`, + used by `DesktopTelemetryRuntime`'s poll loop; `walk()` (now exported) recursively lists + session-file directories, reused by `header-injection.plugin.ts`'s session-file-scan + strategy to avoid a second directory-walking implementation. +- `src/utils/paths.ts` — `extractRepository()`, the shared repository-name-from-path resolver + used both by the proxy (for git-remote-derived paths) and, via the sandbox-path regex + branch, for Desktop sessions with no resolvable project (`'Cowork'` fallback). + +### Architecture and Layers Affected + +- **Proxy request-interception layer** (`header-injection.plugin.ts`): the new + per-`cliSessionId` resolution chain and its in-flight-promise dedup cache. +- **Telemetry polling layer** (`DesktopTelemetryRuntime.ts`): a parallel writer into the same + `config.sessionRepositoryMap` the proxy layer reads/writes — the two layers were not + originally coordinated (see Review Findings, Fix B). +- **Shared config surface** (`proxy-types.ts`, `telemetry/runtime/types.ts`): the + `sessionRepositoryMap`/`sessionCoworkMap`/`lastDesktopRepo` fields are the only channel + connecting these two otherwise-independent subsystems; both are constructed once in + `proxy-daemon.ts` and passed by reference into each. + +### Integration Points + +- `proxy-daemon.ts` → constructs `sessionRepositoryMap`/`sessionCoworkMap`, assigns to + `config`, passes `sessionRepositoryMap` into `DesktopTelemetryRuntimeConfig` — the one place + that wires the two subsystems together. +- `header-injection.plugin.ts` → reads `context.remotePort` (from `sso.proxy.ts`) and + `context.url` (for the `?beta=true` orchestrator check) — both request-level, not + session-level, state. +- `DesktopTelemetryRuntime.ensureSession()` → `ConfigLoader.load(workingDirectory)` — a + filesystem read of the *target* project's own `.codemie/codemie-cli.config.json`, distinct + from the Desktop proxy's own config, to resolve `codeMieProject` for that session. + +### Root-Cause Verification Against Live Data + +Two questions raised during review were resolved by querying the preview Elasticsearch +cluster directly (`kubectl port-forward -n preview-elastic svc/elasticsearch-master 9200:9200`, +`codemie_metrics_logs` index) rather than by static code reading alone: + +1. **Does "Code tab: client stays as CLI" (the original in-code comment) match reality?** + Queried for `client_type: claude-desktop` documents with a non-empty `branch` (the + signature of a Code tab session — only Code tab writes a real branch). Found 86 matching + documents, e.g. `epm-cdme/codemie-ui | EPMCDME-12687 | claude-desktop | cli_request=true`. + **Conclusion: false.** Code tab sessions are labeled `claude-desktop`, not `CLI`. The + comment was stale/aspirational documentation that never matched the actual code path (which + never overrides `X-CodeMie-Client` back to a CLI value for Code tab — it simply doesn't + touch the header, leaving `config.clientType`'s Desktop-mode default in place). Fixed as + part of this review (spec.md Fix C). + +2. **Are rows like `codemie-ai/codemie-code | | CLI` (from the ticket owner's own + Repositories-table screenshot) evidence of a Code tab mislabeling bug?** Queried the same + repository's documents directly: `user_agent: "claude-cli/2.1.216 (external, cli)"`, + `client_type: "codemie-claude"`, `cli_request: true` — a **real CLI session**, unrelated + to the Desktop proxy entirely (3,410 total documents for that repository; only 11 are + `claude-desktop`). **Conclusion:** those rows were not evidence of anything wrong in this + code — they were simply a different developer running the CLI directly on that repo. + +### Log Investigation (inconclusive — documented for future reference) + +The ticket owner asked whether the concurrent-resolution and dual-write patterns found during +review (spec.md Fix A/B) originated from the multi-project-switching use case described above. +Investigated local debug logs (`~/.codemie/logs/debug-2026-07-{17..22}.log`) for +`header-injection` resolution entries — found only plugin-registration log lines, no actual +per-request resolution traces, because Desktop mode was not actively exercised in the last ~5 +days these logs cover. The original development session (per `git log`, late June 2026 — +`fix(proxy): fix Cowork vs Code tab session attribution and branch fallback`, 2026-06-24) is +outside the local log retention window; those logs no longer exist. **Could not confirm from +logs**; resolved instead by reasoning about the code structure (each project switch gets a new +`cliSessionId`, so the per-session cache already supports the described use case — the review +findings are a narrower, session-*internal* timing concern, not a multi-project-switching bug). + +### Patterns and Conventions + +- **macOS-only system introspection**: every `lsof`/`ps`-based resolution strategy starts with + `if (process.platform !== 'darwin') return null;` — a deliberate, silent no-op on other + platforms rather than an error, consistent with this being a best-effort enrichment (falls + through to the `lastDesktopRepo` cache or `Cowork` default, never blocks the request). +- **Read-don't-shell for cheap git info**: `readGitRemoteLocal`/`readGitBranchLocal` parse + `.git/config`/`.git/HEAD` directly via `fs.readFile` rather than spawning `git` — avoids a + process spawn on the hot per-request path (the `lsof`/`ps` calls are already the expensive + part, gated behind `macOS` + only running once per new session thanks to the resolution + cache). +- **In-flight promise caching for request-triggered async work**: the pattern added in this + review (Fix A) — a module-level `Map>`, populated on first call, awaited by + concurrent callers, cleared in a `finally` block — is a standard Node.js idiom for + deduplicating expensive async work triggered by concurrent requests; not previously used + elsewhere in this plugin family, worth reusing if a similar race surfaces elsewhere. +- **"First writer wins" for shared caches with multiple independent producers**: once + `DesktopTelemetryRuntime.ts`'s poll loop was found to write into the same + `sessionRepositoryMap` the proxy layer owns, the simplest correct fix was making both writers + respect the same guard (`!map.has(key)`) rather than introducing a priority/locking scheme + between the two subsystems — appropriate here because the two resolutions are expected to + usually agree, and staleness/inconsistency risk is low (both resolve from the same + underlying session's working directory, just via different means). + +--- + +## 3. Documentation Findings + +### Guides and Architecture Docs + +No `.ai-run/`/`.codemie/guides/` entry documents the Desktop proxy's per-request resolution +chain or the two-subsystem (proxy vs. telemetry-poll) coordination. This file + spec.md are the +first record. + +### A Note on In-Code "Fix N" References + +The pre-review implementation's comments referenced a numbering scheme ("Fix 3", "Fix 4", +"Fix 4B") from a personal working spec (`claude-desktop-project-attribution/spec.md`) that was +**never committed to this repository** — it existed only in the ticket owner's local notes +(moved to `~/Downloads/spec.md`, outside any repo, during an earlier cleanup pass this session). +Confirmed via `grep -rn "Fix [0-9]" src/` (post-fix: zero remaining matches) that this repo now +has no dangling references to that external numbering. This spec/plan file's own prose section +headers ("Fix A", "Fix B", "Fix C", "Fix D" under Review Findings) are **local to this document +only** — they are not referenced anywhere in code comments and exist purely to organize this +write-up; if that becomes confusing in the future, prefer descriptive prose in code and reserve +lettered/numbered fix labels for review documents like this one. + +### Cross-Repo Findings + +- `codemie` repo, `classification_engine.py`: the backend-side counterpart to this repo's + `Cowork` fallback convention — both repos independently converged on `'Cowork'` as the label + for "no resolvable repository context," but there is no shared constant between them (each + defines its own string). Documented as a cross-repo convention, not a shared dependency. +- `codemie-ui` repo, `helpers.tsx` `CLIENT_CONFIG`: renders `client_type: claude-desktop` with + a dedicated icon/label — directly consumes the client attribution this repo's header + injection produces; no changes needed there for this task (already handled defensively for + both `CLI`-family and `claude-desktop` values). + +--- + +## 4. Testing Landscape + +### Existing Coverage + +- `src/utils/__tests__/paths.test.ts` — covers `extractRepository()`'s sandbox-path detection; + updated in lockstep with the `Cowork` label rename (5 assertions). +- No unit coverage exists for `header-injection.plugin.ts`'s resolution chain, the in-flight + dedup cache, or `DesktopTelemetryRuntime.ensureSession()`'s repository-map write. Verified + manually against a real Desktop session (`CODEMIE_DEBUG=true` + `codemie proxy connect + desktop`) and cross-checked against live Elasticsearch, consistent with how this plugin + family has been verified historically — no existing harness mocks `lsof`/`ps` output or a + live proxy session. + +### Gaps + +- No regression test locks in the in-flight-promise dedup (Fix A) — e.g. asserting that two + concurrent `resolveDesktopSessionRepository()` calls for the same `cliSessionId` only trigger + one `getPidForRemotePort` call. Would require mocking `child_process.exec`; out of scope for + this pass given no existing mock infrastructure for these system calls in this plugin family. +- No regression test locks in the "first writer wins" guard (Fix B) between + `header-injection.plugin.ts` and `DesktopTelemetryRuntime.ts` — would require constructing + both subsystems against a shared `ProxyConfig` and asserting write order doesn't matter for + the final map value. diff --git a/src/bin/proxy-daemon.ts b/src/bin/proxy-daemon.ts index 335951541..013805849 100644 --- a/src/bin/proxy-daemon.ts +++ b/src/bin/proxy-daemon.ts @@ -140,6 +140,10 @@ try { config.pinnedPort = true; if (config.telemetryMode === 'claude-desktop') { + const sessionRepositoryMap = new Map(); + config.sessionRepositoryMap = sessionRepositoryMap; + config.sessionCoworkMap = new Set(); + telemetryRuntime = new DesktopTelemetryRuntime( new ClaudeDesktopTelemetryAdapter(), { @@ -151,9 +155,12 @@ try { syncApiUrl: config.syncApiUrl, syncCodeMieUrl: config.syncCodeMieUrl, pollIntervalMs: config.telemetryPollIntervalMs ?? 10000, - inactivityTimeoutMs: config.telemetryInactivityTimeoutMs ?? 300000 + inactivityTimeoutMs: config.telemetryInactivityTimeoutMs ?? 300000, + sessionRepositoryMap, } ); + + config.triggerPoll = () => telemetryRuntime!.triggerPoll(); await telemetryRuntime.start(); } diff --git a/src/providers/plugins/sso/proxy/plugins/header-injection.plugin.ts b/src/providers/plugins/sso/proxy/plugins/header-injection.plugin.ts index 4ede7fede..ec4fd2d13 100644 --- a/src/providers/plugins/sso/proxy/plugins/header-injection.plugin.ts +++ b/src/providers/plugins/sso/proxy/plugins/header-injection.plugin.ts @@ -6,10 +6,23 @@ * KISS: Straightforward header injection */ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { exec as execCb } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execAsync = promisify(execCb); import { ProxyPlugin, PluginContext, ProxyInterceptor } from './types.js'; import { ProxyContext } from '../proxy-types.js'; import { ProviderRegistry } from '../../../../core/registry.js'; import { logger } from '../../../../../utils/logger.js'; +import { + getClaudeDesktopLocalSessionsRoot, + getClaudeDesktopCodeSessionsRoot, +} from '../../../../../telemetry/clients/claude-desktop/claude-desktop.paths.js'; +import { walk } from '../../../../../telemetry/clients/claude-desktop/claude-desktop.discovery.js'; +import { extractRepository } from '../../../../../utils/paths.js'; export class HeaderInjectionPlugin implements ProxyPlugin { id = '@codemie/proxy-headers'; @@ -68,10 +81,70 @@ class HeaderInjectionInterceptor implements ProxyInterceptor { context.headers['X-CodeMie-Client'] = config.clientType; } - // Add repository, branch and project headers - if (config.repository) { - context.headers['X-CodeMie-Repository'] = config.repository; + // Per-request repository resolution for Desktop mode. + // Claude Desktop sends x-claude-code-session-id = cliSessionId (plain UUID, no local_ prefix). + // The shared map is keyed by agentSessionId = cliSessionId, so look up directly. + // + // Lookup chain for unknown session (runs in order, stops on first success): + // 1. Process CWD lookup — uses TCP remotePort → lsof → PID → CWD; works on first message + // before session file exists on disk. macOS only (~50ms). + // 2. Session file scan — targeted scan of Desktop session files for matching cliSessionId; + // succeeds from message 2 onward when file is on disk. + // 3. Default — not cached; next request retries from step 1. + if (config.sessionRepositoryMap) { + const cliSessionId = context.headers['x-claude-code-session-id']; + + if (cliSessionId && !config.sessionRepositoryMap.has(cliSessionId)) { + const resolved = await resolveDesktopSessionRepository(cliSessionId, context); + if (resolved) { + // Always inject branch when workingDir is found directly for this session. + // For Code tab (isCodeIntegration=true): client stays as whatever config.clientType + // already set (claude-desktop for the whole Desktop proxy) — verified against live + // analytics data (Code tab sessions do carry client=claude-desktop, not CLI). + // For Cowork (isCodeIntegration=false): explicitly (re)assert X-CodeMie-Client as + // claude-desktop so both orchestrator and subprocess metrics share the same + // (repo, branch, claude-desktop) bucket → they merge into one row instead of + // CLI+Desktop duplicates. + config.sessionRepositoryMap.set(cliSessionId, resolved.repository); + config.lastDesktopRepo = { repo: resolved.repository, branch: resolved.branch, ts: Date.now() }; + if (resolved.branch) context.headers['X-CodeMie-Branch'] = resolved.branch; + if (!resolved.isCodeIntegration) { + config.sessionCoworkMap?.add(cliSessionId); + context.headers['X-CodeMie-Client'] = 'claude-desktop'; + } + } else { + // Last resort: reuse the most recently resolved Desktop repo. The 30 s TTL ensures + // we only carry over the repo from the same user turn (subprocess + orchestrator + // arrive within ~200 ms) and not from a stale prior session in a different folder. + const last = config.lastDesktopRepo; + if (last && Date.now() - last.ts < 30_000) { + config.sessionRepositoryMap.set(cliSessionId, last.repo); + if (last.branch) context.headers['X-CodeMie-Branch'] = last.branch; + logger.debug('[header-injection] Used cached last Desktop repo for orchestrator', { + cliSessionId, lastRepo: last.repo, lastBranch: last.branch, + }); + } + } + } + + const resolvedRepository = cliSessionId + ? (config.sessionRepositoryMap.get(cliSessionId) ?? config.repository ?? 'Cowork') + : (config.repository ?? 'Cowork'); + + context.headers['X-CodeMie-Repository'] = resolvedRepository; + + // For Cowork sessions (tracked in sessionCoworkMap), always override X-CodeMie-Client + // so subsequent requests (after cache hit) also attribute to Desktop, not CLI. + if (cliSessionId && config.sessionCoworkMap?.has(cliSessionId)) { + context.headers['X-CodeMie-Client'] = 'claude-desktop'; + } + } else { + // Non-Desktop mode: use static config values + if (config.repository) { + context.headers['X-CodeMie-Repository'] = config.repository; + } } + if (config.branch) { context.headers['X-CodeMie-Branch'] = config.branch; } @@ -79,6 +152,309 @@ class HeaderInjectionInterceptor implements ProxyInterceptor { context.headers['X-CodeMie-Project'] = config.project; } - logger.debug(`[${this.name}] Injected CodeMie headers`); + logger.info('[header-injection] Request headers', { + cliSessionId: context.headers['x-claude-code-session-id'] ?? null, + repository: context.headers['X-CodeMie-Repository'] ?? null, + branch: context.headers['X-CodeMie-Branch'] ?? null, + client: context.headers['X-CodeMie-Client'] ?? null, + remotePort: context.remotePort, + }); + } +} + +type DesktopSessionResolution = { + repository: string; + branch: string | null; + isCodeIntegration: boolean; +}; + +// Dedupe concurrent repository resolution for the same new session. Subprocess and +// orchestrator requests for the same user turn can arrive within ~200 ms of each other; +// without this, both would independently run the lsof/ps lookup chain below instead of +// sharing one in-flight resolution. +const inFlightSessionResolutions = new Map>(); + +async function resolveDesktopSessionRepository( + cliSessionId: string, + context: ProxyContext +): Promise { + const existing = inFlightSessionResolutions.get(cliSessionId); + if (existing) return existing; + + const resolution = resolveDesktopSessionRepositoryUncached(cliSessionId, context); + inFlightSessionResolutions.set(cliSessionId, resolution); + try { + return await resolution; + } finally { + inFlightSessionResolutions.delete(cliSessionId); + } +} + +async function resolveDesktopSessionRepositoryUncached( + cliSessionId: string, + context: ProxyContext +): Promise { + let workingDir: string | null = null; + + // Resolve the connecting PID once — shared by the process-lookup and process-tree-descent + // steps below to avoid running lsof twice for the same remotePort on the same request. + const connectingPid = context.remotePort + ? await getPidForRemotePort(context.remotePort).catch(() => null) + : null; + + // isCodeIntegration tracks whether the resolved session is a claude-code-sessions + // (Code tab) session. Branch is only injected for Code integration sessions because + // local-agent-mode-sessions (chat mode) have no git hooks → delta.gitBranch is always + // empty → injecting branch would create a second bucket {repo, branch} separate from + // the tool_usage bucket {repo, ""}, causing duplicate rows in the analytics table. + let isCodeIntegration = false; + + // Subprocess lookup — finds the claude process via its TCP connection. + // Works for subprocess requests (x-claude-code-session-id present, process has --add-dir). + // A subprocess with --add-dir may be a Code integration session (Code tab) OR a Cowork + // subprocess spawned by Desktop. Check session root to distinguish. + if (connectingPid) { + workingDir = await findWorkingDirViaProcess(connectingPid).catch(() => null); + if (workingDir) { + const sessionRes = await findWorkingDirForSession(cliSessionId).catch(() => null); + isCodeIntegration = sessionRes ? sessionRes.isCodeIntegration : true; + logger.debug('[header-injection] Resolved working dir via process lookup', { + cliSessionId, remotePort: context.remotePort, workingDir, isCodeIntegration, + }); + } else { + logger.debug('[header-injection] Process lookup returned no workingDir', { + cliSessionId, connectingPid, remotePort: context.remotePort, + }); + } + } else { + logger.debug('[header-injection] No connectingPid found', { + cliSessionId, remotePort: context.remotePort, + }); + } + + // Session file scan (works from message 2 onward when session file exists on disk) + if (!workingDir) { + const resolved = await findWorkingDirForSession(cliSessionId).catch(() => null); + if (resolved) { + workingDir = resolved.workingDir; + isCodeIntegration = resolved.isCodeIntegration; + logger.debug('[header-injection] Resolved working dir via session file scan', { + cliSessionId, workingDir, isCodeIntegration, + }); + } else { + logger.debug('[header-injection] Session file scan returned no workingDir', { + cliSessionId, + }); + } + } + + // Process tree descent — for Desktop orchestrator requests whose connecting + // process is the Desktop renderer (no --add-dir). Identified by ?beta=true in the URL. + // Desktop spawns the claude subprocess before sending its orchestrator call, so the + // subprocess is already in ps. Also covers second+ orchestrator via lastDesktopRepo. + // Always a Code integration session (subprocess has --add-dir). + if (!workingDir && connectingPid && context.url?.includes('beta=true')) { + workingDir = await findWorkingDirForDesktopDirectRequest(connectingPid).catch(() => null); + if (workingDir) { + isCodeIntegration = true; + logger.debug('[header-injection] Resolved working dir via process tree descent', { + cliSessionId, remotePort: context.remotePort, workingDir, + }); + } else { + logger.debug('[header-injection] Process tree descent returned no workingDir', { + cliSessionId, connectingPid, remotePort: context.remotePort, + }); + } + } + + if (!workingDir) return null; + + const repository = (await readGitRemoteLocal(workingDir)) ?? extractRepository(workingDir); + const branch = await readGitBranchLocal(workingDir); + logger.debug('[header-injection] Resolved repository via targeted lookup', { + cliSessionId, workingDir, repository, branch, isCodeIntegration, + }); + return { repository, branch, isCodeIntegration }; +} + +type SessionResolution = { + workingDir: string; + isCodeIntegration: boolean; +}; + +async function findWorkingDirForSession(cliSessionId: string): Promise { + const roots = [ + { path: getClaudeDesktopLocalSessionsRoot(), isCode: false }, + { path: getClaudeDesktopCodeSessionsRoot(), isCode: true }, + ]; + + for (const { path: root, isCode } of roots) { + if (!existsSync(root)) continue; + const files = await walk(root); + for (const file of files) { + try { + const json = JSON.parse(await readFile(file, 'utf-8')) as Record; + if (json['cliSessionId'] !== cliSessionId) continue; + const folders = json['userSelectedFolders'] as string[] | undefined; + const workingDir = + (json['originCwd'] as string | undefined) + ?? (json['worktreePath'] as string | undefined) + ?? folders?.[0] + ?? (json['cwd'] as string | undefined); + if (!workingDir) return null; + return { workingDir, isCodeIntegration: isCode }; + } catch { /* skip unreadable files */ } + } + } + + return null; +} + +async function readGitRemoteLocal(dir: string): Promise { + try { + const gitConfig = await readFile(join(dir, '.git', 'config'), 'utf-8'); + const match = gitConfig.match(/\[remote "origin"\][^[]*url\s*=\s*(.+)/); + if (!match) return null; + const repo = extractRepository(match[1].trim()); + return repo.endsWith('.git') ? repo.slice(0, -4) : repo; + } catch { + return null; + } +} + +// Reads .git/HEAD to get the current branch name. Not cached — branch can change +// within a session when user switches branches in the Desktop Code tab. +async function readGitBranchLocal(dir: string): Promise { + try { + const head = (await readFile(join(dir, '.git', 'HEAD'), 'utf-8')).trim(); + const match = head.match(/^ref: refs\/heads\/(.+)$/); + return match?.[1] ?? null; + } catch { + return null; + } +} + +// Returns the PID of the process that owns the TCP connection from remotePort. +// Shared by the process-lookup and process-tree-descent steps to avoid running lsof twice +// per request. macOS only. Returns null on any failure. +async function getPidForRemotePort(remotePort: number): Promise { + if (process.platform !== 'darwin') return null; + try { + const { stdout } = await execAsync( + `lsof -n -P -i 4TCP@127.0.0.1:${remotePort} 2>/dev/null`, + { timeout: 2000 } + ); + const ownPid = process.pid; + for (const line of stdout.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length < 2 || parts[1] === 'PID') continue; + const pid = parseInt(parts[1], 10); + if (!pid || pid === ownPid) continue; + if (line.includes(`127.0.0.1:${remotePort}->`)) return pid; + } + return null; + } catch { + return null; + } +} + +// Resolves the working directory of the subprocess that owns the given PID. +// Reads --add-dir from the process command line (Desktop passes --add-dir when +// spawning claude). macOS only. Returns null on any failure. +async function findWorkingDirViaProcess(pid: number): Promise { + if (process.platform !== 'darwin') return null; + try { + const { stdout } = await execAsync(`ps -p ${pid} -o args=`, { timeout: 2000 }); + // Lazy match stops at the first subsequent -- flag, supporting paths with spaces. + const match = stdout.match(/--add-dir\s+(.+?)(?=\s+--|$)/); + const dir = match?.[1]?.trim(); + // Reject relative paths — Desktop renderer uses --add-dir for plugin loading (not absolute). + if (dir?.startsWith('/')) return dir; + + // Fallback: use the OS-level process cwd via lsof. + // Code tab subprocesses are launched from the project directory but carry no --add-dir flag. + // Reject default launcher paths (home dir, Library, app bundles) to avoid misattributing + // Cowork/no-folder sessions that have a non-project cwd. + const { stdout: lsofOut } = await execAsync(`lsof -a -d cwd -p ${pid} -Fn`, { timeout: 2000 }); + const cwd = lsofOut.split('\n').find(l => l.startsWith('n'))?.slice(1).trim(); + if (!cwd?.startsWith('/')) return null; + const home = process.env.HOME ?? ''; + if (cwd === home || cwd.includes('/Library/') || cwd.includes('.app/Contents')) return null; + return cwd; + } catch { + return null; } } + +// Resolves the working directory for a Desktop orchestrator request. +// Takes the already-resolved connectingPid (Desktop renderer) to avoid a second lsof call. +// Walks up from the renderer to the Claude app root, then BFS-descends to find a subprocess +// with --add-dir. macOS only. Returns null on any failure. +async function findWorkingDirForDesktopDirectRequest(connectingPid: number): Promise { + if (process.platform !== 'darwin') return null; + + try { + const { stdout } = await execAsync('ps -axww -o pid,ppid,args', { timeout: 2000 }); + + // Build process map and children index in one pass + const processes = new Map(); + const children = new Map(); + for (const line of stdout.split('\n').slice(1)) { + const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.*)/); + if (!m) continue; + const pid = parseInt(m[1], 10); + const ppid = parseInt(m[2], 10); + if (isNaN(pid) || isNaN(ppid)) continue; + processes.set(pid, { ppid, args: m[3].trim() }); + if (!children.has(ppid)) children.set(ppid, []); + children.get(ppid)!.push(pid); + } + + // Walk up from Desktop renderer to find the Claude app root. + // Check Claude\.app BEFORE the ppid<=1 break so the main app process (PPID=1) is included. + let claudeRootPid = connectingPid; + let pid = connectingPid; + for (let depth = 0; depth < 10; depth++) { + const proc = processes.get(pid); + if (!proc) break; + if (/Claude\.app/.test(proc.args)) claudeRootPid = pid; + if (proc.ppid <= 1) break; + pid = proc.ppid; + } + + // BFS down from Claude root: --add-dir (Cowork+folder) or process cwd (Code tab). + const queue = [claudeRootPid]; + const visited = new Set(); + const home = process.env.HOME ?? ''; + while (queue.length > 0) { + const cur = queue.shift()!; + if (visited.has(cur)) continue; + visited.add(cur); + const proc = processes.get(cur); + if (proc?.args.includes('--add-dir')) { + const m = proc.args.match(/--add-dir\s+(.+?)(?=\s+--|$)/); + const dir = m?.[1]?.trim(); + if (dir?.startsWith('/')) return dir; + } else if (proc?.args.includes('--output-format stream-json')) { + try { + const { stdout: cwdOut } = await execAsync(`lsof -a -d cwd -p ${cur} -Fn`, { timeout: 1000 }); + const cwd = cwdOut.split('\n').find(l => l.startsWith('n'))?.slice(1).trim(); + logger.debug('[header-injection] Fix4B subprocess cwd candidate', { pid: cur, cwd: cwd ?? null }); + if (cwd?.startsWith('/') && cwd !== home && !cwd.includes('/Library/') && !cwd.includes('.app/Contents')) { + return cwd; + } + } catch (e) { + logger.debug('[header-injection] Fix4B subprocess lsof failed', { pid: cur, error: String(e) }); + } + } + for (const child of (children.get(cur) ?? [])) { + if (!visited.has(child)) queue.push(child); + } + } + + return null; + } catch { + return null; + } +} + diff --git a/src/providers/plugins/sso/proxy/proxy-types.ts b/src/providers/plugins/sso/proxy/proxy-types.ts index 686d73ca5..365e15811 100644 --- a/src/providers/plugins/sso/proxy/proxy-types.ts +++ b/src/providers/plugins/sso/proxy/proxy-types.ts @@ -34,6 +34,10 @@ export interface ProxyConfig { telemetryMode?: 'none' | 'claude-desktop'; telemetryPollIntervalMs?: number; telemetryInactivityTimeoutMs?: number; + sessionRepositoryMap?: Map; + sessionCoworkMap?: Set; + lastDesktopRepo?: { repo: string; branch: string | null; ts: number }; + triggerPoll?: () => Promise; /** * When true, the server retries the SAME configured port on EADDRINUSE * (with short backoff) instead of falling back to a random port. Used by @@ -58,6 +62,7 @@ export interface ProxyContext { requestBody: Buffer | null; // Changed to Buffer to preserve byte integrity requestStartTime: number; targetUrl?: string; + remotePort?: number; // TCP source port of the connecting client (used for process CWD lookup) metadata: Record; } diff --git a/src/providers/plugins/sso/proxy/sso.proxy.ts b/src/providers/plugins/sso/proxy/sso.proxy.ts index d78eb7c84..f31e58bbc 100644 --- a/src/providers/plugins/sso/proxy/sso.proxy.ts +++ b/src/providers/plugins/sso/proxy/sso.proxy.ts @@ -393,6 +393,7 @@ export class CodeMieProxy { headers: forwardHeaders, requestBody, requestStartTime: Date.now(), + remotePort: req.socket?.remotePort, metadata: {} }; } diff --git a/src/providers/plugins/sso/session/processors/metrics/metrics-aggregator.ts b/src/providers/plugins/sso/session/processors/metrics/metrics-aggregator.ts index 936d529cc..be93cca59 100644 --- a/src/providers/plugins/sso/session/processors/metrics/metrics-aggregator.ts +++ b/src/providers/plugins/sso/session/processors/metrics/metrics-aggregator.ts @@ -59,11 +59,19 @@ export function aggregateDeltas( ): SessionMetric[] { logger.debug(`[aggregator] Aggregating ${deltas.length} deltas for session ${session.sessionId}`); - // Group deltas by branch + // Group deltas by branch. + // Fall back to session.gitBranch when delta.gitBranch is absent — Desktop + // telemetry sessions set gitBranch on the session object (via detectGitBranch) + // but individual deltas parsed from Claude Desktop session files never carry + // a gitBranch field, so without the fallback all Desktop deltas would bucket + // under branch="" and create a spurious extra row in the analytics table. const deltasByBranch = new Map(); for (const delta of deltas) { - const branch = delta.gitBranch || ''; + const branch = delta.gitBranch || session.gitBranch || ''; + if (delta.gitBranch === undefined && session.gitBranch) { + logger.debug(`[aggregator] delta has no gitBranch — falling back to session.gitBranch="${session.gitBranch}"`); + } if (!deltasByBranch.has(branch)) { deltasByBranch.set(branch, []); @@ -72,6 +80,7 @@ export function aggregateDeltas( deltasByBranch.get(branch)!.push(delta); } + logger.info(`[aggregator] Branch buckets for session ${session.sessionId}: [${Array.from(deltasByBranch.keys()).map(b => `"${b || '(empty)'}"`) .join(', ')}] (session.gitBranch="${session.gitBranch ?? ''}")`); logger.debug(`[aggregator] Grouped deltas into ${deltasByBranch.size} branches: ${Array.from(deltasByBranch.keys()).join(', ')}`); // Create one metric per branch diff --git a/src/providers/plugins/sso/session/processors/metrics/metrics-sync-processor.ts b/src/providers/plugins/sso/session/processors/metrics/metrics-sync-processor.ts index 389ebd55c..020aa1d97 100644 --- a/src/providers/plugins/sso/session/processors/metrics/metrics-sync-processor.ts +++ b/src/providers/plugins/sso/session/processors/metrics/metrics-sync-processor.ts @@ -88,6 +88,7 @@ export class MetricsSyncProcessor implements SessionProcessor { return { recordId: d.recordId, + gitBranch: d.gitBranch || '(empty)', timestamp: typeof d.timestamp === 'number' ? new Date(d.timestamp).toISOString() : d.timestamp, @@ -183,7 +184,7 @@ export class MetricsSyncProcessor implements SessionProcessor { for (const metric of metrics) { const branchDeltas = pendingDeltas.filter((delta) => - (delta.gitBranch || '') === metric.attributes.branch + (delta.gitBranch || sessionMetadata.gitBranch || '') === metric.attributes.branch ); try { diff --git a/src/telemetry/clients/claude-desktop/claude-desktop.discovery.ts b/src/telemetry/clients/claude-desktop/claude-desktop.discovery.ts index ad58ae836..b8812af75 100644 --- a/src/telemetry/clients/claude-desktop/claude-desktop.discovery.ts +++ b/src/telemetry/clients/claude-desktop/claude-desktop.discovery.ts @@ -15,6 +15,7 @@ interface DesktopMetadata { cwd?: string; originCwd?: string; worktreePath?: string; + userSelectedFolders?: string[]; createdAt: number; lastActivityAt: number; model?: string; @@ -91,7 +92,7 @@ async function resolveClaudeTranscriptPath(metadata: DesktopMetadata): Promise { +export async function walk(root: string): Promise { const files: string[] = []; const entries = await readdir(root, { withFileTypes: true }); @@ -158,6 +159,7 @@ export async function discoverClaudeDesktopSessions( || companionMetadata?.worktreePath || metadata.originCwd || metadata.worktreePath + || metadata.userSelectedFolders?.[0] || metadata.cwd || transcriptDir, createdAt: metadata.createdAt, diff --git a/src/telemetry/runtime/DesktopTelemetryRuntime.ts b/src/telemetry/runtime/DesktopTelemetryRuntime.ts index fd1880a70..694d2b683 100644 --- a/src/telemetry/runtime/DesktopTelemetryRuntime.ts +++ b/src/telemetry/runtime/DesktopTelemetryRuntime.ts @@ -13,6 +13,7 @@ import type { import { setRuntimeCheckpoint } from '@/telemetry/runtime/checkpoints.js'; import { logger } from '@/utils/logger.js'; import { detectGitBranch, detectGitRemoteRepo } from '@/utils/processes.js'; +import { ConfigLoader } from '@/utils/config.js'; interface TrackedSession { codemieSessionId: string; @@ -48,11 +49,22 @@ export class DesktopTelemetryRuntime { this.timer = undefined; } + // Final poll to catch sessions whose transcript was written after the last regular poll + // but before proxy stop (e.g. Code tab sessions where transcript write is async). + await this.poll().catch((error) => { + logger.error('[desktop-telemetry] Final poll on stop failed:', error); + }); + for (const tracked of this.trackedSessions.values()) { await this.finalizeSession(tracked.codemieSessionId, 'desktop-daemon-stop'); } } + async triggerPoll(): Promise { + await this.poll(); + } + + private async poll(): Promise { if (this.isPolling) { return; @@ -63,11 +75,26 @@ export class DesktopTelemetryRuntime { try { const discoveredSessions = await this.adapter.discoverSessions(this.lastPollAt - this.config.pollIntervalMs); + logger.debug('[desktop-telemetry] Poll tick', { + discovered: discoveredSessions.length, + tracked: this.trackedSessions.size, + lastPollAt: new Date(this.lastPollAt).toISOString(), + }); for (const discovered of discoveredSessions) { if (discovered.createdAt < this.startedAt) { continue; } + const isNew = !this.trackedSessions.has(discovered.externalSessionId); + if (isNew) { + logger.info('[desktop-telemetry] New session detected', { + externalSessionId: discovered.externalSessionId, + agentSessionId: discovered.agentSessionId, + workingDirectory: discovered.workingDirectory, + transcriptPath: discovered.transcriptPath, + }); + } + const session = await this.ensureSession(discovered); this.trackedSessions.set(discovered.externalSessionId, { codemieSessionId: session.sessionId, @@ -83,6 +110,11 @@ export class DesktopTelemetryRuntime { continue; } + logger.info('[desktop-telemetry] Session inactive — finalizing', { + externalSessionId, + inactiveForMs, + codemieSessionId: tracked.codemieSessionId, + }); await this.finalizeSession(tracked.codemieSessionId, 'desktop-inactive-timeout'); this.trackedSessions.delete(externalSessionId); } @@ -108,10 +140,30 @@ export class DesktopTelemetryRuntime { return existing; } - const [gitBranch, repository] = await Promise.all([ + const [gitBranch, repository, project] = await Promise.all([ detectGitBranch(discovered.workingDirectory), - detectGitRemoteRepo(discovered.workingDirectory) + detectGitRemoteRepo(discovered.workingDirectory), + ConfigLoader.load(discovered.workingDirectory) + .then(c => c.codeMieProject ?? undefined) + .catch(() => undefined) ]); + logger.info('[desktop-telemetry] Session resolved', { + externalSessionId: discovered.externalSessionId, + workingDirectory: discovered.workingDirectory, + repository, + gitBranch, + project, + }); + + // Don't overwrite a value the header-injection plugin's per-request lookup (lsof/session-file + // scan) may have already resolved for this session — that path runs first and is more precise + // (process-level CWD vs. this poll's session-file-derived workingDirectory). + if (!discovered.agentSessionId.startsWith('local_') && !this.config.sessionRepositoryMap?.has(discovered.agentSessionId)) { + this.config.sessionRepositoryMap?.set( + discovered.agentSessionId, + repository || 'Cowork' + ); + } const session: Session = { sessionId: randomUUID(), @@ -121,6 +173,7 @@ export class DesktopTelemetryRuntime { workingDirectory: discovered.workingDirectory, gitBranch: gitBranch || undefined, repository: repository || undefined, + project: project || undefined, status: 'active', activeDurationMs: 0, correlation: { @@ -203,6 +256,13 @@ export class DesktopTelemetryRuntime { const context = await this.buildProcessingContext(session); await this.syncer.sync(sessionId, context); await this.sendSessionEndMetric(session); + logger.info('[desktop-telemetry] Session finalized', { + sessionId, + reason, + repository: session.repository, + gitBranch: session.gitBranch, + status: session.status, + }); } private async buildProcessingContext( @@ -256,14 +316,24 @@ export class DesktopTelemetryRuntime { timeout: 10000 }); + logger.info('[desktop-telemetry] Sending session START metric', { + agentSessionId: discovered.agentSessionId, + repository: session.repository, + gitBranch: session.gitBranch, + workingDirectory: session.workingDirectory, + apiBaseUrl: context.apiBaseUrl, + hasCookies: !!context.cookies, + }); + await sender.sendSessionStart( { sessionId: discovered.agentSessionId, agentName: this.config.clientType, provider: this.config.provider, + project: session.project, + repository: session.repository, startTime: session.startTime, workingDirectory: session.workingDirectory, - repository: session.repository, model: discovered.model }, session.workingDirectory, @@ -286,14 +356,23 @@ export class DesktopTelemetryRuntime { timeout: 10000 }); + logger.info('[desktop-telemetry] Sending session END metric', { + agentSessionId: session.correlation.agentSessionId || session.sessionId, + repository: session.repository, + gitBranch: session.gitBranch, + reason: session.reason, + apiBaseUrl: context.apiBaseUrl, + }); + await sender.sendSessionEnd( { sessionId: session.correlation.agentSessionId || session.sessionId, agentName: this.config.clientType, provider: this.config.provider, + project: session.project, + repository: session.repository, startTime: session.startTime, - workingDirectory: session.workingDirectory, - repository: session.repository + workingDirectory: session.workingDirectory }, session.workingDirectory, { status: 'completed', reason: session.reason || 'desktop-session-complete' }, diff --git a/src/telemetry/runtime/types.ts b/src/telemetry/runtime/types.ts index b7dd76e1c..350686a51 100644 --- a/src/telemetry/runtime/types.ts +++ b/src/telemetry/runtime/types.ts @@ -23,6 +23,7 @@ export interface DesktopTelemetryRuntimeConfig { syncCodeMieUrl?: string; pollIntervalMs: number; inactivityTimeoutMs: number; + sessionRepositoryMap?: Map; } export interface LocalTelemetryAdapter { diff --git a/src/utils/__tests__/paths.test.ts b/src/utils/__tests__/paths.test.ts index 063535e6d..99f80a6ca 100644 --- a/src/utils/__tests__/paths.test.ts +++ b/src/utils/__tests__/paths.test.ts @@ -274,30 +274,30 @@ describe('Path Utilities - Cross-Platform', () => { }); }); - describe('Claude Desktop sandbox paths — return Claude Desktop', () => { + describe('Claude Desktop sandbox paths — return Cowork', () => { it('should detect real Desktop sandbox path observed in logs', () => { const path = '/Users/mykola/Library/Application Support/Claude-3p/local-agent-mode-sessions/759182a5-1458-46d1-92e3-d6b6bc1262bd/00000000-0000-4000-8000-000000000001/local_2d5f3a0f-6a50-4778-ac55-9ffbca0446da/outputs'; - expect(extractRepository(path)).toBe('Claude Desktop'); + expect(extractRepository(path)).toBe('Cowork'); }); it('should detect sandbox path with different UUID', () => { const path = '/Users/alice/Library/Application Support/Claude-3p/local-agent-mode-sessions/abc/def/local_7fb4c6a0-a7b9-4121-960c-035d0e4830ff/outputs'; - expect(extractRepository(path)).toBe('Claude Desktop'); + expect(extractRepository(path)).toBe('Cowork'); }); it('should detect sandbox path ending at the local_ directory itself', () => { const path = '/some/path/local_74f67be8-ddc7-44c4-a911-1d6cd034ec9d'; - expect(extractRepository(path)).toBe('Claude Desktop'); + expect(extractRepository(path)).toBe('Cowork'); }); it('should detect sandbox path with a project subfolder inside outputs', () => { const path = '/Users/alice/Library/Application Support/Claude-3p/local-agent-mode-sessions/s/u/local_95002bb5-744b-4b94-b87b-711c0ebf7a47/outputs/my-project'; - expect(extractRepository(path)).toBe('Claude Desktop'); + expect(extractRepository(path)).toBe('Cowork'); }); it('should be case-insensitive for hex digits', () => { const path = '/some/path/local_2D5F3A0F-6A50-4778-AC55-9FFBCA0446DA/outputs'; - expect(extractRepository(path)).toBe('Claude Desktop'); + expect(extractRepository(path)).toBe('Cowork'); }); }); diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 682703667..247c5ba6d 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -384,7 +384,7 @@ const CLAUDE_DESKTOP_SANDBOX_RE = /\/local_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[ /** * Extract parent/repo format from a working directory path. - * Returns 'Claude Desktop' for sandbox paths used by Claude Desktop sessions. + * Returns 'Cowork' for sandbox paths used by Claude Desktop sessions (Cowork tab / new task without a folder). * * @example * extractRepository('/Users/john/projects/codemie-code') @@ -392,11 +392,11 @@ const CLAUDE_DESKTOP_SANDBOX_RE = /\/local_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[ * * @example * extractRepository('/Users/john/Library/Application Support/Claude-3p/local-agent-mode-sessions///local_2d5f3a0f-6a50-4778-ac55-9ffbca0446da/outputs') - * // Returns: 'Claude Desktop' + * // Returns: 'Cowork' */ export function extractRepository(workingDirectory: string): string { if (CLAUDE_DESKTOP_SANDBOX_RE.test(normalizePathSeparators(workingDirectory))) { - return 'Claude Desktop'; + return 'Cowork'; } const parts = splitPath(workingDirectory);