diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,32 @@
 
 ## [Unreleased]
 
+## [0.3.0.0] - 2026-09-15
+
+### Added
+
+- Support for GHC 9.14.
+
+### Changed
+
+- `tricorder source` no longer requires the daemon to evaluate source code.
+
+### Fixed
+
+- The daemon did not respect environment variables and configuration options
+  that specified minimum logging levels. It was hard-coded to `INFO` by mistake.
+- Tricorder did not expand wildcard entries in `cabal.project`'s `packages`
+  list. So an entry like `packages/*` resulted in no packages being found.
+  Tricorder now correctly mimics Cabal's behavior, and expands wildcards
+  properly.
+
+### Removed
+
+- `tricorder log` no longer has the `--follow` option. Use something like
+  `tricorder log --print-path | xargs tail -f` instead. There are better tools to
+  handle following a text file as it is being written to rather than including
+  an extremely simple facsimile of one in Tricorder.
+
 ## [0.2.2.3] - 2026-09-11
 
 ### Changed
diff --git a/src/Tricorder/CLI/App.hs b/src/Tricorder/CLI/App.hs
--- a/src/Tricorder/CLI/App.hs
+++ b/src/Tricorder/CLI/App.hs
@@ -1,5 +1,6 @@
 module Tricorder.CLI.App (run) where
 
+import Atelier.Effects.Cache (Cache)
 import Atelier.Effects.Clock (Clock)
 import Atelier.Effects.Conc (Conc)
 import Atelier.Effects.Console (Console)
@@ -7,12 +8,15 @@
 import Atelier.Effects.Exit (Exit)
 import Atelier.Effects.File (File)
 import Atelier.Effects.FileSystem (FileSystem)
+import Atelier.Effects.Input (Input)
+import Atelier.Effects.Log (Log)
 import Atelier.Effects.Posix.Daemons (Daemons)
 import Atelier.Effects.Process (Process)
 import Atelier.Effects.Timeout (Timeout)
 import Effectful (IOE)
 import Effectful.Concurrent (Concurrent)
 import Effectful.Reader.Static (Reader, ask, asks)
+import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery)
 import Prelude hiding (force)
 
 import Atelier.Effects.Console qualified as Console
@@ -38,8 +42,14 @@
 import Tricorder.CLI.UI.BrickChan (BrickChan)
 import Tricorder.Daemon.DaemonInfo (DaemonInfo (..))
 import Tricorder.Runtime (LogPath (..), PidFile (..), SocketPath (..))
+import Tricorder.Session.Command (Repl)
 import Tricorder.Socket.Client (isDaemonRunning, queryStatus)
 import Tricorder.Socket.UnixSocket (UnixSocket)
+import Tricorder.SourceLookup (ModuleSourceResult)
+import Tricorder.SourceLookup.GhcPkg (GhcPkg)
+import Tricorder.SourceLookup.Hackage (Hackage)
+import Tricorder.SourceLookup.PackageId (PackageId)
+import Tricorder.SourceLookup.PackageStore (PackageStore)
 
 import Tricorder.CLI.UI.Keys qualified as Keys
 
