Skip to content

Commit 6ba2c3d

Browse files
committed
docs(planning): propose Cursor agent hooks as a workspace signal
Cursor's hooks channel spawns fresh per agent-lifecycle event with an accurate per-window workspace_roots payload, sidestepping the shared mcp-remote process problem entirely instead of extracting more signal from it. Proposes a best-effort hint tier below window_pins to close the residual manual set_workspace_root cost on the global bridge. Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 7d8436f commit 6ba2c3d

1 file changed

Lines changed: 215 additions & 0 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# Cursor Agent Hooks as a Workspace Signal
2+
3+
**Last Updated:** Aug 21, 2026
4+
**Status:** Proposed, not spiked. Written the same night [`window-scoped-workspace-pin.md`](./window-scoped-workspace-pin.md) shipped its decision-4b patch — that patch stops a wrong pin from becoming permanent on the shared global bridge, but it does not give the shared session a *right* answer. This doc is the first design that could.
5+
**Branch:** `root-resolution`
6+
**Depends on:** [`window-scoped-workspace-pin.md`](./window-scoped-workspace-pin.md) (reuses `SessionRootsRegistry`'s tiered resolution and the loopback-trust model `window_identity.rs` established) and [`cursor-workspace-routing-bridge.md`](./cursor-workspace-routing-bridge.md) (extends the global bridge's config-generator UI rather than replacing it)
7+
**Unblocks:** The ~16% residual both docs above call inherent — a session on the shared global bridge that today needs a manual `mcpmux_set_workspace_root` call every single time, because nothing about that process is unique to one window
8+
9+
---
10+
11+
## Problem
12+
13+
Decision 4b closed a real leak: a `set_workspace_root` pin on the shared `mcp-remote` process no longer promotes to `window_pins` without independent proof of single-window intent. That is correct and it is not enough — it converts a *wrong, durable* answer back into *no* answer. The session still has to call `mcpmux_set_workspace_root` on every reconnect, because every signal the gateway can see from that connection (`mcp-session-id`, the peer PID, `roots/list`) is shared by every window that has the global bridge open. `window-scoped-workspace-pin.md`'s own field evidence ("759 manual pins for six windows") is the size of this cost when the design was still leaking; decision 4b removes the leak but leaves the 759 calls.
14+
15+
Every fix considered so far has tried to extract more signal from the *transport* — the loopback socket, the child's env, `roots/list`, `WORKSPACE_FOLDER_PATHS`. All of them are downstream of the same fact: Cursor spawns one `mcp-remote` child for the global bridge entry regardless of window count, so nothing arriving over that one TCP connection can be window-scoped no matter how it's read.
16+
17+
Cursor has a second, unrelated channel that doesn't go through `mcp-remote` at all: **hooks**. `hooks.json` (project, user, team, or enterprise scoped) lets Cursor spawn a fresh process per agent lifecycle event — `sessionStart`, `beforeSubmitPrompt`, `preToolUse`, `postToolUse`, `afterFileEdit`, `afterAgentResponse`, and others — and every one of those events carries a `workspace_roots` array that Cursor itself fills in for the exact window that triggered it, plus a `CURSOR_PROJECT_DIR` env var on the spawned process. There is no shared-process problem here because there is no shared process: each hook firing is its own spawn, the same way each `mcp-remote` child would be if Cursor gave the global bridge one per window (it doesn't).
18+
19+
The npm package [`cursor-agent-wakatime`](https://github.com/ryanhiizy/cursor-agent-wakatime) is existing proof this channel works for exactly this kind of per-window attribution problem: it installs `afterAgentResponse` / `afterFileEdit` / `postToolUse` hooks to attribute AI coding time to the right project when a developer has several Cursor windows open, which is the same ambiguity this doc is trying to resolve for MCP routing instead of time tracking.
20+
21+
**What hooks don't give for free:** a hook fires in Cursor's own process, out-of-band from the MCP transport. Nothing today ties a hook invocation to the specific HTTP request that later arrives at the gateway through the shared `mcp-remote` child. Closing that gap is this doc's actual technical risk, and Phase 1 exists to measure it rather than assume it.
22+
23+
---
24+
25+
## Decisions
26+
27+
| # | Decision | Choice | Rationale |
28+
| - | -------- | ------ | --------- |
29+
| 1 | Hook event | `beforeSubmitPrompt` (fires once per user turn), not `preToolUse` (fires once per tool call) | A turn's tool calls all belong to the same window, so one hint per turn covers them all. `preToolUse` is more precise but far chattier — every `mcpmux_*` call would spawn a hook process. Start with the cheaper signal; the per-call event is the documented upgrade path if the turn-level heuristic (decision 3) proves too coarse. |
30+
| 2 | Delivery mechanism | The hook script does one `POST` to a new loopback-only gateway endpoint with `{workspace_roots}` from its own stdin payload | The gateway already trusts loopback for desktop-only surfaces (`restrict_management_to_loopback` in [`server/mod.rs`](../../crates/mcpmux-gateway/src/server/mod.rs)); a hook process spawned by the user's own Cursor is exactly that trust level. No new auth surface, no key to embed in a hooks file. |
31+
| 3 | What the gateway does with a hint | Hold it as a short-TTL (proposed 5s), FIFO, best-effort hint — **never** written to `pinned` or `window_pins` | The hook doesn't know the `mcp-session-id` its own turn's tool calls will arrive on, so there's nothing to key a durable write to. Treating it as durable would risk the exact class of bug decision 4b just closed. `ponytail:` ceiling — two turns started in two different windows within the same ~5s window can still grab each other's hint; the upgrade path is decision 4b below turning this into an exact match instead of a queue. |
32+
| 3b | How this differs from `window_pins` | Applied per-request from a queue, not per-window from a table | `window_pins` answers "which folder does this *process* always mean," which is false on the shared bridge. This tier answers "which folder did the *most recent nearby prompt* mean," which degrades gracefully (a miss falls through to `PendingRoots`, same as today) instead of failing durably wrong. |
33+
| 4 | Correlation precision (Phase 1 question) | Measure whether the hook's `tool_call_id` / `generation_id` shows up anywhere on the corresponding `tools/call` JSON-RPC request the gateway receives | If Cursor's outbound MCP request carries a matching id, decision 3's FIFO queue becomes exact matching and the race in 3's ceiling disappears entirely. If not, the FIFO/TTL design ships as the ceiling-carrying fallback it's written as. This is exactly the kind of load-bearing assumption `window-scoped-workspace-pin.md` Phase 1 proved before Phase 2 was built, applied here to a different unknown. |
34+
| 5 | Resolution tier placement | New tier sits **below** `window_pins`, **above** probed `roots/list` | A window pin is still stronger proof than a turn-level hint when one exists (e.g. a per-repo install, or a single-candidate promotion). The hint only matters for exactly the case nothing else resolves: the shared global bridge with an ambiguous candidate set. |
35+
| 6 | Scope | Cursor only; global (`~/.cursor/hooks.json`) bridge only, not per-repo installs | Same client-scoping precedent as decision 5 in `cursor-workspace-routing-bridge.md` — other clients already route correctly via `roots`. Per-repo installs (`workspace_install.rs`) are already fully deterministic via a static header; adding a hook there solves an already-solved case. |
36+
| 7 | Hook script shape | An inline one-liner `command`/`args` in `hooks.json` (e.g. `node -e "..."`), not a shipped/versioned script file or npm package | Fewest files, nothing to version-sync between the desktop app and a published package. The one-liner reads stdin, does one `fetch`, and passes stdin through as stdout (hooks expect a response). |
37+
| 8 | Installation surface | Extend the existing global-bridge config generator in [`RegisterApiKeyClientModal.tsx`](../../apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx) to also render a `~/.cursor/hooks.json` snippet alongside the `~/.cursor/mcp.json` one it already builds | One panel, one "copy" action per file, same flow the user already knows from setting up the bridge. Not a new feature surface. |
38+
39+
---
40+
41+
## Scope
42+
43+
**In:**
44+
45+
- A new loopback-only gateway endpoint that accepts `{workspace_roots: string[]}` and stores it as a short-TTL hint
46+
- A new resolution tier consulted by `SessionRootsRegistry::get()` / `get_pinned()`, slotted between `window_pins` and probed `roots/list`
47+
- A `buildCursorBridgeHooksJson()` helper alongside the existing `buildCursorBridgeMcpJson()`, and a second snippet block in the Cursor tab of `RegisterApiKeyClientModal.tsx`
48+
- Phase 1: a real measurement of whether hook payload ids correlate with anything visible on the resulting MCP request, before any of the above is built on an assumption
49+
50+
**Out:**
51+
52+
| Item | Reason / Deferral |
53+
| ---- | ------------------ |
54+
| `preToolUse` as the primary hook event | Deferred per decision 1 — only revisited if the turn-level hint proves too coarse in the field (races, or turns whose tool calls span more than ~5s of latency before the first `mcpmux_*` call) |
55+
| Project-scoped `.cursor/hooks.json` | Decision 6 — per-repo installs already have a fully deterministic path; a hook there would duplicate `workspace_install.rs`, not improve on it |
56+
| Any non-Cursor client | Hooks are a Cursor-specific capability. VS Code / Claude Code already route correctly via `roots` (same finding `cursor-workspace-routing-bridge.md` decision 5 relied on) |
57+
| Replacing or removing decision 4b's gate | This tier is additive and ranks below `window_pins` (decision 5) — the gate that stops a wrong pin from becoming durable stays exactly as-is |
58+
| Exact-match correlation via `tool_call_id` as the *initial* build | Decision 4 — this is Phase 1's open question, not a decision made in advance of measuring it |
59+
| Filing the hooks gap upstream with Cursor | Not applicable — hooks already carry everything needed; there's no upstream bug here, unlike the `${workspaceFolder}` substitution flake |
60+
61+
---
62+
63+
## Architecture
64+
65+
### Why this sidesteps the shared-process problem instead of gating it further
66+
67+
```text
68+
Existing signals, all downstream of the same TCP connection:
69+
mcp-remote (pid 62753, ONE process for every window with the global bridge)
70+
|
71+
+-- mcp-session-id <- shared, decision 4b already handles this
72+
+-- peer socket -> PID <- shared (it's the same PID for every window)
73+
+-- roots/list <- shared, stale, listChanged:false
74+
75+
Hooks, an entirely separate channel:
76+
Cursor's own process
77+
+-- beforeSubmitPrompt hook, spawned fresh, THIS window's workspace_roots
78+
|
79+
v (loopback POST, out of band from mcp-remote)
80+
Gateway hint queue (TTL, best-effort)
81+
```
82+
83+
The hook signal isn't a better way to read the same shared connection — it never touches that connection. That's what makes it different from every prior attempt in `window-scoped-workspace-pin.md`, all of which tried to extract more from the transport `mcp-remote` already shares.
84+
85+
### Resolution ladder (extends `window-scoped-workspace-pin.md`'s ladder)
86+
87+
```text
88+
1. pinned[session] explicit header or set_workspace_root, this session (unchanged, authoritative)
89+
2. window_pin[window_key] remembered explicit claim from the same bridge process (unchanged)
90+
3. hook_hint[] most recent unexpired beforeSubmitPrompt hint (NEW — best-effort)
91+
4. map[session] probed roots/list (unchanged)
92+
5. PendingRoots ambiguous or absent — today's behavior (unchanged)
93+
```
94+
95+
### New state
96+
97+
```rust
98+
/// A workspace claim from a Cursor hook, not tied to any session id at
99+
/// write time — the hook fires in Cursor's own process, out of band from
100+
/// the `mcp-remote` connection its turn's tool calls will arrive on.
101+
///
102+
/// ponytail: FIFO with a short TTL, not exact correlation. Two windows
103+
/// starting turns within the same ~5s window can grab each other's hint.
104+
/// The upgrade path is decision 4's `tool_call_id` correlation, if Phase 1
105+
/// finds it's available.
106+
struct HookHint {
107+
workspace_roots: Vec<String>,
108+
received_at: Instant,
109+
}
110+
111+
/// FIFO queue of recent hints, drained (not just peeked) on each consult so
112+
/// a stale hint from an earlier turn can't outlive its own TTL by sitting
113+
/// behind a fresher one.
114+
hook_hints: Mutex<VecDeque<HookHint>>,
115+
```
116+
117+
### New endpoint
118+
119+
```text
120+
POST /internal/cursor-hook/workspace-hint (loopback-gated, same trust model as /oauth/clients)
121+
Body: { "workspace_roots": ["/Users/joe/Desktop/Repos/Personal/mcp-mux"] }
122+
```
123+
124+
### `hooks.json` shape (global, user-scoped)
125+
126+
```jsonc
127+
// ~/.cursor/hooks.json
128+
{
129+
"hooks": {
130+
"beforeSubmitPrompt": [
131+
{
132+
"command": "node",
133+
"args": [
134+
"-e",
135+
"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const p=JSON.parse(d);fetch('http://127.0.0.1:45818/internal/cursor-hook/workspace-hint',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({workspace_roots:p.workspace_roots})}).catch(()=>{}).finally(()=>process.stdout.write(d));});"
136+
]
137+
}
138+
]
139+
}
140+
}
141+
```
142+
143+
(Illustrative — Phase 2 renders the exact snippet from the desktop app, matching the copy-paste pattern `buildCursorBridgeMcpJson` already uses for `~/.cursor/mcp.json`.)
144+
145+
---
146+
147+
## Files to create / modify
148+
149+
| Area | File | Action |
150+
| ---- | ---- | ------ |
151+
| Gateway | `crates/mcpmux-gateway/src/services/hook_hints.rs` | Create — `HookHint`, the TTL queue, `push()` / `take_fresh()` |
152+
| Gateway | [`crates/mcpmux-gateway/src/services/session_roots.rs`](../../crates/mcpmux-gateway/src/services/session_roots.rs) | Modify — `get()` / `get_pinned()` consult the new tier between `window_pins` and probed roots |
153+
| Gateway | [`crates/mcpmux-gateway/src/server/mod.rs`](../../crates/mcpmux-gateway/src/server/mod.rs) | Modify — new route, added to `is_management_path()` so it shares the existing loopback gate |
154+
| Gateway | `crates/mcpmux-gateway/src/mcp/handlers.rs` (or a new sibling) | Create/modify — handler for the new endpoint |
155+
| Desktop UI | [`apps/desktop/src/features/clients/cursor-bridge-config.helpers.ts`](../../apps/desktop/src/features/clients/cursor-bridge-config.helpers.ts) | Modify — add `buildCursorBridgeHooksJson()` alongside `buildCursorBridgeMcpJson()` |
156+
| Desktop UI | [`apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx`](../../apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx) | Modify — Cursor tab renders the hooks.json snippet alongside the existing mcp.json one |
157+
| Docs | [`docs/manual/cursor-workspace-bridge.md`](../manual/cursor-workspace-bridge.md) | Modify — document the hook, what it sends, and how to see a hint applied vs. missed in the logs |
158+
159+
---
160+
161+
## Phases
162+
163+
### Phase 1 — Correlation spike, no gateway changes (~half day)
164+
165+
Proves or kills decision 4 before anything is built on either branch of it.
166+
167+
- Hand-write the `hooks.json` one-liner from the Architecture section, pointed at a throwaway `nc`/logging endpoint instead of the real gateway
168+
- Trigger a real turn that calls an `mcpmux_*` tool; capture both the hook's payload and the raw MCP `tools/call` request the gateway receives for that same turn
169+
- Compare every id field on each side (`tool_call_id`, `generation_id`, `conversation_id`, the JSON-RPC `id`) looking for any overlap
170+
171+
**Outcome:** Either a correlating id exists (decision 4 resolves to "exact match," Phase 2 builds a keyed lookup instead of a FIFO queue) or it doesn't (decision 3's FIFO/TTL design ships as designed, with its ceiling documented and accepted). Either answer unblocks Phase 2 — this phase's job is to pick which design, not to stall on uncertainty.
172+
173+
---
174+
175+
### Phase 2 — Gateway endpoint and resolver tier (~1 day)
176+
177+
- `hook_hints.rs`: the queue (or keyed map, per Phase 1's answer), a bounded size/TTL, and `take_fresh()` semantics that drain rather than peek
178+
- New loopback-gated route; extend `is_management_path()`
179+
- Wire the new tier into `SessionRootsRegistry::get()` / `get_pinned()`, below `window_pins`
180+
- Log a hint's application distinctly from a window-pin inheritance, so field traces can tell which tier actually resolved a given session
181+
182+
**Outcome:** With the hand-written `hooks.json` from Phase 1 now pointed at the real gateway, a session on the shared bridge with an ambiguous candidate set resolves correctly without a `mcpmux_set_workspace_root` call, and the log names the hint as the source.
183+
184+
---
185+
186+
### Phase 3 — Desktop UI and docs (~half day)
187+
188+
- `buildCursorBridgeHooksJson()` + the second snippet block in `RegisterApiKeyClientModal.tsx`'s Cursor tab
189+
- `cursor-workspace-bridge.md`: what the hook does, the exact log lines for "hint applied" vs. "hint missed / expired," and the FIFO ceiling from decision 3 stated plainly so a future racing-windows report doesn't reopen the investigation
190+
- `window-scoped-workspace-pin.md`: note that this tier addresses the residual its own Scope/Out table left standing
191+
192+
**Outcome:** A user can copy both snippets from the same modal in one sitting, and the residual `set_workspace_root` call count on a steady-state multi-window setup drops toward zero without them having done anything beyond the one-time hooks.json install.
193+
194+
---
195+
196+
## Key files referenced
197+
198+
| File | Note |
199+
| ---- | ---- |
200+
| [`crates/mcpmux-gateway/src/services/session_roots.rs`](../../crates/mcpmux-gateway/src/services/session_roots.rs) | `get()` / `get_pinned()` are the two insertion points; `window_pins` / `PinSource` are the tier this one sits directly below |
201+
| [`crates/mcpmux-gateway/src/services/window_identity.rs`](../../crates/mcpmux-gateway/src/services/window_identity.rs) | Not modified, but the reason this doc exists — its own doc comment says the PID "is a window key, not a folder," and on the shared bridge it isn't even that |
202+
| [`crates/mcpmux-gateway/src/server/mod.rs`](../../crates/mcpmux-gateway/src/server/mod.rs) | `is_management_path()` / `restrict_management_to_loopback` — the exact trust model the new endpoint reuses rather than inventing auth |
203+
| [`apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx`](../../apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx) | Where the Cursor tab already renders `buildCursorBridgeMcpJson()`; the hooks snippet joins it here |
204+
| [`apps/desktop/src/features/clients/cursor-bridge-config.helpers.ts`](../../apps/desktop/src/features/clients/cursor-bridge-config.helpers.ts) | `buildCursorBridgeMcpJson()` is the direct model for the new `buildCursorBridgeHooksJson()` |
205+
| [`ryanhiizy/cursor-agent-wakatime`](https://github.com/ryanhiizy/cursor-agent-wakatime) | External prior art — hooks into `afterAgentResponse` / `afterFileEdit` / `postToolUse` to attribute AI activity per-window; proves the hook channel carries enough signal for this class of problem |
206+
| [Hooks — Cursor Docs](https://cursor.com/docs/hooks.md) | Payload schema: `workspace_roots`, `conversation_id`, `generation_id`, `tool_call_id`, `CURSOR_PROJECT_DIR` env var; project/user/team/enterprise scoping |
207+
208+
---
209+
210+
## Related documentation
211+
212+
- [`docs/planning/window-scoped-workspace-pin.md`](./window-scoped-workspace-pin.md) — the durable-pin design and decision 4b's leak fix; this doc's Scope/Out table is exactly the residual left standing after that patch
213+
- [`docs/planning/cursor-workspace-routing-bridge.md`](./cursor-workspace-routing-bridge.md) — the global bridge this extends; Decision 5's Cursor-only scoping precedent carries over unchanged
214+
- [`docs/planning/resilience-routing-leftovers.md`](./resilience-routing-leftovers.md) — item 1, the residual this doc's Unblocks line refers to
215+
- [`docs/manual/cursor-workspace-bridge.md`](../manual/cursor-workspace-bridge.md) — user-facing bridge setup; gets the hooks section in Phase 3

0 commit comments

Comments
 (0)