@@ -47,6 +57,8 @@
 run
     :: ( Brick :> es
        , BrickChan :> es
+       , Cache (PackageId, SourceQuery) ModuleSourceResult :> es
+       , Cache ModuleName PackageId :> es
        , Clock :> es
        , Conc :> es
        , Concurrent :> es
@@ -56,7 +68,12 @@
        , Exit :> es
        , File :> es
        , FileSystem :> es
+       , GhcPkg :> es
+       , Hackage :> es
        , IOE :> es
+       , Input Repl :> es
+       , Log :> es
+       , PackageStore :> es
        , Process :> es
        , Reader Command :> es
        , Reader Keys.Config :> es
@@ -120,7 +137,7 @@
                     else
                         asks @LogPath (.getLogPath)
             case logMode of
-                ShowLog followMode -> showLog logFile followMode
+                ShowLog -> showLog logFile
                 ShowLogPath -> Console.putTextLn (toText logFile)
         UI -> do
             running <- isDaemonRunning
@@ -129,10 +146,6 @@
                 void waitForDaemon
             viewUi
         Source moduleNames -> do
-            running <- isDaemonRunning
-            unless running $ do
-                startDaemon
-                void waitForDaemon
             showSource moduleNames
         Restart force ->
             restartDaemon force >>= \case
diff --git a/src/Tricorder/CLI/Arguments.hs b/src/Tricorder/CLI/Arguments.hs
--- a/src/Tricorder/CLI/Arguments.hs
+++ b/src/Tricorder/CLI/Arguments.hs
@@ -1,6 +1,5 @@
 module Tricorder.CLI.Arguments
     ( Command (..)
-    , FollowMode (..)
     , LogMode (..)
     , OutputFormat (..)
     , StatusOptions (..)
@@ -24,7 +23,6 @@
     , command
     , eitherReader
     , flag
-    , flag'
     , fullDesc
     , header
     , help
@@ -41,7 +39,6 @@
 import Tricorder.CLI.Command
     ( Command (..)
     , EvalCommentsOptions (..)
-    , FollowMode (..)
     , Force (..)
     , LogMode (..)
     , OutputFormat (..)
@@ -100,23 +97,15 @@
 
 logParser :: Parser Command
 logParser =
-    Log <$> (pathFlag <|> followFlag)
+    Log <$> pathFlag
   where
     pathFlag =
-        flag'
+        flag
+            ShowLog
             ShowLogPath
             ( long "print-path"
                 <> help "Print the path to the log file instead of its contents"
             )
-    followFlag =
-        ShowLog
-            <$> flag
-                NoFollow
-                Follow
-                ( long "follow"
-                    <> short 'f'
-                    <> help "Keep streaming new log lines as they are written"
-                )
 
 
 statusParser :: Parser Command
diff --git a/src/Tricorder/CLI/Main.hs b/src/Tricorder/CLI/Main.hs
--- a/src/Tricorder/CLI/Main.hs
+++ b/src/Tricorder/CLI/Main.hs
@@ -6,24 +6,42 @@
 import Atelier.Effects.Conc (runConc)
 import Atelier.Effects.Console (runConsole)
 import Atelier.Effects.Delay (runDelay)
+import Atelier.Effects.Env (runEnv)
 import Atelier.Effects.Exit (runExit)
 import Atelier.Effects.File (runFile)
 import Atelier.Effects.FileSystem (runFileSystemIO)
+import Atelier.Effects.Input (input, runInputEff)
+import Atelier.Effects.Log (runLogNoOp)
 import Atelier.Effects.Posix.Daemons (runDaemons)
 import Atelier.Effects.Process (runProcessIO)
 import Atelier.Effects.Timeout (runTimeout)
+import Data.Default (def)
 import Effectful (runEff)
 import Effectful.Concurrent (runConcurrent)
+import Effectful.Reader.Static (runReader)
+import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery)
 
+import Atelier.Effects.Cache qualified as Cache
+import Atelier.Effects.Cache qualified as CacheConfig
+import Atelier.Effects.FileSystem.Glob qualified as Glob
+
 import Tricorder.CLI.Arguments (runArguments)
 import Tricorder.CLI.UI.Brick (runBrick)
 import Tricorder.CLI.UI.BrickChan (runBrickChan)
-import Tricorder.Config (runLoadedConfig)
+import Tricorder.Config (inputLoadedConfig, runLoadedConfig)
 import Tricorder.Runtime (runLogPath, runPidFile, runProjectRoot, runRuntimeDir, runSocketPath)
+import Tricorder.Session (Session (..), loadSession)
+import Tricorder.Session.CabalFile (inputCabalFiles)
+import Tricorder.Session.Command (Command (..))
 import Tricorder.Socket.UnixSocket (runUnixSocketIO)
+import Tricorder.SourceLookup.PackageId (PackageId)
 
 import Tricorder.CLI.App qualified as App
 import Tricorder.CLI.UI.Keys qualified as Keys
+import Tricorder.SourceLookup qualified as SourceLookup
+import Tricorder.SourceLookup.GhcPkg qualified as GhcPkg
+import Tricorder.SourceLookup.Hackage qualified as Hackage
+import Tricorder.SourceLookup.PackageStore qualified as PackageStore
 
 
 main :: IO ()
@@ -40,6 +58,7 @@
         . runDelay
         . runFile
         . runFileSystemIO
+        . Glob.runIO
         . runProjectRoot
         . runRuntimeDir
         . runPidFile
@@ -52,4 +71,16 @@
         . runArgumentsIO
         . runArguments
         . runUnixSocketIO
+        . runEnv
+        . inputLoadedConfig
+        . runLogNoOp
+        . inputCabalFiles
+        . runInputEff loadSession
+        . runInputEff ((.command.repl) <$> input)
+        . runReader @CacheConfig.Config def
+        . Cache.runCacheTtl @ModuleName @PackageId
+        . Cache.runCacheTtl @(PackageId, SourceQuery) @SourceLookup.ModuleSourceResult
+        . GhcPkg.runGhcPkgIO
+        . PackageStore.run
+        . Hackage.run
         $ App.run
diff --git a/src/Tricorder/CLI/Operations.hs b/src/Tricorder/CLI/Operations.hs
--- a/src/Tricorder/CLI/Operations.hs
+++ b/src/Tricorder/CLI/Operations.hs
@@ -7,17 +7,19 @@
     )
 where
 
+import Atelier.Effects.Cache (Cache)
 import Atelier.Effects.Clock (Clock, currentTimeZone)
 import Atelier.Effects.Console (Console)
-import Atelier.Effects.Delay (Delay)
 import Atelier.Effects.Exit (Exit, exitFailure)
 import Atelier.Effects.File (File)
-import Atelier.Effects.FileSystem (FileSystem, doesFileExist, followFile, readFileLbs)
+import Atelier.Effects.FileSystem (FileSystem, doesFileExist, readFileLbs)
+import Atelier.Effects.Input (Input)
+import Atelier.Effects.Log (Log)
 import Data.Aeson (encode)
 import Data.Time.Format (defaultTimeLocale, formatTime)
 import Data.Time.LocalTime (utcToLocalTime)
 import Effectful.Reader.Static (Reader, ask)
-import Tricorder.SourceLookup.SourceQuery (SourceQuery)
+import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery)
 
 import Atelier.Effects.Console qualified as Console
 import Data.ByteString.Lazy qualified as BSL
@@ -29,7 +31,6 @@
 import Tricorder.Build.Test (Suites (..))
 import Tricorder.CLI.Arguments
     ( EvalCommentsOptions (..)
-    , FollowMode (..)
     , OutputFormat (..)
     , StatusOptions (..)
     , TestOptions (..)
@@ -42,9 +43,15 @@
     , renderSourceResults
     )
 import Tricorder.Runtime (SocketPath (..))
+import Tricorder.Session.Command (Repl)
 import Tricorder.Session.TestTarget (renderTestTarget)
-import Tricorder.Socket.Client (querySource, queryStatus, queryStatusWait)
+import Tricorder.Socket.Client (queryStatus, queryStatusWait)
 import Tricorder.Socket.UnixSocket (UnixSocket)
+import Tricorder.SourceLookup (ModuleSourceResult, lookupModuleSource)
+import Tricorder.SourceLookup.GhcPkg (GhcPkg)
+import Tricorder.SourceLookup.Hackage (Hackage)
+import Tricorder.SourceLookup.PackageId (PackageId)
+import Tricorder.SourceLookup.PackageStore (PackageStore)
 import Tricorder.TestOutput (stripGhciNoise)
 
 import Tricorder.Build qualified as Build
@@ -175,18 +182,14 @@
 
 showLog
     :: ( Console :> es
-       , Delay :> es
        , FileSystem :> es
        )
-    => FilePath -> FollowMode -> Eff es ()
-showLog path followMode = do
+    => FilePath -> Eff es ()
+showLog path = do
     exists <- doesFileExist path
     if not exists
-        then
-            Console.putTextLn $ "Log file does not exist yet: " <> toText path
-        else case followMode of
-            Follow -> followFile path Console.putStr
-            NoFollow -> readFileLbs path >>= Console.putStr . BSL.toStrict
+        then Console.putTextLn $ "Log file does not exist yet: " <> toText path
+        else readFileLbs path >>= Console.putStr . BSL.toStrict
 
 
 showTests
@@ -256,19 +259,21 @@
 
 
 showSource
-    :: ( Console :> es
-       , File :> es
-       , Reader SocketPath :> es
-       , UnixSocket :> es
+    :: ( Cache (PackageId, SourceQuery) ModuleSourceResult :> es
+       , Cache ModuleName PackageId :> es
+       , Console :> es
+       , FileSystem :> es
+       , GhcPkg :> es
+       , Hackage :> es
+       , Input Repl :> es
+       , Log :> es
+       , PackageStore :> es
        )
     => [SourceQuery]
     -> Eff es ()
 showSource queries = do
-    SocketPath sockPath <- ask
-    result <- querySource sockPath queries
-    case result of
-        Left err -> Console.putTextLn $ "Error: " <> err
-        Right results -> renderSourceResults results
+    results <- mapM lookupModuleSource queries
+    renderSourceResults results
 
 
 showEvalComments
diff --git a/src/Tricorder/Daemon/Core.hs b/src/Tricorder/Daemon/Core.hs
--- a/src/Tricorder/Daemon/Core.hs
+++ b/src/Tricorder/Daemon/Core.hs
@@ -1,13 +1,12 @@
 module Tricorder.Daemon.Core (main) where
 
-import Atelier.Config (LoadedConfig)
 import Atelier.Effects.Chan (Chan)
 import Atelier.Effects.Clock (Clock)
 import Atelier.Effects.Conc (Conc)
 import Atelier.Effects.Debounce (Debounce)
 import Atelier.Effects.FileSystem (FileSystem)
 import Atelier.Effects.FileWatcher (FileEvent, FileWatcher)
-import Atelier.Effects.Input (Input)
+import Atelier.Effects.Input (Input, input)
 import Atelier.Effects.Log (Log)
 import Atelier.Effects.Process (Process)
 import Atelier.Effects.Publishing (runPubSub)
@@ -62,8 +61,7 @@
 import Tricorder.Daemon.TestRunner (TestRunner)
 import Tricorder.Daemon.Watch (WatchedFile)
 import Tricorder.Runtime (ProjectRoot (..))
-import Tricorder.Session (Session (..), loadSession)
-import Tricorder.Session.CabalFile (CabalFile)
+import Tricorder.Session (Session (..))
 import Tricorder.Session.Command (Command (..), Repl)
 import Tricorder.Session.GenerateWithHpack (GenerateWithHpack (..))
 import Tricorder.Session.IdleTimeout (IdleTimeout)
@@ -112,8 +110,7 @@
        , FileWatcher :> es
        , GhciSession :> es
        , Hpack :> es
-       , Input LoadedConfig :> es
-       , Input [CabalFile] :> es
+       , Input Session :> es
        , Log :> es
        , Process :> es
        , Pub BuildPhase :> es
@@ -133,7 +130,7 @@
     . runPubSub @ReloadBuilder
     $ Conc.restartableFork waitForReloadSession do
         root <- Reader.ask
-        session <- loadSession
+        session <- input
         logSession session
 
         State.put session.command.repl
@@ -163,16 +160,9 @@
     waitForReloadSession = Waiters.wait $ Sub.listenOnce_ @ReloadSession
 
 
-shouldReloadSession
-    :: ( FileSystem :> es
-       , Input LoadedConfig :> es
-       , Input [CabalFile] :> es
-       , Log :> es
-       , Reader ProjectRoot :> es
-       )
-    => Session -> Eff es Bool
+shouldReloadSession :: (Input Session :> es) => Session -> Eff es Bool
 shouldReloadSession oldSession = do
-    newSession <- loadSession
+    newSession <- input
     pure $ newSession /= oldSession
 
 
diff --git a/src/Tricorder/Daemon/Main.hs b/src/Tricorder/Daemon/Main.hs
--- a/src/Tricorder/Daemon/Main.hs
+++ b/src/Tricorder/Daemon/Main.hs
@@ -23,6 +23,7 @@
 
 import Atelier.Effects.Cache.Config qualified as CacheConfig
 import Atelier.Effects.Conc qualified as Conc
+import Atelier.Effects.FileSystem.Glob qualified as Glob
 import Atelier.Effects.Input qualified as Input
 import Atelier.Effects.Log qualified as Log
 
@@ -66,16 +67,17 @@
         . runDebounce @FilePath
         . runFileWatcherIO
         . runFileSystemIO
+        . Glob.runIO
         . runProjectRoot
         . runExit
         . runFile
         . runRuntimeDir
         . runSocketPath
         . runLogPath
-        . runLogging
+        . runEnv
         . inputLoadedConfig
+        . runLogging
         . runChan
-        . runEnv
         . inputCabalFiles
         . inputSession
         . runReader @CacheConfig.Config def
diff --git a/src/Tricorder/Logging.hs b/src/Tricorder/Logging.hs
--- a/src/Tricorder/Logging.hs
+++ b/src/Tricorder/Logging.hs
@@ -1,18 +1,41 @@
 module Tricorder.Logging (runLogging) where
 
+import Atelier.Config (LoadedConfig, extractNestedConfig)
+import Atelier.Effects.Env (Env)
 import Atelier.Effects.File (BufferMode (..), File)
+import Atelier.Effects.Input (Input, input)
 import Atelier.Effects.Log (Log, Severity (..), runLogToHandle)
 import Effectful (IOE)
 import Effectful.Reader.Static (Reader, asks)
 
 import Atelier.Effects.File qualified as File
+import Atelier.Effects.Log qualified as Log
 
 import Tricorder.Runtime (LogPath (..))
 
 
-runLogging :: (File :> es, IOE :> es, Reader LogPath :> es) => Eff (Log : es) a -> Eff es a
+runLogging
+    :: ( Env :> es
+       , File :> es
+       , IOE :> es
+       , Input LoadedConfig :> es
+       , Reader LogPath :> es
+       )
+    => Eff (Log : es) a -> Eff es a
 runLogging act = do
     path <- asks @LogPath (.getLogPath)
     File.withFile path AppendMode \h -> do
         File.hSetBuffering h LineBuffering
-        runLogToHandle h INFO act
+        sev <- getMinimumSeverity
+        runLogToHandle h sev act
+
+
+getMinimumSeverity :: (Env :> es, Input LoadedConfig :> es) => Eff es Severity
+getMinimumSeverity = do
+    mSev <- Log.minimumSeverityFromEnv
+    case mSev of
+        Just sev -> pure sev
+        Nothing -> do
+            loadedConfig <- input
+            let config = extractNestedConfig @"session.logging" @Log.Config loadedConfig
+            pure config.minimumSeverity
diff --git a/src/Tricorder/Session/CabalFile.hs b/src/Tricorder/Session/CabalFile.hs
--- a/src/Tricorder/Session/CabalFile.hs
+++ b/src/Tricorder/Session/CabalFile.hs
@@ -7,6 +7,7 @@
 
 import Atelier.Effects.Env (Env)
 import Atelier.Effects.FileSystem (FileSystem, doesFileExist, listDirectory, readFileBs)
+import Atelier.Effects.FileSystem.Glob (Glob, globDir1)
 import Atelier.Effects.Input (Input, runInputEff)
 import Atelier.Effects.Log (Log)
 import Data.Traversable (for)
@@ -15,6 +16,7 @@
 import Distribution.Types.GenericPackageDescription (GenericPackageDescription)
 import Effectful.Reader.Static (Reader, ask)
 import System.FilePath (normalise, takeExtension, (</>))
+import System.FilePath.Glob (compile)
 
 import Atelier.Effects.Env qualified as Env
 import Atelier.Effects.Log qualified as Log
@@ -34,6 +36,7 @@
 inputCabalFiles
     :: ( Env :> es
        , FileSystem :> es
+       , Glob :> es
        , Log :> es
        , Reader ProjectRoot :> es
        )
@@ -53,13 +56,12 @@
     pure $ packageDescriptions
 
 
--- | Lists all `.cabal` files for packages listed in the project root's
--- `cabal.project` (or `cabal.project.local`) file. If no `cabal.project` file
--- is found, looks for a `.cabal` file in the project root, and uses that
--- instead.
+-- | Discovers `.cabal` files in all locations and formats Cabal itself
+-- supports.
 discoverCabalFiles
     :: ( Env :> es
        , FileSystem :> es
+       , Glob :> es
        , Reader ProjectRoot :> es
        )
     => Eff es [FilePath]
@@ -68,12 +70,12 @@
     homeCabalFiles <- maybe [] (one . (</> ".cabal/config")) <$> Env.lookupEnv "HOME"
     let projectFilePaths = projectCabalFiles projectRoot <> homeCabalFiles
     projectFiles <- filterM doesFileExist projectFilePaths
-    case nonEmpty projectFiles of
-        Nothing ->
+    if null projectFiles
+        then
             cabalFilesIn projectRoot
-        Just neProjectFiles -> do
+        else do
             packages <- fmap (find (not . null))
-                $ for (toList neProjectFiles) \projectFile -> do
+                $ for projectFiles \projectFile -> do
                     contents <- readFileBs projectFile
                     concat
                         <$> traverse
@@ -86,28 +88,37 @@
     projectCabalFiles projectRoot =
         (projectRoot </>) <$> ["cabal.project.local", "cabal.project.freeze", "cabal.project"]
 
-    -- A @packages:@ entry is either a direct path to a @.cabal@ file or a
-    -- directory to search for one.
     cabalFilesForEntry projectRoot entry
-        | takeExtension entry == ".cabal" = pure [projectRoot </> entry]
-        | otherwise = cabalFilesIn (normalise (projectRoot </> entry))
+        | hasWildcard entry = do
+            matches <- globDir1 (compile entry) projectRoot
+            concat <$> traverse resolveMatch matches
+        | isCabalFile entry = pure [projectRoot </> entry]
+        | otherwise = cabalFilesIn $ normalise $ projectRoot </> entry
+      where
+        resolveMatch path
+            | isCabalFile path = pure [path]
+            | otherwise = cabalFilesIn path
 
 
 -- | List the @.cabal@ files directly inside a directory.
 cabalFilesIn :: (FileSystem :> es) => FilePath -> Eff es [FilePath]
 cabalFilesIn dir = do
-    entries <- filter (\f -> takeExtension f == ".cabal") <$> listDirectory dir
-    pure $ map (dir </>) entries
+    entries <- filter isCabalFile <$> listDirectory dir
+    pure $ (dir </>) <$> entries
 
 
+-- | Does a @packages:@ entry contain a glob wildcard?
+hasWildcard :: FilePath -> Bool
+hasWildcard = elem '*'
+
+
 -- | Extract the directory/file entries from the @packages:@ field of a
--- @cabal.project@. Glob entries (containing @*@) are not expanded and are
--- skipped.
+-- @cabal.project@.
 projectPackageEntries :: ByteString -> [FilePath]
 projectPackageEntries contents =
     case readFields contents of
         Left _ -> []
-        Right fields -> filter (notElem '*') $ concatMap fromField fields
+        Right fields -> concatMap fromField fields
   where
     fromField = \case
         (Field (Name _ name) fieldLines)
@@ -119,3 +130,7 @@
 
     dropComma ',' = ' '
     dropComma c = c
+
+
+isCabalFile :: FilePath -> Bool
+isCabalFile = (== ".cabal") . takeExtension
diff --git a/src/Tricorder/Session/Target.hs b/src/Tricorder/Session/Target.hs
--- a/src/Tricorder/Session/Target.hs
+++ b/src/Tricorder/Session/Target.hs
@@ -135,11 +135,11 @@
 -- way the result is sorted with 'compareTargets' so libraries exposing a custom
 -- @Prelude@ come last [ref:lib_sort_order].
 resolveTargets :: [CabalFile] -> [Text] -> [Target]
-resolveTargets cabalFiles targets@(_ : _) =
-    sortBy (compareTargets (definesCustomPrelude cabalFiles)) $ parseTarget <$> targets
-resolveTargets cabalFiles [] =
-    sortBy (compareTargets (definesCustomPrelude cabalFiles))
-        $ foldMap (allComponentTargets . (.projectPackageDescription)) cabalFiles
+resolveTargets cabalFiles = \case
+    targets@(_ : _) -> sortTargets $ parseTarget <$> targets
+    [] -> sortTargets $ foldMap (allComponentTargets . (.projectPackageDescription)) cabalFiles
+  where
+    sortTargets = sortBy (compareTargets (definesCustomPrelude cabalFiles))
 
 
 -- | [tag:lib_sort_order] When running @cabal repl <package defining custom
diff --git a/src/Tricorder/Socket/Client.hs b/src/Tricorder/Socket/Client.hs
--- a/src/Tricorder/Socket/Client.hs
+++ b/src/Tricorder/Socket/Client.hs
@@ -3,7 +3,6 @@
     , queryStatusWait
     , queryWatch
     , Restarting (..)
-    , querySource
     , queryDiagnostic
     , requestShutdown
     , isDaemonRunning
@@ -21,7 +20,6 @@
 import Effectful.Reader.Static (Reader, ask)
 import Effectful.State.Static.Shared (evalState, get, modify, put)
 import System.IO.Error (isEOFError)
-import Tricorder.SourceLookup.SourceQuery (SourceQuery)
 import Prelude hiding (force)
 
 import Atelier.Effects.Delay qualified as Delay
@@ -40,7 +38,6 @@
     , Waiters (..)
     )
 import Tricorder.Socket.UnixSocket (UnixSocket, withConnection)
-import Tricorder.SourceLookup (ModuleSourceResult)
 
 import Tricorder.Version qualified as Version
 
@@ -110,20 +107,6 @@
                     Just state -> do
                         put retryLimit
                         inject (handler (Right state)) >> loop h
-
-
--- | Look up the source for one or more modules via the daemon.
-querySource
-    :: (File :> es, UnixSocket :> es)
-    => FilePath
-    -> [SourceQuery]
-    -> Eff es (Either Text [ModuleSourceResult])
-querySource sockPath queries = withConnection sockPath \h -> do
-    sendQuery h (Source queries)
-    line <- File.hGetLine h
-    case eitherDecode (BSL.fromStrict (encodeUtf8 (toText line))) of
-        Left err -> pure $ Left (toText err)
-        Right results -> pure $ Right results
 
 
 -- | Fetch the full body of a single diagnostic by 1-based index.
diff --git a/src/Tricorder/Socket/Protocol.hs b/src/Tricorder/Socket/Protocol.hs
--- a/src/Tricorder/Socket/Protocol.hs
+++ b/src/Tricorder/Socket/Protocol.hs
@@ -11,7 +11,6 @@
 
 import Data.Aeson (FromJSON, ToJSON)
 import Tricorder.CLI.Command (Force (..))
-import Tricorder.SourceLookup.SourceQuery (SourceQuery)
 
 
 data StatusQuery = StatusQuery {awaitDone :: Bool}
@@ -27,7 +26,6 @@
 data Query
     = Status StatusQuery
     | Watch
-    | Source [SourceQuery]
     | DiagnosticAt DiagnosticQuery
     | Quit Waiters
     deriving stock (Eq, Generic, Show)
diff --git a/src/Tricorder/Socket/Server.hs b/src/Tricorder/Socket/Server.hs
--- a/src/Tricorder/Socket/Server.hs
+++ b/src/Tricorder/Socket/Server.hs
@@ -1,9 +1,7 @@
 module Tricorder.Socket.Server (main, SocketRemoved (..)) where
 
-import Atelier.Effects.Cache (Cache)
 import Atelier.Effects.Conc (Conc)
 import Atelier.Effects.Exit (Exit, exitSuccess)
-import Atelier.Effects.FileSystem (FileSystem)
 import Atelier.Effects.Input (Input, input)
 import Atelier.Effects.Log (Log)
 import Atelier.Effects.Publishing.Sub (Sub)
@@ -12,7 +10,6 @@
 import Effectful.Reader.Static (Reader, ask)
 import Effectful.State.Static.Shared (State)
 import System.IO (Handle)
-import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery)
 
 import Atelier.Effects.Conc qualified as Conc
 import Atelier.Effects.Log qualified as Log
@@ -24,7 +21,6 @@
 import Tricorder.Daemon.DaemonInfo (DaemonInfo)
 import Tricorder.Daemon.IdleTimer (IdleTimer)
 import Tricorder.Runtime (SocketPath (..))
-import Tricorder.Session.Command (Repl)
 import Tricorder.Socket.Protocol
     ( ClientMessage (..)
     , DiagnosticQuery (..)
@@ -41,11 +37,6 @@
     , removeSocketFile
     , sendLine
     )
-import Tricorder.SourceLookup (ModuleSourceResult, lookupModuleSource)
-import Tricorder.SourceLookup.GhcPkg (GhcPkg)
-import Tricorder.SourceLookup.Hackage (Hackage)
-import Tricorder.SourceLookup.PackageId (PackageId)
-import Tricorder.SourceLookup.PackageStore (PackageStore)
 import Tricorder.Version (VersionMismatch (..), checkVersion)
 import Tricorder.Waiters (Waiters)
 
@@ -62,19 +53,12 @@
 
 
 main
-    :: ( Cache (PackageId, SourceQuery) ModuleSourceResult :> es
-       , Cache ModuleName PackageId :> es
-       , Conc :> es
+    :: ( Conc :> es
        , Exit :> es
-       , FileSystem :> es
-       , GhcPkg :> es
-       , Hackage :> es
        , IdleTimer :> es
        , Input BuildId :> es
        , Input DaemonInfo :> es
-       , Input Repl :> es
        , Log :> es
-       , PackageStore :> es
        , Reader SocketPath :> es
        , Sub BuildPhase :> es
        , UnixSocket :> es
@@ -90,19 +74,12 @@
 
 
 acceptTrigger
-    :: ( Cache (PackageId, SourceQuery) ModuleSourceResult :> es
-       , Cache ModuleName PackageId :> es
-       , Conc :> es
+    :: ( Conc :> es
        , Exit :> es
-       , FileSystem :> es
-       , GhcPkg :> es
-       , Hackage :> es
        , IdleTimer :> es
        , Input BuildId :> es
        , Input DaemonInfo :> es
-       , Input Repl :> es
        , Log :> es
-       , PackageStore :> es
        , Reader SocketPath :> es
        , State BuildPhase :> es
        , Sub BuildPhase :> es
@@ -120,19 +97,12 @@
 
 
 handleConnection
-    :: ( Cache (PackageId, SourceQuery) ModuleSourceResult :> es
-       , Cache ModuleName PackageId :> es
-       , Conc :> es
+    :: ( Conc :> es
        , Exit :> es
-       , FileSystem :> es
-       , GhcPkg :> es
-       , Hackage :> es
        , IdleTimer :> es
        , Input BuildId :> es
        , Input DaemonInfo :> es
-       , Input Repl :> es
        , Log :> es
-       , PackageStore :> es
        , State BuildPhase :> es
        , Sub BuildPhase :> es
        , UnixSocket :> es
@@ -154,18 +124,11 @@
 
 
 dispatch
-    :: ( Cache (PackageId, SourceQuery) ModuleSourceResult :> es
-       , Cache ModuleName PackageId :> es
-       , Conc :> es
+    :: ( Conc :> es
        , Exit :> es
-       , FileSystem :> es
-       , GhcPkg :> es
-       , Hackage :> es
        , Input BuildId :> es
        , Input DaemonInfo :> es
-       , Input Repl :> es
        , Log :> es
-       , PackageStore :> es
        , State BuildPhase :> es
        , Sub BuildPhase :> es
        , UnixSocket :> es
@@ -178,7 +141,6 @@
     Status (StatusQuery False) -> respondOnce h
     Status (StatusQuery True) -> respondWhenDone h
     Watch -> watchStream h
-    Source moduleNames -> respondSource moduleNames h
     DiagnosticAt dq -> respondDiagnostic dq.index h
     Quit waiters -> quit h waiters
 
@@ -272,26 +234,6 @@
             | otherwise -> sendJson h $ ErrorResponse "Build in progress"
         Build.Failed msg -> sendJson h $ ErrorResponse $ "Build command failed:\n" <> msg
         _ -> sendJson h $ ErrorResponse "Build in progress"
-
-
--- | Look up source for each requested module and send the results as a JSON array.
-respondSource
-    :: ( Cache (PackageId, SourceQuery) ModuleSourceResult :> es
-       , Cache ModuleName PackageId :> es
-       , FileSystem :> es
-       , GhcPkg :> es
-       , Hackage :> es
-       , Input Repl :> es
-       , Log :> es
-       , PackageStore :> es
-       , UnixSocket :> es
-       )
-    => [SourceQuery]
-    -> Handle
-    -> Eff es ()
-respondSource queries h = do
-    results <- mapM lookupModuleSource queries
-    sendJson h results
 
 
 sendJson :: (ToJSON a, UnixSocket :> es) => Handle -> a -> Eff es ()
diff --git a/test/Unit/Tricorder/Session/CabalFileSpec.hs b/test/Unit/Tricorder/Session/CabalFileSpec.hs
--- a/test/Unit/Tricorder/Session/CabalFileSpec.hs
+++ b/test/Unit/Tricorder/Session/CabalFileSpec.hs
@@ -7,6 +7,7 @@
 import Effectful.State.Static.Shared (evalState)
 import Test.Hspec (Spec, describe, it, shouldBe, shouldMatchList)
 
+import Atelier.Effects.FileSystem.Glob qualified as Glob
 import Data.Map.Strict qualified as Map
 
 import Tricorder.Runtime (ProjectRoot (..))
@@ -80,9 +81,24 @@
                 actual = runDiscovery fs [] discoverCabalFiles
             actual `shouldBe` ["/sub/foo.cabal"]
 
-        it "skips glob entries under packages: (not expanded)" do
+        it "expands a glob entry matching .cabal files directly" do
             let fs = Map.singleton "/cabal.project" "packages: */*.cabal\n"
-                actual = runDiscovery fs [] discoverCabalFiles
+                script = [Glob.NextGlobDir1 ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]]
+                actual = runDiscoveryGlob fs [] script discoverCabalFiles
+            actual `shouldMatchList` ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]
+
+        it "expands a glob entry matching package directories" do
+            let fs =
+                    Map.singleton "/cabal.project" "packages: */\n"
+                        `Map.union` multiPackageCabalFs
+                script = [Glob.NextGlobDir1 ["/pkg-a", "/pkg-b"]]
+                actual = runDiscoveryGlob fs [] script discoverCabalFiles
+            actual `shouldMatchList` ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]
+
+        it "returns no files when a glob entry matches nothing" do
+            let fs = Map.singleton "/cabal.project" "packages: */*.cabal\n"
+                script = [Glob.NextGlobDir1 []]
+                actual = runDiscoveryGlob fs [] script discoverCabalFiles
             actual `shouldBe` []
 
     describe "$HOME/.cabal/config fallback" do
@@ -114,9 +130,11 @@
                 actual `shouldMatchList` ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]
   where
     pr = ProjectRoot "/"
-    runDiscovery fs env =
+    runDiscovery fs env = runDiscoveryGlob fs env []
+    runDiscoveryGlob fs env script =
         runPureEff
             . runEnvConst env
             . evalState fs
             . runFileSystemState
+            . Glob.runScripted script
             . runReader pr
diff --git a/tricorder.cabal b/tricorder.cabal
--- a/tricorder.cabal
+++ b/tricorder.cabal
@@ -5,7 +5,7 @@
 -- see: https://github.com/sol/hpack
 
 name:            tricorder
-version:         0.2.2.3
+version:         0.3.0.0
 synopsis:        Continuous Haskell build status, diagnostics, and tests via a shared daemon
 description:     tricorder rebuilds your Haskell project continuously and surfaces build status, diagnostics, test results, and documentation - for developers and LLM coding agents. Like ghcid and ghciwatch it reloads on every change, but builds run in a background daemon so multiple clients (an interactive TUI, a status CLI, an agent skill) share a single build state without triggering redundant rebuilds. It discovers components across multi-package cabal.project workspaces automatically and ships context-friendly output for agentic use via the CLI.
 category:        Development
@@ -16,6 +16,12 @@
 license:         MIT
 license-file:    LICENSE
 build-type:      Simple
+tested-with:
+    GHC == 9.10.3
+  , GHC == 9.6.7
+  , GHC == 9.8.4
+  , GHC == 9.12.4
+  , GHC == 9.14.1
 extra-doc-files:
     README.md
     CHANGELOG.md
@@ -118,6 +124,7 @@
   build-depends:
       Cabal >=3.12 && <3.19
     , Cabal-syntax >=3.12 && <3.19
+    , Glob ==0.10.*
     , aeson >=2.2 && <2.4
     , atelier-core ==0.6.*
     , atelier-prelude ==0.3.*
