diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,73 @@
 
 ## [Unreleased]
 
+## [0.6.0.0] - 2026-10-20
+
+### Added
+
+- `tricorder test suites [TEST-TARGET...]` shows test suites, their test cases
+  and their output. `--fields` picks what to show for each suite, e.g.
+  `--fields 'name,cases(description,failure)'`; by default it shows a summary
+  of each suite. `--failed-only` keeps only failed suites and their failed test
+  cases. Text output is YAML, and `--json` gives JSON. This will replace
+  `tricorder test-results`. Non-breaking.
+- `tricorder source MODULE#SYMBOL` now follows re-exports: a symbol that
+  `MODULE` re-exports rather than defines (e.g. `Data.Text#pack`, defined in
+  `Data.Text.Internal`) is traced to its defining module, and its source is
+  printed under a `-- Re-exported from <module>` line.
+- `tricorder source` now takes optional `--max-depth N` (re-export hops to
+  follow, default 5; `0` disables following) and `--max-modules N` (modules to
+  read while following, default 24) to bound the re-export search.
+- `tricorder source` now finds modules defined in the project itself, searching
+  the `hs-source-dirs` of every component of every project package before
+  falling back to installed packages.
+
+### Changed
+
+- Navigation in `tricorder ui` is now done with the left and right arrow keys
+  (or `h` and `l`), where you switch to the left or right tab accordingly. The
+  previous hotkey-based solution where you had to know which key related to
+  which tab felt antiquated, and less intuitive compared to just moving between
+  them left and right. You will get errors when starting Tricorder with old,
+  unsupported key bindings in your `keybindings` config. These take the following form:
+
+  ```
+  Error(s) encountered when attempting to parse key bindings:
+  Unrecognized key event: foo_bar
+  ```
+
+- Default key binding for the "help" view is now `?`. This key binding is
+  supported as expected on keyboards where `?` is inputted with a modifier key.
+- Configuration parsing issues are now properly surfaced to the user, and shown
+  in `tricorder ui`, as well as being outputted to `stderr` for other commands.
+
+### Fixed
+
+- When the build command exits before GHCi starts, its output is now shown as
+  plain text in `tricorder ui`, `tricorder status` and the JSON output.
+  Previously it was rendered with `show`, so it appeared as
+  `StartupFailed "..."` with line breaks printed as a literal `\n`.
+  ([#542](https://github.com/tweag/tricorder/issues/542)) (Thanks @RyosukeDTomita)
+- Pre-built binaries on GitHub were built against paths in `/nix/store`, and
+  were not portable to systems that did not have Nix installed. The binaries
+  are now correctly linked to be used on non-Nix machines, and NixOS machines
+  with `nix-ld` enabled.
+- Tricorder would sometimes not react to `.cabal` file changes, especially when
+  there were existing build failures. This should now be fixed by reverting
+  parts of an earlier change where the daemon's build state logic.
+- Tricorder would sometimes keep old diagnostic messages from previous build
+  failures around when files were moved. This would cause said diagnostic
+  failures to persist until the daemon was manually restarted either through
+  `tricorder restart`, or using `Shift+R` in `tricorder ui`. This was caused by
+  handling the builder state in a layer above the builder restart logic,
+  causing the builder state to persist between builder restarts (which happen
+  when the `.cabal` file changes, like when adding or removing modules). This
+  should now be fixed.
+
+### Deprecated
+
+- `tricorder test-results`: to be replaced by `tricorder test suites`.
+
 ## [0.5.0.0] - 2026-10-02
 
 ### Added
diff --git a/src/Tricorder/Build/EvalComment.hs b/src/Tricorder/Build/EvalComment.hs
--- a/src/Tricorder/Build/EvalComment.hs
+++ b/src/Tricorder/Build/EvalComment.hs
@@ -15,7 +15,7 @@
     )
 where
 
-import Atelier.Types.QuietSnake (QuietSnake (..))
+import Atelier.Types.JSON.QuietSnake (QuietSnake (..))
 import Data.Aeson (FromJSON (..), ToJSON (..), Value (..), withObject, (.:))
 import GHC.Generics (Generically (..))
 import Text.Megaparsec
diff --git a/src/Tricorder/Build/Test.hs b/src/Tricorder/Build/Test.hs
--- a/src/Tricorder/Build/Test.hs
+++ b/src/Tricorder/Build/Test.hs
@@ -15,8 +15,8 @@
     )
 where
 
-import Data.Aeson (FromJSON, ToJSON)
-import GHC.Generics (Generically (..))
+import Atelier.Types.JSON.QuietSnake (QuietSnake (..))
+import Data.Aeson (FromJSON (..), ToJSON (..))
 
 import Data.Map.Strict qualified as Map
 
@@ -26,8 +26,7 @@
 
 newtype Suites = Suites {getSuites :: Map TestTarget Suite}
     deriving stock (Eq, Generic, Show)
-    deriving (FromJSON, ToJSON) via Generically Suites
-    deriving (Monoid, Semigroup) via Map TestTarget Suite
+    deriving (FromJSON, Monoid, Semigroup, ToJSON) via Map TestTarget Suite
 
 
 hasFailedTests :: Suites -> Bool
@@ -58,7 +57,7 @@
     | SuiteErrored SuiteError
     | SuiteCompleted SuiteCompletion
     deriving stock (Eq, Generic, Show)
-    deriving (FromJSON, ToJSON) via (Generically Suite)
+    deriving (FromJSON, ToJSON) via QuietSnake Suite
 
 
 isFailedRun :: Suite -> Bool
@@ -72,14 +71,14 @@
     , total :: Int
     }
     deriving stock (Eq, Generic, Show)
-    deriving (FromJSON, ToJSON) via Generically Progress
+    deriving (FromJSON, ToJSON) via QuietSnake Progress
 
 
 data Outcome
     = Passed
     | Failed Text
     deriving stock (Eq, Generic, Show)
-    deriving (FromJSON, ToJSON) via (Generically Outcome)
+    deriving (FromJSON, ToJSON) via QuietSnake Outcome
 
 
 data Case = Case
@@ -87,7 +86,7 @@
     , outcome :: Outcome
     }
     deriving stock (Eq, Generic, Show)
-    deriving (FromJSON, ToJSON) via (Generically Case)
+    deriving (FromJSON, ToJSON) via QuietSnake Case
 
 
 caseFailed :: Case -> Bool
@@ -99,7 +98,7 @@
     { message :: Text
     }
     deriving stock (Eq, Generic, Show)
-    deriving (FromJSON, ToJSON) via (Generically SuiteError)
+    deriving (FromJSON, ToJSON) via QuietSnake SuiteError
 
 
 data SuiteCompletion = SuiteCompletion
@@ -109,4 +108,4 @@
     , duration :: Maybe Duration
     }
     deriving stock (Eq, Generic, Show)
-    deriving (FromJSON, ToJSON) via (Generically SuiteCompletion)
+    deriving (FromJSON, ToJSON) via QuietSnake SuiteCompletion
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,6 +1,5 @@
 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)
@@ -16,14 +15,13 @@
 import Effectful (IOE)
 import Effectful.Concurrent (Concurrent)
 import Effectful.Reader.Static (Reader, ask, asks)
-import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery)
+import Tricorder.CLI.Command (Command (..), LogMode (..))
 import Prelude hiding (force)
 
 import Atelier.Effects.Console qualified as Console
 import Data.Text qualified as T
 import Tricorder.CLI.Command.Daemon qualified as DaemonCommand
 
-import Tricorder.CLI.Arguments (Command (..), LogMode (..))
 import Tricorder.CLI.Daemon
     ( restartDaemon
     , startDaemon
@@ -43,23 +41,21 @@
 import Tricorder.CLI.UI.BrickChan (BrickChan)
 import Tricorder.Daemon.DaemonInfo (DaemonInfo)
 import Tricorder.Runtime (LogPath (..), PidFile (..), SocketPath (..))
+import Tricorder.Session.CabalFile (CabalFile)
 import Tricorder.Session.Repl (Repl)
 import Tricorder.Socket.Client (isDaemonRunning)
 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.App.Test qualified as Test
 import Tricorder.CLI.UI.Keys qualified as Keys
 
 
 run
     :: ( Brick :> es
        , BrickChan :> es
-       , Cache (PackageId, SourceQuery) ModuleSourceResult :> es
-       , Cache ModuleName PackageId :> es
        , Clock :> es
        , Conc :> es
        , Concurrent :> es
@@ -74,6 +70,7 @@
        , IOE :> es
        , Input DaemonInfo :> es
        , Input Repl :> es
+       , Input [CabalFile] :> es
        , Log :> es
        , PackageStore :> es
        , Process :> es
@@ -118,13 +115,14 @@
                     Console.putStrLn "Stopped."
                 else
                     showStatus opts
-        Test opts -> do
+        TestResults opts -> do
             running <- isDaemonRunning
             if not running
                 then
                     Console.putStrLn "Stopped."
                 else
                     showTests opts
+        Test testCmd -> Test.run testCmd
         Log logMode -> do
             logFile <- asks @LogPath (.getLogPath)
             case logMode of
@@ -136,8 +134,8 @@
                 startDaemon
                 void waitForDaemon
             viewUi
-        Source moduleNames -> do
-            showSource moduleNames
+        Source opts -> do
+            showSource opts
         Restart force ->
             restartDaemon force >>= \case
                 Just (Left reasons) -> traverse_ Console.putTextLn reasons
diff --git a/src/Tricorder/CLI/App/CLIResponse.hs b/src/Tricorder/CLI/App/CLIResponse.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/CLI/App/CLIResponse.hs
@@ -0,0 +1,16 @@
+{-# LANGUAGE UndecidableInstances #-}
+
+module Tricorder.CLI.App.CLIResponse (CLIResponse (..)) where
+
+import Data.Aeson (ToJSON)
+import Data.Aeson.Text (encodeToLazyText)
+
+
+-- | This is effectively `Text.Show`, but for printing to the terminal.
+class CLIResponse a where
+    -- | Render the value in such a way that it displays nicely on a terminal.
+    renderCLIResponse :: a -> LText
+
+
+instance (ToJSON a) => CLIResponse a where
+    renderCLIResponse = encodeToLazyText
diff --git a/src/Tricorder/CLI/App/Output.hs b/src/Tricorder/CLI/App/Output.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/CLI/App/Output.hs
@@ -0,0 +1,57 @@
+module Tricorder.CLI.App.Output
+    ( Output (..)
+    , TextOutput (..)
+    , JsonOutput (..)
+    )
+where
+
+import Data.Aeson (ToJSON)
+import Data.Aeson.Text (encodeToLazyText)
+import System.Exit (ExitCode)
+
+import Tricorder.CLI.App.CLIResponse (CLIResponse (..))
+
+
+data Output a
+    = Fail ExitCode LText
+    | Output a
+
+
+instance Functor Output where
+    fmap f = \case
+        Fail exitCode reason -> Fail exitCode reason
+        Output val -> Output $ f val
+
+
+instance Applicative Output where
+    pure = Output
+    Fail exitCode reason <*> _ = Fail exitCode reason
+    _ <*> Fail exitCode reason = Fail exitCode reason
+    Output f <*> Output a = Output $ f a
+
+
+instance Monad Output where
+    Fail exitCode reason >>= _ = Fail exitCode reason
+    Output a >>= f = f a
+
+
+instance (CLIResponse a) => CLIResponse (Output a) where
+    renderCLIResponse = \case
+        Fail _ reason -> "Error: " <> reason
+        Output val -> renderCLIResponse val
+
+
+-- | Textual output to the terminal.
+data TextOutput a = TextOutput a
+
+
+instance (CLIResponse a) => CLIResponse (TextOutput a) where
+    renderCLIResponse (TextOutput val) = renderCLIResponse val
+
+
+-- | JSON output to the terminal.
+data JsonOutput a = JsonOutput a
+
+
+instance (ToJSON a) => CLIResponse (JsonOutput a) where
+    renderCLIResponse (JsonOutput val) = encodeToLazyText val
diff --git a/src/Tricorder/CLI/App/Test.hs b/src/Tricorder/CLI/App/Test.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/CLI/App/Test.hs
@@ -0,0 +1,145 @@
+module Tricorder.CLI.App.Test (run) where
+
+import Atelier.Effects.Console (Console)
+import Atelier.Effects.Delay (Delay)
+import Atelier.Effects.Exit (Exit, exitFailure)
+import Atelier.Effects.File (File)
+import Atelier.Effects.Posix.Daemons (Daemons, PidFile)
+import Effectful (IOE)
+import Effectful.Reader.Static (Reader)
+import Tricorder.CLI.Command.IfDaemonIsStopped (IfDaemonIsStopped)
+import Tricorder.CLI.Command.OutputFormat (OutputFormat (..))
+import Tricorder.CLI.Command.Test (Command (..), SuitesOptions (..))
+import Tricorder.CLI.Command.WaitMode (WaitMode (..))
+
+import Atelier.Effects.Console qualified as Console
+import Effectful.Reader.Static qualified as Reader
+import Tricorder.CLI.Command.IfDaemonIsStopped qualified as IfDaemonIsStopped
+import Tricorder.FieldMask.Schema qualified as FieldMask.Schema
+
+import Tricorder.Build (BuildState (..))
+import Tricorder.CLI.App.Test.SuiteView (defaultFields, schema)
+import Tricorder.Runtime (SocketPath (..))
+import Tricorder.Socket.Client (queryStatus, queryStatusWait)
+import Tricorder.Socket.UnixSocket (UnixSocket)
+
+import Tricorder.CLI.App.Test.Json qualified as Json
+import Tricorder.CLI.App.Test.Text qualified as Text
+import Tricorder.CLI.Daemon qualified as Daemons
+import Tricorder.Session.TestTarget qualified as TestTarget
+import Tricorder.Socket.Client qualified as Client
+
+
+run
+    :: ( Console :> es
+       , Daemons :> es
+       , Delay :> es
+       , Exit :> es
+       , File :> es
+       , IOE :> es
+       , Reader PidFile :> es
+       , Reader SocketPath :> es
+       , UnixSocket :> es
+       )
+    => Command -> Eff es ()
+run command =
+    printResult =<< case command of
+        Suites opts -> showSuites opts
+
+
+printResult :: (Console :> es, Exit :> es) => Either Text Text -> Eff es ()
+printResult = \case
+    Left err -> do
+        Console.putTextLn err
+        exitFailure
+    Right res ->
+        Console.putTextLn res
+
+
+showSuites
+    :: ( Daemons :> es
+       , Delay :> es
+       , File :> es
+       , IOE :> es
+       , Reader PidFile :> es
+       , Reader SocketPath :> es
+       , UnixSocket :> es
+       )
+    => SuitesOptions -> Eff es (Either Text Text)
+showSuites opts =
+    case validated of
+        Left err -> pure $ case opts.format of
+            TextOutput -> Left $ "Error: " <> err
+            JsonOutput -> Json.argumentError err
+        Right (targets, selection) ->
+            ensureDaemonRunning opts.ifDaemonIsStopped do
+                result <- awaitBuildStatus opts.wait
+                case opts.format of
+                    TextOutput ->
+                        pure
+                            $ Text.wrapTextShow result
+                            $ Text.showBasePhase
+                            $ Text.showSuites targets opts.testFilter selection
+                    JsonOutput ->
+                        pure
+                            $ Json.wrapResponse
+                            $ Json.showBasePhase result
+                            $ Json.showSuites targets opts.testFilter selection
+  where
+    validated = do
+        targets <- traverse parseTarget opts.suiteNames
+        selection <- FieldMask.Schema.select schema $ fromMaybe defaultFields opts.fields
+        pure (targets, selection)
+    parseTarget suiteName =
+        maybeToRight
+            ( "invalid test suite name: "
+                <> suiteName
+                <> ". Test suite names are expected to be in the format of a Cabal target."
+            )
+            $ TestTarget.parse suiteName
+
+
+awaitBuildStatus
+    :: ( File :> es
+       , Reader SocketPath :> es
+       , UnixSocket :> es
+       )
+    => WaitMode -> Eff es (Either Text BuildState)
+awaitBuildStatus wait = do
+    SocketPath sockPath <- Reader.ask
+    case wait of
+        WaitForBuild -> queryStatusWait sockPath
+        ShowCurrent -> queryStatus sockPath
+
+
+ensureDaemonRunning
+    :: ( Daemons :> es
+       , Delay :> es
+       , IOE :> es
+       , Reader PidFile :> es
+       , Reader SocketPath :> es
+       , UnixSocket :> es
+       )
+    => IfDaemonIsStopped -> Eff es (Either Text Text) -> Eff es (Either Text Text)
+ensureDaemonRunning ifDaemonIsStopped continue = do
+    isRunning <- Client.isDaemonRunning
+    case (ifDaemonIsStopped, isRunning) of
+        (IfDaemonIsStopped.Fail, False) -> pure $ Left "daemon is stopped"
+        (IfDaemonIsStopped.StartDaemon, False) -> do
+            ensureDaemonIsRunning
+            continue
+        (_, True) -> continue
+
+
+ensureDaemonIsRunning
+    :: ( Daemons :> es
+       , Delay :> es
+       , IOE :> es
+       , Reader PidFile :> es
+       , Reader SocketPath :> es
+       , UnixSocket :> es
+       )
+    => Eff es ()
+ensureDaemonIsRunning = do
+    Daemons.startDaemon
+    void Daemons.waitForDaemon
diff --git a/src/Tricorder/CLI/App/Test/Json.hs b/src/Tricorder/CLI/App/Test/Json.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/CLI/App/Test/Json.hs
@@ -0,0 +1,63 @@
+module Tricorder.CLI.App.Test.Json
+    ( showSuites
+    , wrapResponse
+    , showBasePhase
+    , argumentError
+    )
+where
+
+import Data.Aeson (ToJSON, toJSON)
+import Data.Aeson.Text (encodeToLazyText)
+import Tricorder.CLI.Command.TestFilter (TestFilter)
+
+import Data.Text qualified as T
+import Tricorder.FieldMask.Projection qualified as FieldMask.Projection
+import Tricorder.FieldMask.Schema qualified as FieldMask (Schema)
+
+import Tricorder.Build (BuildPhase (..), BuildState (..), PostBuild (..))
+import Tricorder.CLI.App.Test.Json.Responses
+    ( MessageWithLoc (..)
+    , Response (..)
+    , SuitesResponse (..)
+    )
+import Tricorder.CLI.App.Test.SuiteView (selectSuites)
+import Tricorder.Session.TestTarget (TestTarget)
+
+import Tricorder.Build.Test qualified as Test
+import Tricorder.CLI.App.Test.Json.Responses qualified as Responses
+import Tricorder.Session.TestTarget qualified as TestTarget
+
+
+showSuites
+    :: [TestTarget] -> TestFilter -> FieldMask.Schema -> Test.Suites -> Response SuitesResponse
+showSuites targets testFilter selection testSuites =
+    case selectSuites targets testFilter testSuites of
+        Left notFound ->
+            Responses.Error
+                $ MessageWithLoc (T.intercalate ", " $ TestTarget.render <$> toList notFound) "not found"
+        Right suites -> Responses.Success $ SuitesResponse $ FieldMask.Projection.project selection . toJSON <$> suites
+
+
+wrapResponse :: (ToJSON a) => Response a -> Either Text Text
+wrapResponse resp = case resp of
+    Responses.Error _ -> Left $ toStrict $ encodeToLazyText resp
+    _ -> Right $ toStrict $ encodeToLazyText resp
+
+
+-- | Report invalid arguments given to a command.
+argumentError :: Text -> Either Text Text
+argumentError err = wrapResponse @() $ Responses.Error $ MessageWithLoc "arguments" err
+
+
+showBasePhase :: Either Text BuildState -> (Test.Suites -> Response a) -> Response a
+showBasePhase result mkResponse = case result of
+    Left err -> Responses.Error $ MessageWithLoc "build" err
+    Right build -> case build.phase of
+        Starting -> Responses.Pending $ MessageWithLoc "build" "starting"
+        Building _ _ -> Responses.Pending $ MessageWithLoc "build" "building"
+        PostBuilding _ postBuild
+            | Test.anyRunningTests postBuild.testSuites ->
+                Responses.Pending $ MessageWithLoc "postBuild" "testing"
+            | otherwise -> mkResponse postBuild.testSuites
+        Finished _ postBuild -> mkResponse postBuild.testSuites
+        Failed reason -> Responses.Error $ MessageWithLoc "build" $ "build failed: " <> reason
diff --git a/src/Tricorder/CLI/App/Test/Json/Responses.hs b/src/Tricorder/CLI/App/Test/Json/Responses.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/CLI/App/Test/Json/Responses.hs
@@ -0,0 +1,79 @@
+module Tricorder.CLI.App.Test.Json.Responses
+    ( Response (..)
+    , MessageWithLoc (..)
+    , SuitesResponse (..)
+    )
+where
+
+import Atelier.Types.JSON.QuietSnake (QuietSnake (..))
+import Data.Aeson
+    ( FromJSON
+    , Options (..)
+    , SumEncoding (..)
+    , ToJSON (..)
+    , defaultOptions
+    , genericToEncoding
+    , genericToJSON
+    , object
+    , pairs
+    , (.=)
+    )
+import Text.Casing (quietSnake)
+
+import Tricorder.FieldMask.Projection qualified as FieldMask (Projection)
+
+
+data Response a
+    = Error MessageWithLoc
+    | Pending MessageWithLoc
+    | Success a
+    deriving stock (Eq, Functor, Generic, Show)
+
+
+instance Applicative Response where
+    pure = Success
+    Error message <*> _ = Error message
+    _ <*> Error message = Error message
+    Pending message <*> _ = Pending message
+    _ <*> Pending message = Pending message
+    Success f <*> Success a = Success $ f a
+
+
+instance Monad Response where
+    Error message >>= _ = Error message
+    Pending message >>= _ = Pending message
+    Success a >>= f = f a
+
+
+instance (ToJSON a) => ToJSON (Response a) where
+    toJSON = genericToJSON responseOptions
+    toEncoding = genericToEncoding responseOptions
+
+
+data MessageWithLoc = MessageWithLoc
+    { location :: Text
+    , message :: Text
+    }
+    deriving stock (Eq, Generic, Show)
+    deriving (FromJSON, ToJSON) via QuietSnake MessageWithLoc
+
+
+responseOptions :: Options
+responseOptions =
+    defaultOptions
+        { constructorTagModifier = quietSnake
+        , sumEncoding = ObjectWithSingleField
+        }
+
+
+-- | Test suites, cut down to the requested fields.
+newtype SuitesResponse = SuitesResponse
+    { suites :: [FieldMask.Projection]
+    }
+    deriving stock (Eq, Show)
+
+
+-- | Written by hand so 'toEncoding' keeps the field order of the projection.
+instance ToJSON SuitesResponse where
+    toJSON response = object ["suites" .= response.suites]
+    toEncoding response = pairs ("suites" .= response.suites)
diff --git a/src/Tricorder/CLI/App/Test/SuiteView.hs b/src/Tricorder/CLI/App/Test/SuiteView.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/CLI/App/Test/SuiteView.hs
@@ -0,0 +1,154 @@
+-- | The full, unprojected view of a test suite that @tricorder test suites@
+-- selects fields from.
+module Tricorder.CLI.App.Test.SuiteView
+    ( SuiteView (..)
+    , Status (..)
+    , CaseView (..)
+    , Outcome (..)
+    , schema
+    , defaultFields
+    , selectSuites
+    )
+where
+
+import Atelier.Types.JSON.QuietSnake (QuietSnake (..))
+import Data.Aeson (ToJSON (..))
+import Tricorder.CLI.Command.TestFilter (TestFilter)
+import Tricorder.FieldMask (Field (..), FieldMask (..))
+
+import Data.Map.Strict qualified as Map
+import Data.Text qualified as T
+import Tricorder.CLI.Command.TestFilter qualified as TestFilter
+import Tricorder.FieldMask.Schema qualified as FieldMask (Schema (..))
+import Tricorder.FieldMask.Schema qualified as FieldMask.Schema
+
+import Tricorder.Build.Duration (Duration)
+import Tricorder.Session.TestTarget (TestTarget)
+import Tricorder.TestOutput (stripGhciNoise)
+
+import Tricorder.Build.Test qualified as Test
+import Tricorder.Session.TestTarget qualified as TestTarget
+
+
+-- | Fields that do not apply to a suite's 'Status' are 'Nothing', and are
+-- left out of the output.
+data SuiteView = SuiteView
+    { name :: Text
+    , status :: Status
+    , error :: Maybe Text
+    , passedCases :: Maybe Int
+    , totalCases :: Maybe Int
+    , durationMs :: Maybe Duration
+    , output :: Maybe Text
+    , cases :: Maybe [CaseView]
+    }
+    deriving stock (Eq, Generic, Show)
+    deriving (ToJSON) via QuietSnake SuiteView
+
+
+data Status = Running | Errored | Passed | Failed
+    deriving stock (Eq, Generic, Show)
+    deriving (ToJSON) via QuietSnake Status
+
+
+data CaseView = CaseView
+    { description :: Text
+    , outcome :: Outcome
+    , failure :: Maybe Text
+    }
+    deriving stock (Eq, Generic, Show)
+    deriving (ToJSON) via QuietSnake CaseView
+
+
+data Outcome = CasePassed | CaseFailed
+    deriving stock (Eq, Generic, Show)
+
+
+instance ToJSON Outcome where
+    toJSON = \case
+        CasePassed -> "passed"
+        CaseFailed -> "failed"
+
+
+-- | Every field of a 'SuiteView', as named in its JSON encoding.
+schema :: FieldMask.Schema
+schema =
+    FieldMask.Schema
+        [ FieldMask.Schema.leaf "name"
+        , FieldMask.Schema.leaf "status"
+        , FieldMask.Schema.leaf "error"
+        , FieldMask.Schema.leaf "passed_cases"
+        , FieldMask.Schema.leaf "total_cases"
+        , FieldMask.Schema.leaf "duration_ms"
+        , FieldMask.Schema.leaf "output"
+        , FieldMask.Schema.nested
+            "cases"
+            [ FieldMask.Schema.leaf "description"
+            , FieldMask.Schema.leaf "outcome"
+            , FieldMask.Schema.leaf "failure"
+            ]
+        ]
+
+
+-- | A summary of each suite, leaving out the (potentially large) test output
+-- and test cases.
+defaultFields :: FieldMask
+defaultFields =
+    FieldMask
+        $ (\name -> Field name Nothing)
+            <$> "name" :| ["status", "error", "passed_cases", "total_cases", "duration_ms"]
+
+
+-- | Pick out the requested suites, or all of them when none are requested.
+-- Fails with the requested suites that do not exist.
+--
+-- 'TestFilter.FailedOnly' keeps only failed suites, and only the failed test
+-- cases within them.
+selectSuites
+    :: [TestTarget] -> TestFilter -> Test.Suites -> Either (NonEmpty TestTarget) [SuiteView]
+selectSuites requested testFilter (Test.Suites suites) = do
+    selected <- case requested of
+        [] -> Right $ Map.toList suites
+        _ -> case nonEmpty missing of
+            Just notFound -> Left notFound
+            Nothing -> Right [(target, suite) | target <- ordNub requested, Just suite <- [Map.lookup target suites]]
+    pure $ uncurry toView <$> filter (keepSuite . snd) selected
+  where
+    missing = filter (`Map.notMember` suites) requested
+
+    keepSuite suite = case testFilter of
+        TestFilter.FailedOnly -> Test.isFailedRun suite
+        TestFilter.All -> True
+
+    keepCase testCase = case testFilter of
+        TestFilter.FailedOnly -> Test.caseFailed testCase
+        TestFilter.All -> True
+
+    toView target suite =
+        let view =
+                SuiteView
+                    { name = TestTarget.render target
+                    , status = Running
+                    , error = Nothing
+                    , passedCases = Nothing
+                    , totalCases = Nothing
+                    , durationMs = Nothing
+                    , output = Nothing
+                    , cases = Nothing
+                    }
+        in  case suite of
+                Test.SuiteRunning _ -> view
+                Test.SuiteErrored err -> view {status = Errored, error = Just err.message}
+                Test.SuiteCompleted completion ->
+                    view
+                        { status = if completion.passed then Passed else Failed
+                        , passedCases = Just $ length $ filter (not . Test.caseFailed) completion.testCases
+                        , totalCases = Just $ length completion.testCases
+                        , durationMs = completion.duration
+                        , output = Just $ T.unlines $ stripGhciNoise $ T.lines completion.output
+                        , cases = Just $ toCaseView <$> filter keepCase completion.testCases
+                        }
+
+    toCaseView testCase = case testCase.outcome of
+        Test.Passed -> CaseView testCase.description CasePassed Nothing
+        Test.Failed reason -> CaseView testCase.description CaseFailed (Just reason)
diff --git a/src/Tricorder/CLI/App/Test/Text.hs b/src/Tricorder/CLI/App/Test/Text.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/CLI/App/Test/Text.hs
@@ -0,0 +1,99 @@
+module Tricorder.CLI.App.Test.Text
+    ( showSuites
+    , wrapTextShow
+    , showBasePhase
+    )
+where
+
+import Data.Aeson (toJSON)
+import Tricorder.CLI.Command.TestFilter (TestFilter)
+
+import Data.Text qualified as T
+import Tricorder.CLI.Command.TestFilter qualified as TestFilter
+import Tricorder.FieldMask.Projection qualified as FieldMask.Projection
+import Tricorder.FieldMask.Schema qualified as FieldMask
+
+import Tricorder.Build
+    ( BuildPhase (..)
+    , BuildProgress (..)
+    , BuildResult (..)
+    , BuildState (..)
+    , Diagnostic (..)
+    , PostBuild (..)
+    , Severity (..)
+    )
+import Tricorder.CLI.App.Test.SuiteView (selectSuites)
+import Tricorder.Session.TestTarget (TestTarget)
+
+import Tricorder.Build.Test qualified as Test
+import Tricorder.Session.TestTarget qualified as TestTarget
+
+
+showSuites :: [TestTarget] -> TestFilter -> FieldMask.Schema -> Test.Suites -> Either Text Text
+showSuites targets testFilter selection testSuites =
+    case selectSuites targets testFilter testSuites of
+        Left notFound ->
+            Left $ "test suites not found: " <> T.intercalate ", " (TestTarget.render <$> toList notFound)
+        Right []
+            | Test.nullSuites testSuites -> Right "no test suites to run"
+            | TestFilter.FailedOnly <- testFilter -> Right "no failed test suites"
+        Right suites ->
+            Right
+                $ T.stripEnd
+                $ FieldMask.Projection.renderYaml
+                $ FieldMask.Projection.PArray
+                $ FieldMask.Projection.project selection . toJSON <$> suites
+
+
+wrapTextShow :: Either Text BuildState -> (BuildPhase -> Either Text Text) -> Either Text Text
+wrapTextShow = \case
+    Left err -> const $ Left $ "Error: " <> err
+    Right build -> ($ build.phase)
+
+
+showBasePhase :: (Test.Suites -> Either Text Text) -> BuildPhase -> Either Text Text
+showBasePhase showTests = \case
+    Starting -> Right "Starting..."
+    Building _ progress ->
+        Right
+            $ mconcat
+                [ "Building ("
+                , show progress.compiled
+                , "/"
+                , show progress.total
+                , ")..."
+                ]
+    PostBuilding result postBuild
+        | buildFailed result postBuild ->
+            Left $ "build failed before tests ran"
+        | Test.anyRunningTests postBuild.testSuites ->
+            Right $ showTestProgress postBuild.testSuites
+        | otherwise ->
+            showTests postBuild.testSuites
+    Finished result postBuild
+        | buildFailed result postBuild ->
+            Left $ "build failed before tests ran"
+        | otherwise ->
+            showTests postBuild.testSuites
+    Failed err -> Left $ "Build failed: " <> err
+  where
+    buildFailed result postBuild = postBuild.testSuites == mempty && any (\d -> d.severity >= SError) result.diagnostics
+
+
+showTestProgress :: Test.Suites -> Text
+showTestProgress suites =
+    mconcat
+        [ "Testing (test suites finished: "
+        , show
+            $ length
+            $ filter
+                ( \case
+                    Test.SuiteCompleted _ -> True
+                    Test.SuiteErrored _ -> True
+                    _ -> False
+                )
+            $ toList suites.getSuites
+        , "/"
+        , show $ length $ suites.getSuites
+        , ")"
+        ]
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,12 +1,5 @@
 module Tricorder.CLI.Arguments
-    ( Command (..)
-    , LogMode (..)
-    , StatusOptions (..)
-    , TestOptions (..)
-    , EvalCommentsOptions (..)
-    , Verbosity (..)
-    , WaitMode (..)
-    , parseArguments
+    ( parseArguments
     , runArguments
     )
 where
@@ -33,6 +26,8 @@
     , metavar
     , option
     , progDesc
+    , progDescDoc
+    , readerError
     , short
     )
 import Tricorder.CLI.Command
@@ -40,15 +35,21 @@
     , EvalCommentsOptions (..)
     , Force (..)
     , LogMode (..)
+    , SourceOptions (..)
     , StatusOptions (..)
     , TestOptions (..)
     , Verbosity (..)
-    , WaitMode (..)
     )
 import Tricorder.SourceLookup.SourceQuery (SourceQuery, parseSourceQuery)
 
+import Options.Applicative.Help.Pretty qualified as Doc
+
+import Tricorder.SourceLookup (ReexportLimits (..), defaultReexportLimits)
+
 import Tricorder.CLI.Arguments.Daemon qualified as Daemon
 import Tricorder.CLI.Arguments.OutputFormat qualified as OutputFormat
+import Tricorder.CLI.Arguments.Test qualified as Test
+import Tricorder.CLI.Arguments.WaitMode qualified as WaitMode
 import Tricorder.Version qualified as Version
 
 
@@ -82,12 +83,23 @@
             <> command
                 "status"
                 (info statusParser (progDesc "Print build diagnostics (--json for machine-readable output)"))
-            <> command "test-results" (info testParser (progDesc "Show output from the latest test run"))
+            <> command
+                "test-results"
+                ( info
+                    testResultsParser
+                    ( progDescDoc
+                        $ Just
+                        $ "Show output from the latest test run"
+                            <> Doc.line
+                            <> Doc.annotate Doc.bold "DEPRECATED: use the `test suites` subcommand"
+                    )
+                )
+            <> command "test" (Test <$> Test.parser)
             <> command "ui" (info (pure UI) (progDesc "Auto-refreshing terminal display"))
             <> command "log" (info logParser (progDesc "Show daemon log output"))
             <> command
                 "source"
-                (info sourceParser (progDesc "Print the Haskell source of one or more installed modules"))
+                (info sourceParser (progDesc "Print the Haskell source of one or more project or installed modules"))
             <> command "restart" (info restartParser (progDesc "Restart the daemon"))
             <> command
                 "eval-comments"
@@ -113,7 +125,7 @@
 statusParser =
     Status
         <$> ( StatusOptions
-                <$> waitParser
+                <$> WaitMode.parser
                 <*> OutputFormat.parser
                 <*> flag
                     Concise
@@ -133,9 +145,9 @@
             )
 
 
-testParser :: Parser Command
-testParser =
-    Test
+testResultsParser :: Parser Command
+testResultsParser =
+    TestResults
         <$> ( TestOptions
                 <$> flag
                     False
@@ -143,14 +155,48 @@
                     ( long "failed"
                         <> help "Only show output from failed test suites"
                     )
-                <*> waitParser
+                <*> WaitMode.parser
             )
 
 
 sourceParser :: Parser Command
 sourceParser =
     Source
-        <$> some (argument queryReader (metavar "MODULE[#FUNCTION]" <> help "Module or Module#function"))
+        <$> ( SourceOptions
+                <$> some
+                    ( argument
+                        queryReader
+                        (metavar "Module[#function]" <> help "Module or Module#function")
+                    )
+                <*> optional
+                    ( option
+                        nonNegative
+                        ( long "max-depth"
+                            <> metavar "N"
+                            <> help
+                                ( "Follow at most N re-export hops when a symbol is not defined in the queried module (default: "
+                                    <> show defaultReexportLimits.maxDepth
+                                    <> ")"
+                                )
+                        )
+                    )
+                <*> optional
+                    ( option
+                        nonNegative
+                        ( long "max-modules"
+                            <> metavar "N"
+                            <> help
+                                ( "Read at most N modules while following re-exports (default: "
+                                    <> show defaultReexportLimits.maxModules
+                                    <> ")"
+                                )
+                        )
+                    )
+            )
+  where
+    nonNegative = do
+        n <- auto
+        if n < 0 then readerError "must be a non-negative integer" else pure n
 
 
 stopParser :: Parser Command
@@ -171,19 +217,9 @@
 evalCommentsParser =
     EvalComments
         <$> ( EvalCommentsOptions
-                <$> waitParser
+                <$> WaitMode.parser
                 <*> OutputFormat.parser
             )
-
-
-waitParser :: Parser WaitMode
-waitParser =
-    flag
-        ShowCurrent
-        WaitForBuild
-        ( long "wait"
-            <> help "Block until the current build cycle completes"
-        )
 
 
 queryReader :: ReadM SourceQuery
diff --git a/src/Tricorder/CLI/Arguments/IfDaemonIsStopped.hs b/src/Tricorder/CLI/Arguments/IfDaemonIsStopped.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/CLI/Arguments/IfDaemonIsStopped.hs
@@ -0,0 +1,14 @@
+module Tricorder.CLI.Arguments.IfDaemonIsStopped (parser) where
+
+import Options.Applicative (Parser, flag, help, long, short)
+import Tricorder.CLI.Command.IfDaemonIsStopped (IfDaemonIsStopped (..), startDaemonFlagName)
+
+
+parser :: Parser IfDaemonIsStopped
+parser =
+    flag
+        Fail
+        StartDaemon
+        $ long startDaemonFlagName
+            <> short 'd'
+            <> help "Start the daemon if it is not already running."
diff --git a/src/Tricorder/CLI/Arguments/OutputFormat.hs b/src/Tricorder/CLI/Arguments/OutputFormat.hs
--- a/src/Tricorder/CLI/Arguments/OutputFormat.hs
+++ b/src/Tricorder/CLI/Arguments/OutputFormat.hs
@@ -1,7 +1,7 @@
 module Tricorder.CLI.Arguments.OutputFormat (parser) where
 
-import Options.Applicative (Parser, flag, help, long)
-import Tricorder.CLI.Command.OutputFormat (OutputFormat (..))
+import Options.Applicative (Parser, flag, help, long, short)
+import Tricorder.CLI.Command.OutputFormat (OutputFormat (..), jsonOutputFlagName)
 
 
 parser :: Parser OutputFormat
@@ -9,4 +9,6 @@
     flag
         TextOutput
         JsonOutput
-        $ long "json" <> help "Output full build state as JSON"
+        $ long jsonOutputFlagName
+            <> short 'j'
+            <> help "Output full build state as JSON"
diff --git a/src/Tricorder/CLI/Arguments/Test.hs b/src/Tricorder/CLI/Arguments/Test.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/CLI/Arguments/Test.hs
@@ -0,0 +1,106 @@
+module Tricorder.CLI.Arguments.Test (parser) where
+
+import Options.Applicative
+    ( Parser
+    , ParserInfo
+    , command
+    , footer
+    , help
+    , helpDoc
+    , hsubparser
+    , info
+    , metavar
+    , progDesc
+    , strArgument
+    )
+import Tricorder.CLI.Command.Test (Command, SuitesOptions (..))
+
+import Data.Text qualified as T
+import Options.Applicative.Help.Pretty qualified as Doc
+import Tricorder.CLI.Command.Test qualified as Test
+import Tricorder.FieldMask qualified as FieldMask
+import Tricorder.FieldMask.Args qualified as FieldMask.Args
+import Tricorder.FieldMask.Schema qualified as FieldMask (Schema (..))
+
+import Tricorder.CLI.App.Test.SuiteView qualified as SuiteView
+import Tricorder.CLI.Arguments.IfDaemonIsStopped qualified as IfDaemonIsStopped
+import Tricorder.CLI.Arguments.OutputFormat qualified as OutputFormat
+import Tricorder.CLI.Arguments.TestFilter qualified as TestFilter
+import Tricorder.CLI.Arguments.WaitMode qualified as WaitMode
+
+
+parser :: ParserInfo Command
+parser =
+    info commandParser (progDesc "See statuses and information on configured test suites.")
+
+
+commandParser :: Parser Command
+commandParser =
+    hsubparser
+        $ command
+            "suites"
+            ( info
+                suitesParser
+                ( progDesc "Show test suites, their test cases and output."
+                    <> footer suitesFooter
+                )
+            )
+
+
+suitesParser :: Parser Command
+suitesParser =
+    fmap Test.Suites
+        $ SuitesOptions
+            <$> many (strArgument (metavar "TEST-TARGET..." <> help "Test suites to show (default: all)"))
+            <*> TestFilter.parser (help "Show only failed test suites, and only their failed test cases")
+            <*> FieldMask.Args.parser
+                ( helpDoc
+                    $ Just
+                    $ (<> Doc.line)
+                    $ mconcat
+                    $ Doc.punctuate
+                        (Doc.line <> Doc.line)
+                        [ "Fields to return for each test suite."
+                        , "Available fields:"
+                            <> Doc.line
+                            <> Doc.indent 4 (Doc.vsep $ renderSchema SuiteView.schema)
+                        , "Select nested fields with parentheses,"
+                            <> Doc.line
+                            <> "e.g. --fields 'name,cases(description,failure)'."
+                            <> Doc.line
+                            <> "A field without parentheses shows all of its nested fields."
+                        , "Default fields:"
+                            <> Doc.line
+                            <> Doc.pretty (FieldMask.render SuiteView.defaultFields)
+                        ]
+                )
+            <*> OutputFormat.parser
+            <*> WaitMode.parser
+            <*> IfDaemonIsStopped.parser
+  where
+    renderSchema (FieldMask.Schema fields) = renderField <$> fields
+    renderField (name, sub) =
+        Doc.pretty name
+            <> maybe
+                Doc.emptyDoc
+                ( \nestedSchema ->
+                    Doc.enclose
+                        "("
+                        ")"
+                        $ Doc.sep
+                        $ Doc.punctuate ","
+                        $ renderSchema nestedSchema
+                )
+                sub
+
+
+suitesFooter :: String
+suitesFooter =
+    toString
+        $ "Available fields:\n"
+            <> renderSchema SuiteView.schema
+            <> "\nSelect nested fields with parentheses, e.g. --fields 'name,cases(description,failure)'."
+            <> " A field without parentheses shows all of its nested fields."
+  where
+    renderSchema (FieldMask.Schema fields) = T.intercalate ", " $ renderField <$> fields
+    renderField (name, sub) = name <> maybe "" (\nestedSchema -> "(" <> renderSchema nestedSchema <> ")") sub
diff --git a/src/Tricorder/CLI/Arguments/TestFilter.hs b/src/Tricorder/CLI/Arguments/TestFilter.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/CLI/Arguments/TestFilter.hs
@@ -0,0 +1,17 @@
+module Tricorder.CLI.Arguments.TestFilter (parser) where
+
+import Options.Applicative (FlagFields, Mod, Parser, flag, long, short)
+import Tricorder.CLI.Command.TestFilter
+    ( TestFilter (..)
+    , failedOnlyFlagName
+    , failedOnlyFlagShortName
+    )
+import Prelude hiding (All (..))
+
+
+parser :: Mod FlagFields TestFilter -> Parser TestFilter
+parser extraMods =
+    flag All FailedOnly
+        $ long failedOnlyFlagName
+            <> short failedOnlyFlagShortName
+            <> extraMods
diff --git a/src/Tricorder/CLI/Arguments/WaitMode.hs b/src/Tricorder/CLI/Arguments/WaitMode.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/CLI/Arguments/WaitMode.hs
@@ -0,0 +1,12 @@
+module Tricorder.CLI.Arguments.WaitMode (parser) where
+
+import Options.Applicative (Parser, flag, help, long, short)
+import Tricorder.CLI.Command.WaitMode (WaitMode (..), waitForBuildFlagName)
+
+
+parser :: Parser WaitMode
+parser =
+    flag ShowCurrent WaitForBuild
+        $ long waitForBuildFlagName
+            <> short 'w'
+            <> help "Block until the current build cycle completes"
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
@@ -1,16 +1,14 @@
 module Tricorder.CLI.Main (main) where
 
-import Atelier.Config (runConfig)
 import Atelier.Effects.Arguments (runArgumentsIO)
 import Atelier.Effects.Clock (runClock)
 import Atelier.Effects.Conc (runConc)
-import Atelier.Effects.Console (runConsole)
+import Atelier.Effects.Console (Console, runConsole, runConsoleHandle)
 import Atelier.Effects.Delay (runDelay)
 import Atelier.Effects.Env (runEnv)
-import Atelier.Effects.Exit (runExit)
+import Atelier.Effects.Exit (exitFailure, 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)
@@ -19,30 +17,29 @@
 import Data.Default (def)
 import Effectful (runEff)
 import Effectful.Concurrent (runConcurrent)
-import Effectful.Reader.Static (runReader)
-import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery)
+import Effectful.Provider (provideWith_, runProvider_)
+import System.IO (stderr)
 
-import Atelier.Effects.Cache qualified as Cache
-import Atelier.Effects.Cache qualified as CacheConfig
+import Atelier.Effects.Console qualified as Console
 import Atelier.Effects.FileSystem.Glob qualified as Glob
+import Atelier.Effects.Input qualified as Input
 
 import Tricorder.CLI.Arguments (runArguments)
 import Tricorder.CLI.UI.Brick (runBrick)
 import Tricorder.CLI.UI.BrickChan (runBrickChan)
-import Tricorder.Config (inputLoadedConfig, runLoadedConfig)
+import Tricorder.Config (asInput)
 import Tricorder.Runtime (runLogPath, runPidFile, runProjectRoot, runRuntimeDir, runSocketPath)
-import Tricorder.Session (Session (..), loadSession)
 import Tricorder.Session.CabalFile (inputCabalFiles)
 import Tricorder.Session.CommandTemplate (CommandTemplate (..))
 import Tricorder.Session.Stage.Build.Session (BuildSession (..))
-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.Config.LoadedConfig qualified as LoadedConfig
 import Tricorder.Daemon.DaemonInfo qualified as DaemonInfo
+import Tricorder.Session qualified as Session
 import Tricorder.Session.StackProject qualified as StackProject
-import Tricorder.SourceLookup qualified as SourceLookup
+import Tricorder.Socket.UnixSocket qualified as UnixSocket
 import Tricorder.SourceLookup.GhcPkg qualified as GhcPkg
 import Tricorder.SourceLookup.Hackage qualified as Hackage
 import Tricorder.SourceLookup.PackageStore qualified as PackageStore
@@ -57,6 +54,7 @@
         . runBrickChan
         . runBrick
         . runConsole
+        . runProvider_ runConsoleHandle
         . runExit
         . runClock
         . runDelay
@@ -68,27 +66,34 @@
         . runPidFile
         . runSocketPath
         . runLogPath
-        . runLoadedConfig
-        . runConfig @"keybindings" @Keys.Config
+        . LoadedConfig.asInput
+        . LoadedConfig.asNestedInput @"keybindings" @Keys.Config
+        . Input.map (either @_ @Keys.Config (const def) id)
+        . Input.toReader @Keys.Config
         . runDaemons
         . runProcessIO
         . runArgumentsIO
         . runArguments
-        . runUnixSocketIO
+        . UnixSocket.runIO
         . runEnv
-        . inputLoadedConfig
         . runLogNoOp
         . StackProject.inputWithCache
         . inputCabalFiles
-        . runInputEff loadSession
-        . runInputEff ((.buildSession.commandTemplate.repl) <$> input)
-        . runReader @CacheConfig.Config def
-        . Cache.runCacheTtl @ModuleName @PackageId
-        . Cache.runCacheTtl @(PackageId, SourceQuery) @SourceLookup.ModuleSourceResult
-        . GhcPkg.runGhcPkgIO
-        . PackageStore.run
-        . Hackage.run
-        . DaemonInfo.runInput
+        . Tricorder.Config.asInput
         $ do
-            installTerminationHandler
-            App.run
+            Session.load >>= \case
+                Left err -> do
+                    provideWith_ @Console stderr
+                        $ Console.putTextLn
+                        $ "Failed to load session: " <> err
+                    exitFailure
+                Right session ->
+                    Input.withConst session
+                        . Input.withConst session.buildSession.commandTemplate.repl
+                        . GhcPkg.runGhcPkgIO
+                        . PackageStore.run
+                        . Hackage.run
+                        . DaemonInfo.runInput
+                        $ do
+                            installTerminationHandler
+                            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
@@ -8,20 +8,26 @@
     )
 where
 
-import Atelier.Effects.Cache (Cache)
 import Atelier.Effects.Clock (Clock, currentTimeZone)
 import Atelier.Effects.Console (Console)
 import Atelier.Effects.Exit (Exit, exitFailure)
 import Atelier.Effects.File (File)
 import Atelier.Effects.FileSystem (FileSystem, doesFileExist, readFileLbs)
-import Atelier.Effects.Input (Input)
+import Atelier.Effects.Input (Input, input)
 import Atelier.Effects.Log (Log)
 import Data.Aeson (ToJSON, encode)
 import Data.Time.Format (defaultTimeLocale, formatTime)
 import Data.Time.LocalTime (utcToLocalTime)
 import Effectful.Reader.Static (Reader, ask)
+import Tricorder.CLI.Command
+    ( EvalCommentsOptions (..)
+    , SourceOptions (..)
+    , StatusOptions (..)
+    , TestOptions (..)
+    , Verbosity (..)
+    )
 import Tricorder.CLI.Command.OutputFormat (OutputFormat (..))
-import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery)
+import Tricorder.CLI.Command.WaitMode (WaitMode (..))
 
 import Atelier.Effects.Console qualified as Console
 import Data.ByteString.Lazy qualified as BSL
@@ -31,13 +37,6 @@
 import Tricorder.Build (BuildState (..), Severity (..))
 import Tricorder.Build.Duration (Duration (..))
 import Tricorder.Build.Test (Suites (..))
-import Tricorder.CLI.Arguments
-    ( EvalCommentsOptions (..)
-    , StatusOptions (..)
-    , TestOptions (..)
-    , Verbosity (..)
-    , WaitMode (..)
-    )
 import Tricorder.CLI.Render
     ( diagnosticLineIndexed
     , formatDuration
@@ -45,13 +44,18 @@
     )
 import Tricorder.Daemon.DaemonInfo (DaemonInfo (..))
 import Tricorder.Runtime (SocketPath (..))
+import Tricorder.Session.CabalFile (CabalFile)
 import Tricorder.Session.Repl (Repl)
 import Tricorder.Socket.Client (queryStatus, queryStatusWait)
 import Tricorder.Socket.UnixSocket (UnixSocket)
-import Tricorder.SourceLookup (ModuleSourceResult, lookupModuleSource)
+import Tricorder.SourceLookup
+    ( ReexportLimits (..)
+    , defaultReexportLimits
+    , lookupModuleSource
+    , projectSourceDirs
+    )
 import Tricorder.SourceLookup.GhcPkg (GhcPkg)
 import Tricorder.SourceLookup.Hackage (Hackage)
-import Tricorder.SourceLookup.PackageId (PackageId)
 import Tricorder.SourceLookup.PackageStore (PackageStore)
 import Tricorder.TestOutput (stripGhciNoise)
 
@@ -262,21 +266,27 @@
 
 
 showSource
-    :: ( Cache (PackageId, SourceQuery) ModuleSourceResult :> es
-       , Cache ModuleName PackageId :> es
-       , Console :> es
+    :: ( Console :> es
        , FileSystem :> es
        , GhcPkg :> es
        , Hackage :> es
        , Input Repl :> es
+       , Input [CabalFile] :> es
        , Log :> es
        , PackageStore :> es
        )
-    => [SourceQuery]
+    => SourceOptions
     -> Eff es ()
-showSource queries = do
-    results <- mapM lookupModuleSource queries
+showSource opts = do
+    dirs <- projectSourceDirs <$> input
+    results <- mapM (lookupModuleSource dirs limits) opts.queries
     renderSourceResults results
+  where
+    limits =
+        ReexportLimits
+            { maxDepth = fromMaybe defaultReexportLimits.maxDepth opts.maxDepth
+            , maxModules = fromMaybe defaultReexportLimits.maxModules opts.maxModules
+            }
 
 
 showEvalComments
diff --git a/src/Tricorder/CLI/Render.hs b/src/Tricorder/CLI/Render.hs
--- a/src/Tricorder/CLI/Render.hs
+++ b/src/Tricorder/CLI/Render.hs
@@ -59,11 +59,16 @@
         when (length results > 1) $ Console.putTextLn $ header query
         Console.putText src
         when (length results > 1) $ Console.putStrLn ""
+    renderOne (SourceReexported query definedIn src) = do
+        when (length results > 1) $ Console.putTextLn $ header query
+        Console.putTextLn $ "-- Re-exported from " <> unModuleName definedIn
+        Console.putText src
+        when (length results > 1) $ Console.putStrLn ""
     renderOne (SourceNotFound query) =
         Console.putTextLn
             $ "Not found: "
                 <> unModuleName query.moduleName
-                <> " (module not in any installed package)"
+                <> " (module not in the project or any installed package)"
     renderOne (SourceUnavailable query pkgId) =
         Console.putTextLn
             $ "No source available: "
diff --git a/src/Tricorder/CLI/UI.hs b/src/Tricorder/CLI/UI.hs
--- a/src/Tricorder/CLI/UI.hs
+++ b/src/Tricorder/CLI/UI.hs
@@ -7,11 +7,12 @@
 import Atelier.Effects.Conc (Conc)
 import Atelier.Effects.Console (Console)
 import Atelier.Effects.Delay (Delay)
+import Atelier.Effects.Exit (Exit)
 import Atelier.Effects.File (File)
 import Atelier.Effects.Process (Process, getExecutablePath, proc, runProcess)
-import Brick (App (..), neverShowCursor)
+import Brick (App (..), EventM, neverShowCursor)
 import Brick.BChan (BChan, writeBChanNonBlocking)
-import Brick.Keybindings (KeyConfig)
+import Brick.Keybindings (KeyConfig, KeyDispatcher)
 import Effectful.Concurrent (Concurrent)
 import Effectful.Concurrent.STM (TVar, atomically, newTVarIO, readTVarIO, writeTVar)
 import Effectful.Exception (bracket_, trySync)
@@ -23,7 +24,7 @@
 import Tricorder.CLI.UI.Brick (Brick)
 import Tricorder.CLI.UI.BrickChan (BrickChan)
 import Tricorder.CLI.UI.Event (Event (..), handleEvent)
-import Tricorder.CLI.UI.Keys (KeyEvent, dispatcher)
+import Tricorder.CLI.UI.Keys (KeyEvent, mkDispatcher)
 import Tricorder.CLI.UI.State (State (..), Viewports (..))
 import Tricorder.CLI.UI.View (mkAttrMap, view)
 import Tricorder.Runtime (SocketPath (..))
@@ -47,6 +48,7 @@
        , Concurrent :> es
        , Console :> es
        , Delay :> es
+       , Exit :> es
        , File :> es
        , Process :> es
        , Reader Keys.Config :> es
@@ -71,10 +73,11 @@
         _ <- Conc.fork $ restartWorker restartChan restarting
         keyConfig <- Keys.mkKeyConfig
         let requestRestart = void $ writeBChanNonBlocking restartChan ()
+        dispatcher <- mkDispatcher requestRestart keyConfig
         void
             $ Brick.runBrickApp
                 chan
-                (watchApp requestRestart keyConfig)
+                (watchApp keyConfig dispatcher)
                 initialState
 
 
@@ -109,12 +112,15 @@
             void waitForDaemon
 
 
-watchApp :: IO () -> KeyConfig KeyEvent -> App State Event Viewports
-watchApp requestRestart kc =
+watchApp
+    :: KeyConfig KeyEvent
+    -> KeyDispatcher KeyEvent (EventM Viewports State)
+    -> App State Event Viewports
+watchApp keyConfig dispatcher =
     App
-        { appDraw = view kc
-        , appHandleEvent = handleEvent $ dispatcher requestRestart kc
+        { appDraw = view keyConfig dispatcher
+        , appChooseCursor = neverShowCursor
+        , appHandleEvent = handleEvent dispatcher
         , appStartEvent = pure ()
         , appAttrMap = mkAttrMap
-        , appChooseCursor = neverShowCursor
         }
diff --git a/src/Tricorder/CLI/UI/Keys.hs b/src/Tricorder/CLI/UI/Keys.hs
--- a/src/Tricorder/CLI/UI/Keys.hs
+++ b/src/Tricorder/CLI/UI/Keys.hs
@@ -1,15 +1,15 @@
 module Tricorder.CLI.UI.Keys
-    ( KeyEvent
+    ( KeyEvent (..)
     , Config
     , keys
-    , dispatcher
+    , mkDispatcher
     , viewKeybindings
     , mkKeyConfig
-    , keybindForRoute
     )
 where
 
 import Atelier.Effects.Console (Console)
+import Atelier.Effects.Exit (Exit, exitFailure)
 import Brick
     ( EventM
     , Widget
@@ -39,16 +39,13 @@
     , onEvent
     , parseBindingList
     )
-import Brick.Keybindings.KeyConfig (firstActiveBinding)
 import Brick.Keybindings.Pretty (ppBinding)
 import Brick.Widgets.Core (hBox)
 import Control.Monad.State (gets, modify)
 import Data.Aeson (FromJSON (..))
 import Data.Default (Default (..))
-import Effectful.Exception (throwIO)
 import Effectful.Reader.Static (Reader, ask)
 import Graphics.Vty (Key (..))
-import System.IO.Error (userError)
 import Text.Casing (quietSnake)
 
 import Atelier.Effects.Console qualified as Console
@@ -57,14 +54,12 @@
 import Data.Text qualified as T
 
 import Tricorder.CLI.UI.Misc (warn)
-import Tricorder.CLI.UI.Route (Route)
 import Tricorder.CLI.UI.State
     ( Processed (Waiting)
     , State (..)
     , Viewports (..)
     , currentRoute
     , cycleTestFilter
-    , navigate
     , viewToViewport
     )
 
@@ -77,11 +72,11 @@
 -- 'KeyEvent', update that list to match — @tagref check@ flags the dangling
 -- reference if this tag is renamed or dropped without touching the docs.
 data KeyEvent
-    = ToggleHelp
+    = SwitchTabPrev
+    | SwitchTabNext
+    | ToggleHelp
     | CycleTestView
-    | ToggleEvalComments
     | RestartDaemon
-    | ExitView
     | ScrollUp
     | ScrollDown
     | Quit
@@ -100,37 +95,42 @@
 textToKeyEvent = (`Map.lookup` keyEventTextMap)
 
 
+eventToDesc :: KeyEvent -> Text
+eventToDesc = \case
+    ToggleHelp -> "toggle help"
+    SwitchTabNext -> "switch to next tab"
+    SwitchTabPrev -> "switch to previous tab"
+    CycleTestView -> "cycle test tab views"
+    RestartDaemon -> "restart daemon"
+    ScrollUp -> "scroll up"
+    ScrollDown -> "scroll down"
+    Quit -> "quit"
+
+
 keys :: KeyEvents KeyEvent
-keys =
-    keyEvents
-        [ ("toggle help", ToggleHelp)
-        , ("cycle test view", CycleTestView)
-        , ("toggle eval comments", ToggleEvalComments)
-        , ("restart daemon", RestartDaemon)
-        , ("exit view", ExitView)
-        , ("scroll up", ScrollUp)
-        , ("scroll down", ScrollDown)
-        , ("quit", Quit)
-        ]
+keys = keyEvents $ (\e -> (eventToDesc e, e)) <$> universe
 
 
-bindings :: [(KeyEvent, [Binding])]
-bindings =
-    [ (ToggleHelp, [bind 'h'])
-    , (CycleTestView, [bind 't'])
-    , (ToggleEvalComments, [bind 'e'])
-    , (RestartDaemon, [bind 'R'])
-    , (ExitView, [binding KEsc []])
-    , (ScrollUp, [binding KUp []])
-    , (ScrollDown, [binding KDown []])
-    , (Quit, [bind 'q', ctrl 'c'])
-    ]
+eventToBinding :: KeyEvent -> [Binding]
+eventToBinding = \case
+    ToggleHelp -> [bind '?']
+    SwitchTabPrev -> [bind 'h', binding KLeft []]
+    SwitchTabNext -> [bind 'l', binding KRight []]
+    CycleTestView -> [bind 't']
+    RestartDaemon -> [bind 'R']
+    ScrollUp -> [binding KUp []]
+    ScrollDown -> [binding KDown []]
+    Quit -> [bind 'q', ctrl 'c', binding KEsc []]
 
 
-mkKeyConfig :: (Console :> es, Reader Config :> es) => Eff es (KeyConfig KeyEvent)
+defaultBindings :: [(KeyEvent, [Binding])]
+defaultBindings = (\e -> (e, eventToBinding e)) <$> universe
+
+
+mkKeyConfig :: (Console :> es, Exit :> es, Reader Config :> es) => Eff es (KeyConfig KeyEvent)
 mkKeyConfig = do
     customBindings <- parseCustomBindings
-    pure $ newKeyConfig keys bindings customBindings
+    pure $ newKeyConfig keys defaultBindings customBindings
 
 
 newtype Config = Config (Map Text Text)
@@ -144,6 +144,7 @@
 
 parseCustomBindings
     :: ( Console :> es
+       , Exit :> es
        , Reader Config :> es
        )
     => Eff es [(KeyEvent, BindingState)]
@@ -153,7 +154,7 @@
     unless (null errors) do
         Console.putTextLn "Error(s) encountered when attempting to parse key bindings:"
         traverse_ (Console.putTextLn . toText) errors
-        throwIO $ userError "Malformed keybindings"
+        exitFailure
     pure customBindings
 
 
@@ -169,62 +170,75 @@
 parseKeyEvent ev = maybeToRight ("Unrecognized key event: " <> ev) $ textToKeyEvent ev
 
 
+eventToHandler :: IO () -> KeyEvent -> KeyEventHandler KeyEvent (EventM Viewports State)
+eventToHandler requestRestart = \case
+    ToggleHelp -> onEvent ToggleHelp "Toggle help" do
+        modify \s -> s {showHelp = not s.showHelp}
+    SwitchTabNext -> onEvent SwitchTabNext "Switch to next tab" do
+        modify \s ->
+            s
+                { route =
+                    if s.route == maxBound
+                        then minBound
+                        else succ s.route
+                }
+    SwitchTabPrev -> onEvent SwitchTabPrev "Switch to previous tab" do
+        modify \s ->
+            s
+                { route =
+                    if s.route == minBound
+                        then maxBound
+                        else pred s.route
+                }
+    CycleTestView -> onEvent CycleTestView "Cycle between test tab views" do
+        modify \s ->
+            if
+                | s.route /= Route.Tests -> s
+                | otherwise -> s {testFilter = cycleTestFilter s.testFilter}
+    RestartDaemon -> onEvent RestartDaemon "Restart the daemon" do
+        liftIO requestRestart
+        modify \s -> s {buildState = Waiting}
+    ScrollUp -> onEvent ScrollUp "Scroll up" do
+        mvp <- gets (viewToViewport . currentRoute)
+        case mvp of
+            Just vp -> vScrollBy (viewportScroll vp) (-1)
+            Nothing -> pure ()
+    ScrollDown -> onEvent ScrollDown "Scroll down" do
+        mvp <- gets (viewToViewport . currentRoute)
+        case mvp of
+            Just vp ->
+                vScrollBy (viewportScroll vp) 1
+            Nothing -> pure ()
+    Quit -> onEvent Quit "Exit" do
+        halt
+
+
 -- | Build the key dispatcher. @requestRestart@ is run (in 'IO') when the restart
 -- key is pressed; it hands the request off to the worker that owns the daemon
 -- control effects, since brick's 'EventM' cannot run them directly.
-dispatcher :: IO () -> KeyConfig KeyEvent -> KeyDispatcher KeyEvent (EventM Viewports State)
-dispatcher requestRestart cfg =
-    -- TODO: Handle this error more gracefully.
-    either (error . ("Invalid key dispatcher config: " <>) . stringify) id
-        $ keyDispatcher
-            cfg
-            [ onEvent ToggleHelp "Toggle help" do
-                modify \s ->
-                    if currentRoute s == Route.Help
-                        then
-                            navigate Route.Main s
-                        else
-                            navigate Route.Help s
-            , onEvent CycleTestView "Cycle test results view" do
-                modify \s -> case currentRoute s of
-                    Route.Tests ->
-                        if s.testFilter == maxBound
-                            then
-                                navigate Route.Main s {testFilter = minBound}
-                            else
-                                s {testFilter = cycleTestFilter s.testFilter}
-                    _ -> navigate Route.Tests s
-            , onEvent ToggleEvalComments "Toggle eval comments view" do
-                modify \s ->
-                    if currentRoute s == Route.Evals
-                        then
-                            navigate Route.Main s
-                        else
-                            navigate Route.Evals s
-            , onEvent RestartDaemon "Restart the daemon" do
-                liftIO requestRestart
-                modify \s -> s {buildState = Waiting}
-            , onEvent ExitView "Exit or go back" do
-                gets (.route) >>= \case
-                    Route.Main -> halt
-                    _ -> modify $ navigate Route.Main
-            , onEvent ScrollUp "Scroll up" do
-                mvp <- gets (viewToViewport . currentRoute)
-                case mvp of
-                    Just vp -> vScrollBy (viewportScroll vp) (-1)
-                    Nothing -> pure ()
-            , onEvent ScrollDown "Scroll down" do
-                mvp <- gets (viewToViewport . currentRoute)
-                case mvp of
-                    Just vp ->
-                        vScrollBy (viewportScroll vp) 1
-                    Nothing -> pure ()
-            , onEvent Quit "Exit" do
-                halt
-            ]
+mkDispatcher
+    :: (Console :> es, Exit :> es)
+    => IO ()
+    -> KeyConfig KeyEvent
+    -> Eff es (KeyDispatcher KeyEvent (EventM Viewports State))
+mkDispatcher requestRestart cfg =
+    case keyDispatcher cfg $ eventToHandler requestRestart <$> universe of
+        Left collisions -> do
+            Console.putTextLn
+                $ T.intercalate "\n\n"
+                $ "Your key bindings have collisions:"
+                    : (uncurry showCollision <$> collisions)
+            exitFailure
+        Right dispatcher -> pure dispatcher
   where
-    stringify =
-        show . fmap (second $ fmap $ handlerDescription . kehHandler . khHandler)
+    showCollision binding' handlers =
+        T.intercalate "\n"
+            $ ("Key binding: " <> ppBinding binding')
+                : (showCollidingHandler <$> handlers)
+    showCollidingHandler handler =
+        "  " <> case handler.khHandler.kehEventTrigger of
+            ByEvent ev -> toText $ quietSnake $ show ev
+            ByKey k -> show k
 
 
 viewKeybindings :: (Ord k, Show k) => KeyConfig k -> [KeyEventHandler k m] -> Widget n
@@ -248,11 +262,3 @@
     getBindings = \case
         ByKey k -> Set.singleton k
         ByEvent e -> Set.fromList $ allActiveBindings kc e
-
-
-keybindForRoute :: KeyConfig KeyEvent -> Route -> Maybe Binding
-keybindForRoute kc = \case
-    Route.Main -> Nothing
-    Route.Help -> firstActiveBinding kc ToggleHelp
-    Route.Tests -> firstActiveBinding kc CycleTestView
-    Route.Evals -> firstActiveBinding kc ToggleEvalComments
diff --git a/src/Tricorder/CLI/UI/Route.hs b/src/Tricorder/CLI/UI/Route.hs
--- a/src/Tricorder/CLI/UI/Route.hs
+++ b/src/Tricorder/CLI/UI/Route.hs
@@ -7,7 +7,6 @@
 
 data Route
     = Main
-    | Help
     | Tests
     | Evals
     deriving stock (Bounded, Enum, Eq)
@@ -16,6 +15,5 @@
 name :: Route -> Text
 name = \case
     Main -> "Dashboard"
-    Help -> "Help"
     Tests -> "Tests"
     Evals -> "Eval comments"
diff --git a/src/Tricorder/CLI/UI/State.hs b/src/Tricorder/CLI/UI/State.hs
--- a/src/Tricorder/CLI/UI/State.hs
+++ b/src/Tricorder/CLI/UI/State.hs
@@ -35,6 +35,7 @@
     , timeZone :: TimeZone
     , route :: Route
     , testFilter :: TestFilter
+    , showHelp :: Bool
     }
 
 
@@ -76,4 +77,5 @@
             , timeZone = tz
             , route = Route.Main
             , testFilter = minBound
+            , showHelp = False
             }
diff --git a/src/Tricorder/CLI/UI/View.hs b/src/Tricorder/CLI/UI/View.hs
--- a/src/Tricorder/CLI/UI/View.hs
+++ b/src/Tricorder/CLI/UI/View.hs
@@ -12,7 +12,7 @@
     , vBox
     , viewport
     )
-import Brick.Keybindings (KeyConfig, KeyHandler (..), keyDispatcherToList, ppBinding)
+import Brick.Keybindings (KeyConfig, KeyDispatcher, KeyHandler (..), keyDispatcherToList, ppBinding)
 import Brick.Widgets.Core
     ( Padding (..)
     , emptyWidget
@@ -27,6 +27,7 @@
     )
 import Data.Time (UTCTime, defaultTimeLocale, formatTime, utcToLocalTime)
 
+import Brick.Keybindings.KeyConfig qualified as KeyConfig
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 import Graphics.Vty.Attributes qualified as Attr
@@ -34,7 +35,7 @@
 
 import Tricorder.Build (BuildPhase, BuildResult, BuildState, Diagnostic, Severity (..))
 import Tricorder.Build.Duration (Duration (..))
-import Tricorder.CLI.UI.Keys (KeyEvent, keybindForRoute, viewKeybindings)
+import Tricorder.CLI.UI.Keys (KeyEvent, viewKeybindings)
 import Tricorder.CLI.UI.Misc (emphasis, err, hBoxSpaced, ok, subtle, vBoxSpaced, warn)
 import Tricorder.CLI.UI.Route (Route)
 import Tricorder.CLI.UI.State
@@ -69,41 +70,35 @@
         )
 
 
-view :: KeyConfig KeyEvent -> State -> [Widget Viewports]
-view kc ws =
+view :: KeyConfig KeyEvent -> KeyDispatcher KeyEvent m -> State -> [Widget Viewports]
+view kc dispatcher ws =
     [ vBoxSpaced
         1
         [ vBox
-            [ viewAppHeader ws
-            , viewTabs kc ws
+            [ viewAppHeader kc ws
+            , viewTabs ws
             ]
-        , case currentRoute ws of
-            Route.Help ->
-                viewHelp kc
-            Route.Tests ->
-                viewTests ws
-            Route.Main ->
-                viewMain ws
-            Route.Evals ->
-                viewEvals ws
+        , if ws.showHelp
+            then viewHelp kc dispatcher
+            else case currentRoute ws of
+                Route.Tests -> viewTests ws
+                Route.Main -> viewMain ws
+                Route.Evals -> viewEvals ws
         ]
     ]
 
 
-viewTabs :: KeyConfig KeyEvent -> State -> Widget n
-viewTabs kc ws =
-    hBoxSpaced 1
-        $ intersperse (subtle $ txt "-")
-        $ viewRouteTab kc ws <$> universe @Route
+viewTabs :: State -> Widget n
+viewTabs ws =
+    hBoxSpaced 1 $ viewRouteTab ws <$> universe @Route
 
 
-viewRouteTab :: KeyConfig KeyEvent -> State -> Route -> Widget n
-viewRouteTab kc ws route =
-    style $ txt $ Route.name route <> keyBind
+viewRouteTab :: State -> Route -> Widget n
+viewRouteTab ws route =
+    style $ txt $ brackets $ Route.name route
   where
+    brackets = if route == currentRoute ws then ("< " <>) . (<> " >") else ("  " <>) . (<> "  ")
     style = if route == currentRoute ws then id else subtle
-    showBinding = (" " <>) . ("[" <>) . (<> "]") . ppBinding
-    keyBind = maybe "" showBinding $ keybindForRoute kc route
 
 
 viewTests :: State -> Widget Viewports
@@ -118,12 +113,12 @@
 viewMain ws = withBuildState ws (viewDefaultPanel ws.timeZone)
 
 
-viewHelp :: KeyConfig KeyEvent -> Widget n
-viewHelp kc = viewKeybindings kc handlers
+viewHelp :: KeyConfig KeyEvent -> KeyDispatcher KeyEvent m -> Widget n
+viewHelp kc dispatcher = viewKeybindings kc handlers
   where
     -- The dispatcher is built only to enumerate handler descriptions for the help
     -- view, so the restart action is a no-op here.
-    handlers = (.khHandler) . snd <$> keyDispatcherToList (Keys.dispatcher (pure ()) kc)
+    handlers = (.khHandler) . snd <$> keyDispatcherToList dispatcher
 
 
 withBuildState :: State -> (BuildState -> Widget Viewports) -> Widget Viewports
@@ -137,26 +132,49 @@
             render bs
 
 
-viewAppHeader :: State -> Widget n
-viewAppHeader ws =
-    ok
-        $ emphasis
-        $ txt
-        $ "Tricorder"
-            <> maybe
-                ""
-                (" - " <>)
-                (viewHeading ws)
+viewAppHeader :: KeyConfig KeyEvent -> State -> Widget n
+viewAppHeader kc ws =
+    hBoxSpaced
+        1
+        [ ok
+            $ emphasis
+            $ txt
+            $ "Tricorder"
+                <> maybe
+                    ""
+                    (" - " <>)
+                    (viewHeading ws)
+        , viewHelpHint kc ws
+        ]
 
 
+viewHelpHint :: KeyConfig KeyEvent -> State -> Widget n
+viewHelpHint kc state
+    | state.showHelp = subtle $ txt $ "Press " <> exitHelpStr <> " to go back"
+    | state.route == Route.Tests =
+        subtle $ txt $ "Press " <> cycleTestFilterStr <> " to cycle between filters"
+    | otherwise = subtle $ txt $ "Press " <> showHelpStr <> " for help"
+  where
+    exitHelpStr = showHint Keys.ToggleHelp
+    showHelpStr = showHint Keys.ToggleHelp
+    cycleTestFilterStr = showHint Keys.CycleTestView
+    showHint = joinWithAndOr "or" . fmap (quote . ppBinding) . KeyConfig.allActiveBindings kc
+    quote = (<> "'") . ("'" <>)
+    joinWithAndOr andOr xs = case splitAt (length xs - 1) xs of
+        ([], [post]) -> post
+        ([pre], []) -> pre
+        (pre, post) -> T.intercalate ", " pre <> " " <> andOr <> " " <> T.intercalate ", " post
+
+
 viewHeading :: State -> Maybe Text
-viewHeading ws = case currentRoute ws of
-    Route.Tests -> case ws.testFilter of
-        TestFilterAll -> Just "Tests"
-        TestFilterFailedOnly -> Just "Tests - Failed only"
-    Route.Help -> Just "Help"
-    Route.Main -> Nothing
-    Route.Evals -> Just "Eval comments"
+viewHeading ws
+    | ws.showHelp = Just "Help"
+    | otherwise = case currentRoute ws of
+        Route.Tests -> case ws.testFilter of
+            TestFilterAll -> Just "Tests"
+            TestFilterFailedOnly -> Just "Tests - Failed only"
+        Route.Main -> Nothing
+        Route.Evals -> Just "Eval comments"
 
 
 viewDefaultPanel :: TimeZone -> BuildState -> Widget Viewports
diff --git a/src/Tricorder/Config.hs b/src/Tricorder/Config.hs
--- a/src/Tricorder/Config.hs
+++ b/src/Tricorder/Config.hs
@@ -1,62 +1,44 @@
 module Tricorder.Config
-    ( LoadedConfig (..)
-    , runLoadedConfig
-    , inputLoadedConfig
-    , configFileName
+    ( Config (..)
+    , asInput
     )
 where
 
-import Atelier.Config (LoadedConfig (..))
-import Atelier.Effects.FileSystem (FileSystem)
-import Atelier.Effects.Input (Input, runInputEff)
-import Effectful.Reader.Static (Reader, ask, runReader)
-import System.FilePath ((</>))
+import Atelier.Effects.Input (Input)
+import Atelier.Types.JSON.QuietSnake (QuietSnake (..))
+import Atelier.Types.WithDefaults (WithDefaults (..))
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Default (Default (..))
 
-import Atelier.Effects.FileSystem qualified as FileSystem
+import Atelier.Effects.Input qualified as Input
 import Data.Aeson qualified as Aeson
-import Data.Aeson.KeyMap qualified as KM
 import Data.Yaml qualified as Yaml
 
-import Tricorder.Runtime (ProjectRoot (..))
-
+import Tricorder.Config.LoadedConfig (LoadedConfig (..))
 
--- | Load config from .tricorder.yaml in the project root.
--- Falls back to empty config (all defaults) if the file is absent or cannot be parsed.
-loadTricorderConfig :: (FileSystem :> es) => FilePath -> Eff es LoadedConfig
-loadTricorderConfig projectRoot = do
-    exists <- FileSystem.doesFileExist yamlPath
-    if not exists
-        then
-            pure $ LoadedConfig (Aeson.Object KM.empty)
-        else do
-            bs <- FileSystem.readFileBs yamlPath
-            pure . LoadedConfig $ case Yaml.decodeEither' @Aeson.Value bs of
-                Left _ -> Aeson.Object KM.empty
-                Right v -> v
-  where
-    yamlPath = projectRoot </> configFileName
+import Tricorder.Session.Config qualified as Session (Config)
 
 
-configFileName :: FilePath
-configFileName = ".tricorder.yaml"
+data Config = Config
+    { session :: Maybe Session.Config
+    }
+    deriving stock (Eq, Generic, Show)
+    deriving (ToJSON) via QuietSnake Config
+    deriving (FromJSON) via WithDefaults (QuietSnake Config)
 
 
-runLoadedConfig
-    :: ( FileSystem :> es
-       , Reader ProjectRoot :> es
-       )
-    => Eff (Reader LoadedConfig : es) a -> Eff es a
-runLoadedConfig act = do
-    ProjectRoot projectRoot <- ask
-    cfg <- loadTricorderConfig projectRoot
-    runReader cfg act
+instance Default Config where
+    def =
+        Config
+            { session = def
+            }
 
 
-inputLoadedConfig
-    :: ( FileSystem :> es
-       , Reader ProjectRoot :> es
-       )
-    => Eff (Input LoadedConfig : es) a -> Eff es a
-inputLoadedConfig = runInputEff do
-    ProjectRoot projectRoot <- ask
-    loadTricorderConfig projectRoot
+asInput
+    :: (Input LoadedConfig :> es)
+    => Eff (Input (Either Text Config) : es) a -> Eff es a
+asInput = Input.withEff do
+    LoadedConfig bs <- Input.input
+    case Yaml.decodeEither' bs of
+        Right Aeson.Null -> pure $ Right def
+        _ -> pure $ first (toText . Yaml.prettyPrintParseException) $ Yaml.decodeEither' bs
diff --git a/src/Tricorder/Config/LoadedConfig.hs b/src/Tricorder/Config/LoadedConfig.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Config/LoadedConfig.hs
@@ -0,0 +1,92 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+
+module Tricorder.Config.LoadedConfig
+    ( LoadedConfig (..)
+    , asInput
+    , asNestedInput
+    , decodeNestedValue
+    , configFileName
+    )
+where
+
+import Atelier.Effects.FileSystem (FileSystem)
+import Atelier.Effects.Input (Input)
+import Data.Aeson (FromJSON)
+import Effectful.Reader.Static (Reader)
+import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)
+import System.FilePath ((</>))
+
+import Atelier.Effects.FileSystem qualified as FileSystem
+import Atelier.Effects.Input qualified as Input
+import Data.Aeson qualified as Aeson
+import Data.Aeson.KeyMap qualified as KM
+import Data.List qualified as List
+import Data.Text qualified as T
+import Data.Yaml qualified as Yaml
+import Effectful.Reader.Static qualified as Reader
+
+import Tricorder.Runtime (ProjectRoot (..))
+
+
+newtype LoadedConfig = LoadedConfig {getLoadedConfig :: ByteString}
+
+
+asInput
+    :: (FileSystem :> es, Reader ProjectRoot :> es)
+    => Eff (Input LoadedConfig : es) a -> Eff es a
+asInput = Input.withEff do
+    ProjectRoot projectRoot <- Reader.ask
+    let configFilePath = projectRoot </> configFileName
+    exists <- FileSystem.doesFileExist configFilePath
+    if exists
+        then do
+            bs <- FileSystem.readFileBs configFilePath
+            pure $ LoadedConfig bs
+        else pure $ LoadedConfig ""
+
+
+asNestedInput
+    :: forall (key :: Symbol) r es a
+     . ( FromJSON r
+       , Input LoadedConfig :> es
+       , KnownSymbol key
+       )
+    => Eff (Input (Either Text r) : es) a -> Eff es a
+asNestedInput = Input.withEff do
+    loadedConfig <- Input.input
+    pure $ decodeNestedValue @key loadedConfig
+
+
+configFileName :: FilePath
+configFileName = ".tricorder.yaml"
+
+
+decodeNestedValue
+    :: forall (key :: Symbol) r. (FromJSON r, KnownSymbol key) => LoadedConfig -> Either Text r
+decodeNestedValue (LoadedConfig root) =
+    go [] props
+        =<< first
+            (toText . Yaml.prettyPrintParseException)
+            (Yaml.decodeEither' root)
+  where
+    props = T.splitOn "." $ toText $ symbolVal $ Proxy @key
+
+    go :: [Text] -> [Text] -> Aeson.Value -> Either Text r
+    go prev (p : ps) (Aeson.Object m) =
+        maybe
+            (Left $ "missing property '" <> p <> "' at " <> formatPrev prev)
+            (go (p : prev) ps)
+            $ KM.lookup (fromString $ toString p) m
+    go prev (_ : _) _ =
+        Left $ "expected property to be an object: " <> formatPrev prev
+    go prev [] x =
+        first (\e -> "parse error for property " <> formatPrev prev <> ": " <> e)
+            $ fromJSON x
+
+    fromJSON x = case Aeson.fromJSON x of
+        Aeson.Success a -> Right a
+        Aeson.Error err -> Left $ toText err
+
+    formatPrev prev
+        | List.null prev = "<root object>"
+        | otherwise = T.intercalate "." $ List.reverse prev
diff --git a/src/Tricorder/Daemon/Builder.hs b/src/Tricorder/Daemon/Builder.hs
--- a/src/Tricorder/Daemon/Builder.hs
+++ b/src/Tricorder/Daemon/Builder.hs
@@ -13,7 +13,7 @@
 import Atelier.Effects.Clock (Clock, UTCTime)
 import Atelier.Effects.FileWatcher (FileEvent)
 import Atelier.Effects.Log (Log)
-import Atelier.Effects.Publishing.Pub (Pub)
+import Atelier.Effects.PubSub.Pub (Pub)
 import Atelier.Time (nominalDiffTime)
 import Data.Time (diffUTCTime)
 import Effectful (Effect, inject)
@@ -26,7 +26,7 @@
 
 import Atelier.Effects.Clock qualified as Clock
 import Atelier.Effects.Log qualified as Log
-import Atelier.Effects.Publishing.Pub qualified as Pub
+import Atelier.Effects.PubSub.Pub qualified as Pub
 import Data.Map.Strict qualified as Map
 import Data.Set qualified as Set
 import Effectful.State.Static.Shared qualified as State
@@ -44,13 +44,16 @@
     , mergeDiagnostics
     , preserveFailureVisibility
     )
-import Tricorder.Daemon.GhciSession (GhciSession, LoadResult (..))
-import Tricorder.Daemon.GhciSession.GhciParser (resolveKnownTargets)
 import Tricorder.Runtime (ProjectRoot (..))
 import Tricorder.Session.Command.RenderedCommand (RenderedCommand)
 import Tricorder.Session.WatchDirs (WatchDirs)
 
-import Tricorder.Daemon.GhciSession qualified as GhciSession
+import Tricorder.Daemon.Ghci.Controls qualified as Controls
+import Tricorder.Daemon.Ghci.Controls qualified as Ghci (Controls)
+import Tricorder.Daemon.Ghci.LoadResult qualified as Ghci (LoadResult)
+import Tricorder.Daemon.Ghci.LoadResult qualified as Ghci.LoadResult
+import Tricorder.Daemon.Ghci.Session qualified as Ghci (Session)
+import Tricorder.Daemon.Ghci.Session qualified as Ghci.Session
 import Tricorder.Session.Stage qualified as Stage
 
 
@@ -72,7 +75,7 @@
 data NewLoadResult = NewLoadResult
     { startTime :: UTCTime
     , endTime :: UTCTime
-    , loadResult :: LoadResult
+    , loadResult :: Ghci.LoadResult
     }
     deriving stock (Eq, Show)
 
@@ -82,7 +85,7 @@
 
 with
     :: ( Clock :> es
-       , GhciSession :> es
+       , Ghci.Session :> es
        , Log :> es
        , Pub BuildProgress :> es
        , Reader ProjectRoot :> es
@@ -95,7 +98,7 @@
 with buildId command watchDirs action = do
     root <- ask
     startTime <- Clock.currentTime
-    trySync $ GhciSession.withGhciWith Pub.publish command root \initialLoad controls -> do
+    trySync $ Ghci.Session.with Pub.publish command root \initialLoad controls -> do
         endTime <- Clock.currentTime
         let filteredMsgs = filterToWatchDirs root.getProjectRoot watchDirs initialLoad.diagnostics
         Log.info
@@ -109,7 +112,7 @@
         let initialLoadResult = NewLoadResult {startTime, endTime, loadResult = initialLoad}
         let initialBuilderState =
                 emptyBuilderState
-                    { loadedModules = resolveKnownTargets Map.empty initialLoad
+                    { loadedModules = Ghci.LoadResult.resolveKnownTargets Map.empty initialLoad
                     , knownTargets = KnownTargetNames (Set.fromList initialLoad.targetNames)
                     }
         reinterpretWith_
@@ -118,7 +121,7 @@
             \case
                 Consider fp event -> considerBuild fp event
                 Build dispatchAction ->
-                    buildSource (GhciSession.transformControls inject controls) dispatchAction
+                    buildSource (Controls.lift inject controls) dispatchAction
 
 
 considerBuild
@@ -139,7 +142,7 @@
 
 buildSource
     :: (Clock :> es, State BuilderState :> es)
-    => GhciSession.Controls (Eff es)
+    => Ghci.Controls (Eff es)
     -> DispatchAction
     -> Eff es (Either BuildFailure NewLoadResult)
 buildSource controls action = do
@@ -155,7 +158,7 @@
         Right (startTime, endTime, loadResult) -> do
             modify \s ->
                 s
-                    { loadedModules = resolveKnownTargets s.loadedModules loadResult
+                    { loadedModules = Ghci.LoadResult.resolveKnownTargets s.loadedModules loadResult
                     , knownTargets = KnownTargetNames (Set.fromList loadResult.targetNames)
                     }
             pure $ Right $ NewLoadResult {startTime, endTime, loadResult}
@@ -174,7 +177,7 @@
     merged = mergeDiagnostics diagnosticMap filteredResult
     filteredResult =
         loadResult
-            { GhciSession.diagnostics =
+            { Ghci.LoadResult.diagnostics =
                 preserveFailureVisibility loadResult.diagnostics
                     $ filterToWatchDirs projectRoot watchDirs loadResult.diagnostics
             }
@@ -187,7 +190,7 @@
             }
 
 
-runAction :: GhciSession.Controls (Eff es) -> DispatchAction -> Eff es LoadResult
+runAction :: Ghci.Controls (Eff es) -> DispatchAction -> Eff es Ghci.LoadResult
 runAction controls = \case
     Reload -> controls.reload
     Add fp -> controls.add fp
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
@@ -3,29 +3,22 @@
 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, input)
+import Atelier.Effects.FileWatcher (FileEvent)
 import Atelier.Effects.Log (Log)
 import Atelier.Effects.Process (Process)
-import Atelier.Effects.Publishing (runPubSub)
-import Atelier.Effects.Publishing.Pub (Pub)
-import Atelier.Effects.Publishing.Sub (Sub)
-import Data.List (isSuffixOf)
+import Atelier.Effects.PubSub.Pub (Pub)
+import Atelier.Effects.PubSub.Sub (Sub)
 import Effectful.Concurrent.MVar (newEmptyMVar, takeMVar, tryPutMVar)
 import Effectful.Concurrent.STM (Concurrent, atomically, newEmptyTMVar, takeTMVar, writeTMVar)
 import Effectful.Reader.Static (Reader)
 import Effectful.State.Static.Shared (State)
 import Relude.Extra.Tuple (dup)
-import System.FilePath ((</>))
 
 import Atelier.Effects.Conc qualified as Conc
-import Atelier.Effects.FileSystem qualified as FileSystem
-import Atelier.Effects.FileWatcher qualified as FileWatcher
 import Atelier.Effects.Log qualified as Log
-import Atelier.Effects.Publishing.Pub qualified as Pub
-import Atelier.Effects.Publishing.Sub qualified as Sub
+import Atelier.Effects.PubSub qualified as PubSub
+import Atelier.Effects.PubSub.Pub qualified as Pub
+import Atelier.Effects.PubSub.Sub qualified as Sub
 import Data.Map.Strict qualified as Map
 import Effectful.Reader.Static qualified as Reader
 import Effectful.State.Static.Shared qualified as State
@@ -42,20 +35,10 @@
     )
 import Tricorder.Daemon.Dispatch (BuilderState (..), DispatchAction, emptyBuilderState)
 import Tricorder.Daemon.EvalCommentRunner (EvalCommentRunner, findEvalCommentsInModules)
-import Tricorder.Daemon.GhciSession (GhciSession)
-import Tricorder.Daemon.GhciSession.GhciParser
-    ( LoadResult
-    , LoadedModule (..)
-    , resolveKnownTargets
-    )
-import Tricorder.Daemon.Hpack.Effect (Hpack)
+import Tricorder.Daemon.Ghci.LoadedModule (LoadedModule (..))
 import Tricorder.Daemon.TestRunner (TestRunner)
-import Tricorder.Daemon.Watch (WatchedFile)
 import Tricorder.Runtime (ProjectRoot (..))
 import Tricorder.Session (Session (..))
-import Tricorder.Session.GenerateWithHpack (GenerateWithHpack (..))
-import Tricorder.Session.IdleTimeout (IdleTimeout)
-import Tricorder.Session.Repl (Repl)
 import Tricorder.Session.Stage.Build.Session (BuildSession (..))
 import Tricorder.Session.Stage.Test.Command (RenderedTestCommand (..))
 import Tricorder.Session.Stage.Test.Session (TestSession (..))
@@ -65,14 +48,13 @@
 import Tricorder.Build qualified as Build
 import Tricorder.Build.EvalComment qualified as Eval
 import Tricorder.Build.Test qualified as Test
-import Tricorder.Config qualified as Config
 import Tricorder.Daemon.Builder qualified as Builder
 import Tricorder.Daemon.EvalCommentRunner qualified as EvalCommentRunner
-import Tricorder.Daemon.Hpack qualified as Hpack
+import Tricorder.Daemon.Ghci.LoadResult qualified as Ghci (LoadResult)
+import Tricorder.Daemon.Ghci.LoadResult qualified as Ghci.LoadResult
+import Tricorder.Daemon.Ghci.Session qualified as Ghci (Session)
 import Tricorder.Daemon.TestRunner qualified as TestRunner
-import Tricorder.Daemon.Watch qualified as Watch
-import Tricorder.Session qualified as Session
-import Tricorder.Session.CommandTemplate qualified as Command
+import Tricorder.Session.Hook qualified as Hook
 import Tricorder.Session.Hooks qualified as Hooks
 import Tricorder.Session.Stage.Build.Command qualified as BuildCommand
 import Tricorder.Session.Stage.Test.Command qualified as TestCommand
@@ -80,9 +62,6 @@
 import Tricorder.Waiters qualified as Waiters
 
 
-data ReloadSession = ReloadSession
-
-
 data RestartBuilder = RestartBuilder
 
 
@@ -96,138 +75,32 @@
        , Clock :> es
        , Conc :> es
        , Concurrent :> es
-       , Debounce FilePath :> es
        , EvalCommentRunner :> es
-       , FileSystem :> es
-       , FileWatcher :> es
-       , GhciSession :> es
-       , Hpack :> es
-       , Input Session :> es
+       , Ghci.Session :> es
        , Log :> es
        , Process :> es
        , Pub BuildPhase :> es
        , Reader ProjectRoot :> es
        , State BuildId :> es
-       , State IdleTimeout :> es
-       , State Repl :> es
-       , TestRunner :> es
-       , Waiters :> es
-       )
-    => Eff es Void
-main =
-    runPubSub @ReloadSession
-        . runPubSub @WatchedFile
-        . runPubSub @CabalChangeDetected
-        . runPubSub @SourceChangeDetected
-        . runPubSub @RestartBuilder
-        . runPubSub @ReloadBuilder
-        . Conc.restartableFork waitForReloadSession
-        $ do
-            Log.debug "Before session"
-            session <- input
-            Log.debug "After session"
-            withSession session
-  where
-    waitForReloadSession = Waiters.wait $ Sub.listenOnce_ @ReloadSession
-
-
-withSession
-    :: ( Chan :> es
-       , Clock :> es
-       , Conc :> es
-       , Concurrent :> es
-       , Debounce FilePath :> es
-       , EvalCommentRunner :> es
-       , FileSystem :> es
-       , FileWatcher :> es
-       , GhciSession :> es
-       , Hpack :> es
-       , Input Session :> es
-       , Log :> es
-       , Process :> es
-       , Pub BuildPhase :> es
-       , Pub CabalChangeDetected :> es
-       , Pub ReloadBuilder :> es
-       , Pub ReloadSession :> es
-       , Pub RestartBuilder :> es
-       , Pub SourceChangeDetected :> es
-       , Pub WatchedFile :> es
-       , Reader ProjectRoot :> es
-       , State BuildId :> es
-       , State IdleTimeout :> es
-       , State Repl :> es
        , Sub CabalChangeDetected :> es
-       , Sub ReloadBuilder :> es
-       , Sub RestartBuilder :> es
        , Sub SourceChangeDetected :> es
-       , Sub WatchedFile :> es
        , TestRunner :> es
        , Waiters :> es
        )
     => Session -> Eff es Void
-withSession session = do
-    root <- Reader.ask
-    logSession session
-
-    State.put session.buildSession.commandTemplate.repl
-    State.put session.idleTimeout
-    Conc.fork_ $ watchConfigFile root
-    conditionallyWatchStackYaml root
-
-    Conc.fork_ $ Watch.files root session
-    Conc.fork_ $ Sub.listen_ Watch.publishChange
-
-    Conc.fork_ $ Sub.listen_ \(CabalChangeDetected _ _) ->
-        ifM
-            (shouldReloadSession session)
-            (Pub.publish ReloadSession)
-            (Pub.publish RestartBuilder)
-
-    Conc.fork_ $ Sub.listen_ \(SourceChangeDetected fp event) ->
-        Pub.publish $ ReloadBuilder fp event
-
-    when (coerce session.generateWithHpack)
-        $ void
-        $ Conc.fork Hpack.main
-
-    State.evalState emptyBuilderState do
-        Conc.restartableFork (Waiters.wait $ Sub.listenOnce_ @RestartBuilder) do
+main session =
+    PubSub.run @ReloadBuilder
+        . PubSub.run @RestartBuilder
+        $ Conc.restartableFork (Waiters.wait $ Sub.listenOnce_ @RestartBuilder) do
             buildId <- State.state (\s -> (s, s + 1))
-            runBuilder buildId session
 
-
-shouldReloadSession :: (Input Session :> es) => Session -> Eff es Bool
-shouldReloadSession oldSession = do
-    newSession <- input
-    pure $ newSession /= oldSession
-
-
-watchConfigFile
-    :: ( Debounce FilePath :> es
-       , FileWatcher :> es
-       , Pub ReloadSession :> es
-       )
-    => ProjectRoot -> Eff es Void
-watchConfigFile root = do
-    FileWatcher.watchFilePathsDebounced
-        [FileWatcher.dirWhere root.getProjectRoot (Config.configFileName `isSuffixOf`)]
-        \_ _ -> Pub.publish ReloadSession
+            Conc.fork_ $ Sub.listen_ \(CabalChangeDetected _ _) ->
+                Pub.publish RestartBuilder
 
+            Conc.fork_ $ Sub.listen_ \(SourceChangeDetected path event) ->
+                Pub.publish $ ReloadBuilder path event
 
-conditionallyWatchStackYaml
-    :: ( Conc :> es
-       , Debounce FilePath :> es
-       , FileSystem :> es
-       , FileWatcher :> es
-       , Pub RestartBuilder :> es
-       )
-    => ProjectRoot -> Eff es ()
-conditionallyWatchStackYaml root = do
-    exists <- FileSystem.doesFileExist $ root.getProjectRoot </> "stack.yaml"
-    when exists do
-        Conc.fork_ $ FileWatcher.watchFilePathsDebounced
-            [FileWatcher.dirWhere root.getProjectRoot ("stack.yaml" `isSuffixOf`)]
-            \_ _ -> Pub.publish RestartBuilder
+            runBuilder buildId session
 
 
 -- | Starts the initial build with GHCi, and waits for source changes.
@@ -236,25 +109,24 @@
        , Conc :> es
        , Concurrent :> es
        , EvalCommentRunner :> es
-       , GhciSession :> es
+       , Ghci.Session :> es
        , Log.Log :> es
        , Process :> es
        , Pub BuildPhase :> es
        , Reader ProjectRoot :> es
-       , State BuilderState :> es
        , Sub ReloadBuilder :> es
        , TestRunner :> es
        , Waiters :> es
        )
     => BuildId -> Session -> Eff es ()
-runBuilder buildId session = do
+runBuilder buildId session = State.evalState emptyBuilderState do
     Log.info $ "Starting session " <> show buildId.getBuildId
     Pub.publish Build.Starting
-    whenJust (session.hooks.start >>= (.before)) Hooks.runHook
+    whenJust (session.hooks.start >>= (.before)) Hook.run
     startupError <- fmap (either id absurd)
         $ Pub.map (Build.Building session.testSession.targets)
         $ Builder.with buildId buildCommand session.watchDirs \_ initialLoad -> do
-            whenJust (session.hooks.start >>= (.after)) Hooks.runHook
+            whenJust (session.hooks.start >>= (.after)) Hook.run
             processPostBuild session $ Right initialLoad
             Log.debug "Waiting for reload"
             newestReloadEvent <- atomically newEmptyTMVar
@@ -272,7 +144,7 @@
                 Conc.restartableFork checkCancel
                     $ waitForReload session event
 
-    Pub.publish $ Build.Failed $ show startupError
+    Pub.publish $ Build.Failed $ toText $ displayException startupError
   where
     buildCommand = BuildCommand.render session.buildSession.commandTemplate session.buildSession.targets
 
@@ -320,9 +192,9 @@
     -> DispatchAction
     -> Eff es ()
 processSource session action = do
-    whenJust (session.hooks.reload >>= (.before)) Hooks.runHook
+    whenJust (session.hooks.reload >>= (.before)) Hook.run
     res <- Builder.build action
-    whenJust (session.hooks.reload >>= (.after)) Hooks.runHook
+    whenJust (session.hooks.reload >>= (.after)) Hook.run
     Log.debug "Finished build"
     processPostBuild session res
 
@@ -374,11 +246,12 @@
        , Pub Eval.Phase :> es
        , State BuilderState :> es
        )
-    => Session -> LoadResult -> Eff es Eval.Phase
+    => Session -> Ghci.LoadResult -> Eff es Eval.Phase
 runEvalComments session loadResult = do
     builderState <- State.get @BuilderState
     evalComments <-
-        findEvalCommentsInModules $ resolveKnownTargets builderState.loadedModules loadResult
+        findEvalCommentsInModules
+            $ Ghci.LoadResult.resolveKnownTargets builderState.loadedModules loadResult
 
     case nonEmpty evalComments of
         Nothing -> pure Eval.NoneFound
@@ -466,7 +339,3 @@
                     s.diagnosticMap
                     newLoadResult
         in  (buildResult, s {diagnosticMap = newDiagnosticMap})
-
-
-logSession :: (Log :> es) => Session -> Eff es ()
-logSession = Log.info . (<> "\n") . Session.show
diff --git a/src/Tricorder/Daemon/DaemonInfo.hs b/src/Tricorder/Daemon/DaemonInfo.hs
--- a/src/Tricorder/Daemon/DaemonInfo.hs
+++ b/src/Tricorder/Daemon/DaemonInfo.hs
@@ -5,12 +5,14 @@
     )
 where
 
-import Atelier.Effects.Input (Input, input, runInputEff)
+import Atelier.Effects.Input (Input)
 import Data.Aeson (FromJSON (..), ToJSON (..))
 import Effectful.Reader.Static (Reader, ask)
 import GHC.Generics (Generically (..))
 import System.FilePath (makeRelative)
 
+import Atelier.Effects.Input qualified as Input
+
 import Tricorder.Runtime (LogPath (..), ProjectRoot (..), SocketPath (..))
 import Tricorder.Session (Session (..))
 import Tricorder.Session.Stage.Build.Session (BuildSession (..))
@@ -36,7 +38,7 @@
        )
     => Eff es DaemonInfo
 load = do
-    session <- input
+    session <- Input.input
     ProjectRoot projectRoot <- ask
     SocketPath sockPath <- ask
     LogPath logFile <- ask
@@ -56,4 +58,4 @@
        , Reader SocketPath :> es
        )
     => Eff (Input DaemonInfo : es) a -> Eff es a
-runInput = runInputEff load
+runInput = Input.withEff load
diff --git a/src/Tricorder/Daemon/Dispatch.hs b/src/Tricorder/Daemon/Dispatch.hs
--- a/src/Tricorder/Daemon/Dispatch.hs
+++ b/src/Tricorder/Daemon/Dispatch.hs
@@ -20,22 +20,21 @@
 import Data.Set qualified as Set
 
 import Tricorder.Build (Diagnostic (..), Severity (..))
-import Tricorder.Daemon.GhciSession.GhciParser
-    ( LoadResult (..)
-    , LoadedModule (..)
-    , isLocationLess
-    , pathSuffixesAsModuleName
-    , unattributedFailure
-    )
 import Tricorder.Session.WatchDirs (WatchDirs (..))
 
+import Tricorder.Daemon.Ghci.Diagnostic qualified as Ghci.Diagnostic
+import Tricorder.Daemon.Ghci.LoadResult qualified as Ghci
+import Tricorder.Daemon.Ghci.LoadedModule qualified as Ghci (LoadedModule)
+import Tricorder.Daemon.Ghci.LoadedModule qualified as Ghci.LoadedModule
+import Tricorder.Daemon.Ghci.Path qualified as Ghci.Path
 
+
 -- | The Builder's per-GHCi-session cache: what it last saw from GHCi plus its
 -- accumulated diagnostics. Reset on every GHCi restart in
 -- @buildWithGhciOnChange@; 'BuildId' is intentionally /not/ here because it
 -- counts across restarts.
 data BuilderState = BuilderState
-    { loadedModules :: Map FilePath LoadedModule
+    { loadedModules :: Map FilePath Ghci.LoadedModule
     , knownTargets :: KnownTargetNames
     , diagnosticMap :: DiagnosticMap
     }
@@ -66,9 +65,9 @@
 -- forever once raised. They describe the current load's outcome, so we clear
 -- them every cycle and let this cycle's 'diagnostics' re-add them if the
 -- failure is still present.
-mergeDiagnostics :: DiagnosticMap -> LoadResult -> DiagnosticMap
-mergeDiagnostics prev LoadResult {compiledFiles, diagnostics} =
-    let retained = Map.filterWithKey (\f _ -> not (isLocationLess f)) prev
+mergeDiagnostics :: DiagnosticMap -> Ghci.LoadResult -> DiagnosticMap
+mergeDiagnostics prev Ghci.LoadResult {compiledFiles, diagnostics} =
+    let retained = Map.filterWithKey (\f _ -> not (Ghci.Path.isLocationLess f)) prev
         cleared = foldr Map.delete retained compiledFiles
         newByFile = Map.fromListWith (++) [(d.file, [d]) | d <- diagnostics]
     in  Map.union newByFile cleared
@@ -95,7 +94,7 @@
 -- leaving the diagnostic stale.
 fileMatchesAnyTarget :: KnownTargetNames -> FilePath -> Bool
 fileMatchesAnyTarget (KnownTargetNames targets) fp =
-    any (`Set.member` targets) (pathSuffixesAsModuleName fp)
+    any (`Set.member` targets) (Ghci.Path.pathSuffixesAsModuleName fp)
         || any (pathTargetMatches fp . toString) (Set.toList targets)
 
 
@@ -125,7 +124,7 @@
 -- stale diagnostics in place.
 dispatch
     :: KnownTargetNames
-    -> Maybe LoadedModule
+    -> Maybe Ghci.LoadedModule
     -> FilePath
     -> FileEvent
     -> Maybe DispatchAction
@@ -160,7 +159,7 @@
 filterToWatchDirs :: FilePath -> WatchDirs -> [Diagnostic] -> [Diagnostic]
 filterToWatchDirs _ (WatchDirs []) diags = diags
 filterToWatchDirs projectRoot (WatchDirs watchDirs) diags =
-    filter (\d -> isLocationLess d.file || isUnderAnyWatchDir d.file) diags
+    filter (\d -> Ghci.Path.isLocationLess d.file || isUnderAnyWatchDir d.file) diags
   where
     absWatchDirs = map toAbsWd watchDirs
     toAbsWd wd
@@ -197,7 +196,7 @@
 -- post-filter list, with the synthetic failure appended only when needed.
 preserveFailureVisibility :: [Diagnostic] -> [Diagnostic] -> [Diagnostic]
 preserveFailureVisibility raw filtered
-    | any isError raw && not (any isError filtered) = filtered ++ [unattributedFailure]
+    | any isError raw && not (any isError filtered) = filtered ++ [Ghci.Diagnostic.unattributedFailure]
     | otherwise = filtered
   where
     isError d = d.severity == SError
diff --git a/src/Tricorder/Daemon/EvalCommentRunner.hs b/src/Tricorder/Daemon/EvalCommentRunner.hs
--- a/src/Tricorder/Daemon/EvalCommentRunner.hs
+++ b/src/Tricorder/Daemon/EvalCommentRunner.hs
@@ -29,12 +29,13 @@
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 
-import Tricorder.Daemon.GhciSession.GhciParser (LoadedModule (..))
-import Tricorder.Daemon.GhciSession.GhciProcess (execGhci, withGhciProcess)
+import Tricorder.Daemon.Ghci.LoadedModule (LoadedModule (..))
 import Tricorder.Runtime (ProjectRoot (..))
 import Tricorder.Session.CommandTemplate (CommandTemplate)
 
 import Tricorder.Build.EvalComment qualified as Eval
+import Tricorder.Daemon.Ghci.LoadedModule qualified as Ghci (LoadedModule)
+import Tricorder.Daemon.Ghci.Process qualified as Ghci.Process
 import Tricorder.Session.Stage qualified as Stage
 import Tricorder.Session.Stage.Eval.Command qualified as EvalCommand
 import Tricorder.Session.Target qualified as Target
@@ -45,13 +46,13 @@
     -- in a fresh GHCi session started in that file's module context.
     EvaluateComments
         :: CommandTemplate 'Stage.Eval
-        -> NonEmpty (LoadedModule, NonEmpty Eval.Comment)
+        -> NonEmpty (Ghci.LoadedModule, NonEmpty Eval.Comment)
         -> EvalCommentRunner m (NonEmpty Eval.Evaluation)
     -- | Extract eval comments from provided source files. Returns a map of all
     -- files that have at least 1 eval comment.
     FindEvalCommentsInModules
-        :: Map FilePath LoadedModule
-        -> EvalCommentRunner m [(LoadedModule, NonEmpty Eval.Comment)]
+        :: Map FilePath Ghci.LoadedModule
+        -> EvalCommentRunner m [(Ghci.LoadedModule, NonEmpty Eval.Comment)]
 
 
 makeEffect ''EvalCommentRunner
@@ -121,10 +122,10 @@
             | otherwise = expr
         command = EvalCommand.render template [Target.Bare moduleName]
     sessionResult <- trySync
-        $ withGhciProcess def command projectRoot noProgress noSetup \ghci _ -> do
-            _ <- execGhci ghci (":m *" <> moduleName) noProgress
+        $ Ghci.Process.with def command projectRoot noProgress noSetup \ghci _ -> do
+            _ <- Ghci.Process.exec ghci (":m *" <> moduleName) noProgress
             for comments \comment -> do
-                outputResult <- trySync $ execGhci ghci (wrapForGhci comment.expression) noProgress
+                outputResult <- trySync $ Ghci.Process.exec ghci (wrapForGhci comment.expression) noProgress
                 pure
                     Eval.Evaluation
                         { file = relPath
diff --git a/src/Tricorder/Daemon/Ghci/Config.hs b/src/Tricorder/Daemon/Ghci/Config.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Config.hs
@@ -0,0 +1,25 @@
+module Tricorder.Daemon.Ghci.Config (Config (..)) where
+
+import Atelier.Time (Second)
+import Data.Default (Default (..))
+
+
+-- | Configuration for GHCi process management.
+data Config = Config
+    { startupTimeout :: Second
+    -- ^ How long to wait for the GHCi version banner on startup.
+    , shutdownTimeout :: Second
+    -- ^ How long to wait for the process to exit gracefully before force-killing.
+    , extraSetupCommands :: [Text]
+    -- ^ Additional GHCi commands to send after the fixed setup block.
+    -- Use this to inject session-wide options such as @:set -XOverloadedStrings@.
+    }
+
+
+instance Default Config where
+    def =
+        Config
+            { startupTimeout = 60
+            , shutdownTimeout = 5
+            , extraSetupCommands = []
+            }
diff --git a/src/Tricorder/Daemon/Ghci/Controls.hs b/src/Tricorder/Daemon/Ghci/Controls.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Controls.hs
@@ -0,0 +1,25 @@
+module Tricorder.Daemon.Ghci.Controls
+    ( Controls (..)
+    , lift
+    )
+where
+
+import Tricorder.Daemon.Ghci.LoadResult (LoadResult)
+
+
+data Controls m = Controls
+    { reload :: m LoadResult
+    , interrupt :: m ()
+    , add :: FilePath -> m LoadResult
+    , unadd :: Text -> m LoadResult
+    }
+
+
+lift :: (forall a. m a -> n a) -> Controls m -> Controls n
+lift f ctrls =
+    Controls
+        { reload = f ctrls.reload
+        , interrupt = f ctrls.interrupt
+        , add = f . ctrls.add
+        , unadd = f . ctrls.unadd
+        }
diff --git a/src/Tricorder/Daemon/Ghci/Diagnostic.hs b/src/Tricorder/Daemon/Ghci/Diagnostic.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Diagnostic.hs
@@ -0,0 +1,56 @@
+module Tricorder.Daemon.Ghci.Diagnostic
+    ( unattributedFailure
+    , headerParser
+    )
+where
+
+import Data.Char (isAlpha)
+import Text.Megaparsec (getInput, takeWhile1P)
+import Text.Megaparsec.Char (char)
+import Prelude hiding (many)
+
+import Data.Text qualified as T
+
+import Tricorder.Build (Diagnostic (..), Severity (..))
+import Tricorder.Daemon.Ghci.Position (Position)
+import Tricorder.Daemon.Ghci.TextParser (TextParser)
+
+import Tricorder.Daemon.Ghci.Position qualified as Position
+
+
+-- | Synthetic diagnostic for a failed load with no located error. Without a
+-- source span it cannot point anywhere, but it stops the build reading as
+-- clean and tells the user where to look.
+unattributedFailure :: Diagnostic
+unattributedFailure =
+    Diagnostic
+        { severity = SError
+        , file = "<no location info>"
+        , line = 0
+        , col = 0
+        , endLine = 0
+        , endCol = 0
+        , title = "GHCi reported a failed load with no located error"
+        , text =
+            "GHCi reported a failed load with no located error.\nRun `tricorder log` to see the full GHCi output.\n"
+        }
+
+
+-- | Parse the full diagnostic header:
+--   @file:pos:@ or @drive:path:pos:@ for Windows.
+-- Returns @(file, startPos, endPos, textAfterColon)@.
+headerParser :: TextParser (Text, Position, Position, Text)
+headerParser = do
+    a <- takeWhile1P Nothing (/= ':')
+    _ <- char ':'
+    filePart <-
+        if T.length a == 1 && isAlpha (T.head a)
+            then do
+                pathRest <- takeWhile1P Nothing (/= ':')
+                _ <- char ':'
+                pure (a <> ":" <> pathRest)
+            else
+                pure a
+    (sp, ep) <- Position.parser
+    afterPos <- getInput
+    pure (filePart, sp, ep, afterPos)
diff --git a/src/Tricorder/Daemon/Ghci/InterruptDecision.hs b/src/Tricorder/Daemon/Ghci/InterruptDecision.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/InterruptDecision.hs
@@ -0,0 +1,28 @@
+module Tricorder.Daemon.Ghci.InterruptDecision
+    ( InterruptDecision (..)
+    , decide
+    )
+where
+
+import Tricorder.Daemon.Ghci.Session.State qualified as Session (State)
+import Tricorder.Daemon.Ghci.Session.State qualified as Session.State
+
+
+-- | The outcome of consulting the 'SessionState' when an interrupt arrives.
+--
+-- 'NoOpIdle' means the GHCi session is at the prompt — sending SIGINT and a
+-- sync marker would dirty the buffers and desync the next 'execGhci'.
+-- 'SendInterruptFor n' means a command was in flight (state 'Busy n') and
+-- the caller should SIGINT the process group and re-write @markerFor n@ (that
+-- command's own marker) to unblock the in-progress 'drainUntil'.
+data InterruptDecision
+    = NoOpIdle
+    | SendInterruptFor Int
+    deriving stock (Eq, Show)
+
+
+-- | Pure state machine for 'interruptGhci'. Returns the new 'SessionState'
+-- to install along with the 'InterruptDecision' the caller should act on.
+decide :: Session.State -> (Session.State, InterruptDecision)
+decide s@(Session.State.Idle _) = (s, NoOpIdle)
+decide (Session.State.Busy n) = (Session.State.Idle (n + 1), SendInterruptFor n)
diff --git a/src/Tricorder/Daemon/Ghci/LineParser.hs b/src/Tricorder/Daemon/Ghci/LineParser.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/LineParser.hs
@@ -0,0 +1,66 @@
+module Tricorder.Daemon.Ghci.LineParser
+    ( LineStream (..)
+    , LineParser
+    , satisfyStripped
+    , stripAnsi
+    )
+where
+
+import Text.Megaparsec (Parsec, Stream (..), token)
+import Prelude hiding (many)
+
+import Data.Char qualified as Char
+import Data.Text qualified as T
+
+
+-- ---------------------------------------------------------------------------
+-- LineStream: a Stream instance for [Text] where Token = Text
+-- ---------------------------------------------------------------------------
+
+-- | Wrapper so we can define Stream / VisualStream / TraversableStream for
+--   a list of 'Text' lines without orphan-instance conflicts.
+newtype LineStream = LineStream [Text]
+
+
+instance Stream LineStream where
+    type Token LineStream = Text
+    type Tokens LineStream = [Text]
+    tokenToChunk Proxy = pure
+    tokensToChunk Proxy = id
+    chunkToTokens Proxy = id
+    chunkLength Proxy = length
+    chunkEmpty Proxy = null
+    take1_ (LineStream []) = Nothing
+    take1_ (LineStream (t : ts)) = Just (t, LineStream ts)
+    takeN_ n (LineStream s)
+        | n <= 0 = Just ([], LineStream s)
+        | null s = Nothing
+        | otherwise = let (a, b) = splitAt n s in Just (a, LineStream b)
+    takeWhile_ p (LineStream s) =
+        let (a, b) = span p s in (a, LineStream b)
+
+
+-- | Parser over a stream of 'Text' lines.
+type LineParser = Parsec Void LineStream
+
+
+-- | Consume a line whose stripped form satisfies the predicate.
+-- Returns @(originalLine, strippedLine)@.
+satisfyStripped :: (Text -> Bool) -> LineParser (Text, Text)
+satisfyStripped p = token testLine mempty
+  where
+    testLine line =
+        let stripped = stripAnsi line
+        in  if p stripped then Just (line, stripped) else Nothing
+
+
+-- | Strip ANSI escape sequences of the form @ESC [ \<params\> \<letter\>@.
+stripAnsi :: Text -> Text
+stripAnsi t = case T.uncons t of
+    Nothing -> t
+    Just ('\ESC', rest) -> case T.uncons rest of
+        Just ('[', rest') ->
+            let afterParams = T.dropWhile (not . Char.isAlpha) rest'
+            in  stripAnsi (T.drop 1 afterParams)
+        _ -> T.cons '\ESC' (stripAnsi rest)
+    Just (c, rest) -> T.cons c (stripAnsi rest)
diff --git a/src/Tricorder/Daemon/Ghci/Load.hs b/src/Tricorder/Daemon/Ghci/Load.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Load.hs
@@ -0,0 +1,143 @@
+module Tricorder.Daemon.Ghci.Load
+    ( Load (..)
+    , parser
+    , textParser
+    )
+where
+
+import Text.Megaparsec (anySingle, choice, satisfy, takeWhile1P, takeWhileP)
+import Text.Megaparsec.Char (char, string)
+
+import Data.Char qualified as Char
+import Data.Text qualified as T
+import Text.Megaparsec.Char.Lexer qualified as L
+
+import Tricorder.Daemon.Ghci.LineParser (LineParser)
+import Tricorder.Daemon.Ghci.LoadOutcome (LoadOutcome)
+import Tricorder.Daemon.Ghci.Position (Position (..))
+import Tricorder.Daemon.Ghci.TextParser (TextParser)
+
+import Tricorder.Daemon.Ghci.Diagnostic qualified as Diagnostic
+import Tricorder.Daemon.Ghci.LineParser qualified as LineParser
+import Tricorder.Daemon.Ghci.LoadOutcome qualified as LoadOutcome
+import Tricorder.Daemon.Ghci.Loading qualified as Ghci (Loading (..))
+import Tricorder.Daemon.Ghci.Loading qualified as Ghci.Loading
+import Tricorder.Daemon.Ghci.Message qualified as Ghci
+import Tricorder.Daemon.Ghci.Severity qualified as Severity
+import Tricorder.Daemon.Ghci.TextParser qualified as TextParser
+
+
+-- | A structured item from GHCi's reload output.
+data Load
+    = Loading Ghci.Loading
+    | Message Ghci.Message
+    | LoadConfig FilePath
+    | Summary LoadOutcome
+    deriving stock (Eq, Show)
+
+
+-- | Parse one item (or skip one line) from the reload output.
+parser :: LineParser (Maybe Load)
+parser =
+    choice
+        [ -- Pattern: "Loaded GHCi configuration from <path>"
+          fmap Just $ do
+            (_, stripped) <- LineParser.satisfyStripped ("Loaded GHCi configuration from " `T.isPrefixOf`)
+            let file = toString $ T.drop (T.length "Loaded GHCi configuration from ") stripped
+            pure (LoadConfig file)
+        , -- Pattern: "[N of M] Compiling ..."
+          do
+            (_, stripped) <- LineParser.satisfyStripped ("[" `T.isPrefixOf`)
+            pure (TextParser.run textParser stripped)
+        , -- Pattern: terminal summary line "Ok, ..." / "Failed, ..."
+          fmap Just $ do
+            (_, stripped) <-
+                LineParser.satisfyStripped (\s -> "Ok, " `T.isPrefixOf` s || "Failed, " `T.isPrefixOf` s)
+            pure
+                $ Summary
+                $ if "Failed, " `T.isPrefixOf` stripped
+                    then LoadOutcome.LoadFailed
+                    else LoadOutcome.LoadSucceeded
+        , -- Pattern: "<no location info>: error:"
+          fmap Just $ do
+            (origLine, _) <- LineParser.satisfyStripped ("<no location info>: error:" `T.isPrefixOf`)
+            body <- many (satisfy isMessageBody)
+            pure
+                $ Message
+                    Ghci.Message
+                        { severity = Severity.Error
+                        , file = "<no location info>"
+                        , startPos = Position 0 0
+                        , endPos = Position 0 0
+                        , messageLines = origLine : body
+                        }
+        , -- Pattern: diagnostic "file:pos:severity: ..."
+          do
+            (origLine, stripped) <-
+                LineParser.satisfyStripped
+                    ( \s ->
+                        not (T.null s)
+                            && not (" " `T.isPrefixOf` s)
+                            && not ("\t" `T.isPrefixOf` s)
+                    )
+            case TextParser.run Diagnostic.headerParser stripped of
+                Nothing -> pure Nothing
+                Just (fileT, sp, ep, afterPos) ->
+                    let lower = T.toLower (T.stripStart afterPos)
+                    in  case Severity.parse lower of
+                            Nothing -> pure Nothing
+                            Just sev -> do
+                                body <- many (satisfy isMessageBody)
+                                pure
+                                    $ Just
+                                    $ Message
+                                        Ghci.Message
+                                            { severity = sev
+                                            , file = toString fileT
+                                            , startPos = sp
+                                            , endPos = ep
+                                            , messageLines = origLine : body
+                                            }
+        , -- Fallback: skip any other line
+          fmap (const Nothing) anySingle
+        ]
+
+
+-- | Parse a "[N of M] Compiling Mod ( file, ... )" loading line.
+textParser :: TextParser Load
+textParser = do
+    _ <- char '['
+    _ <- takeWhileP Nothing (== ' ')
+    n <- L.decimal
+    _ <- string " of "
+    m <- L.decimal
+    _ <- takeWhileP Nothing (== ' ')
+    _ <- char ']'
+    _ <- takeWhile1P Nothing (== ' ')
+    _ <- string "Compiling"
+    _ <- takeWhile1P Nothing (== ' ')
+    modName <- takeWhile1P Nothing (not . Char.isSpace)
+    _ <- takeWhileP Nothing (/= '(')
+    _ <- char '('
+    _ <- takeWhileP Nothing (== ' ')
+    filePath <- takeWhile1P Nothing (\c -> c /= ',' && c /= ')')
+    pure
+        $ Loading
+            Ghci.Loading
+                { Ghci.Loading.index = n
+                , Ghci.Loading.total = m
+                , Ghci.Loading.moduleName = modName
+                , Ghci.Loading.sourceFile = toString (T.stripEnd filePath)
+                }
+
+
+-- | Check if a line is a continuation of a diagnostic message body.
+isMessageBody :: Text -> Bool
+isMessageBody line =
+    " " `T.isPrefixOf` line
+        || "\t" `T.isPrefixOf` line
+        || case T.break (== '|') line of
+            (prefix, rest)
+                | not (T.null rest) ->
+                    T.all (\c -> Char.isSpace c || Char.isDigit c) prefix
+            _ -> False
diff --git a/src/Tricorder/Daemon/Ghci/LoadOutcome.hs b/src/Tricorder/Daemon/Ghci/LoadOutcome.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/LoadOutcome.hs
@@ -0,0 +1,7 @@
+module Tricorder.Daemon.Ghci.LoadOutcome (LoadOutcome (..)) where
+
+
+-- | The terminal summary GHCi emits to close a load: @Ok, N modules loaded.@
+-- on success or @Failed, …@ on failure.
+data LoadOutcome = LoadSucceeded | LoadFailed
+    deriving stock (Eq, Show)
diff --git a/src/Tricorder/Daemon/Ghci/LoadResult.hs b/src/Tricorder/Daemon/Ghci/LoadResult.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/LoadResult.hs
@@ -0,0 +1,54 @@
+module Tricorder.Daemon.Ghci.LoadResult
+    ( LoadResult (..)
+    , resolveKnownTargets
+    )
+where
+
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+
+import Tricorder.Build (Diagnostic)
+import Tricorder.Daemon.Ghci.LoadedModule (LoadedModule (..))
+
+
+-- | The result of a GHCi load or reload operation.
+data LoadResult = LoadResult
+    { moduleCount :: Int
+    , compiledFiles :: Set FilePath
+    -- ^ Files compiled in this cycle (derived from 'GLoading' items).
+    -- Used by the session layer to decide which files' previous diagnostics to replace vs. retain.
+    , loadedModules :: Map FilePath LoadedModule
+    -- ^ Map from canonical absolute path to module metadata, derived from @:show modules@ output.
+    -- Lists only modules that compiled successfully this cycle — GHCi drops
+    -- failed-compile modules from @:show modules@.
+    , targetNames :: [Text]
+    -- ^ Raw entries from @:show targets@ — typically dotted module names
+    -- in @cabal repl --enable-multi-repl@. Unlike 'loadedModules' this
+    -- survives failed compiles, so the Builder feeds it into
+    -- @KnownTargetNames@ for the dispatcher's fallback lookup.
+    , diagnostics :: [Diagnostic]
+    }
+    deriving stock (Eq, Show)
+
+
+-- | Path↔module-name map for the next cycle: this round's @:show modules@
+-- plus carryover from @prev@ for targets that failed mid-session and so
+-- dropped out. Never-compiled targets are absent here (no path↔name
+-- mapping); the dispatcher recognises those via 'KnownTargetNames'.
+resolveKnownTargets
+    :: Map FilePath LoadedModule
+    -- ^ Previous known-targets map (carryover source).
+    -> LoadResult
+    -> Map FilePath LoadedModule
+resolveKnownTargets prev lr =
+    let primary = lr.loadedModules
+        primaryNames = Set.fromList [lm.moduleName | lm <- Map.elems primary]
+        prevByName = Map.fromList [(lm.moduleName, (path, lm)) | (path, lm) <- Map.toList prev]
+        carryover =
+            Map.fromList
+                [ (path, lm)
+                | name <- lr.targetNames
+                , not (Set.member name primaryNames)
+                , Just (path, lm) <- [Map.lookup name prevByName]
+                ]
+    in  Map.union primary carryover
diff --git a/src/Tricorder/Daemon/Ghci/LoadedModule.hs b/src/Tricorder/Daemon/Ghci/LoadedModule.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/LoadedModule.hs
@@ -0,0 +1,13 @@
+module Tricorder.Daemon.Ghci.LoadedModule
+    ( LoadedModule (..)
+    )
+where
+
+
+-- | A module currently loaded in the GHCi session.
+data LoadedModule = LoadedModule
+    { relPath :: FilePath
+    -- ^ Path relative to the project root (e.g. @"./src/Foo.hs"@).
+    , moduleName :: Text
+    }
+    deriving stock (Eq, Show)
diff --git a/src/Tricorder/Daemon/Ghci/Loading.hs b/src/Tricorder/Daemon/Ghci/Loading.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Loading.hs
@@ -0,0 +1,13 @@
+module Tricorder.Daemon.Ghci.Loading (Loading (..)) where
+
+
+-- | Payload for a @[N of M] Compiling Mod ( file, ... )@ line.
+data Loading = Loading
+    { index :: Int
+    -- ^ N (this module's index in the compilation sequence)
+    , total :: Int
+    -- ^ M (total modules to compile)
+    , moduleName :: Text
+    , sourceFile :: FilePath
+    }
+    deriving stock (Eq, Show)
diff --git a/src/Tricorder/Daemon/Ghci/Message.hs b/src/Tricorder/Daemon/Ghci/Message.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Message.hs
@@ -0,0 +1,17 @@
+module Tricorder.Daemon.Ghci.Message (Message (..)) where
+
+import Tricorder.Daemon.Ghci.Position (Position)
+import Tricorder.Daemon.Ghci.Severity (Severity)
+
+
+-- | Payload for a compiler diagnostic (error or warning).
+data Message = Message
+    { severity :: Severity
+    , file :: FilePath
+    , startPos :: Position
+    , endPos :: Position
+    -- ^ equals 'startPos' when no span
+    , messageLines :: [Text]
+    -- ^ raw lines (with any ANSI), header first
+    }
+    deriving stock (Eq, Show)
diff --git a/src/Tricorder/Daemon/Ghci/Output.hs b/src/Tricorder/Daemon/Ghci/Output.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Output.hs
@@ -0,0 +1,57 @@
+module Tricorder.Daemon.Ghci.Output
+    ( parseShowModules
+    , parseShowTargets
+    )
+where
+
+import Data.Char (isSpace)
+import Text.Megaparsec
+import Text.Megaparsec.Char (char)
+import Prelude hiding (many)
+
+import Data.Text qualified as T
+
+import Tricorder.Daemon.Ghci.LineParser (LineParser, LineStream (..))
+import Tricorder.Daemon.Ghci.TextParser (TextParser)
+
+import Tricorder.Daemon.Ghci.LineParser qualified as LineParser
+import Tricorder.Daemon.Ghci.TextParser qualified as TextParser
+
+
+-- | Parse the output of @:show modules@ into (module name, file path) pairs.
+parseShowModules :: [Text] -> [(Text, FilePath)]
+parseShowModules ls =
+    case parse (catMaybes <$> many showModuleLine <* eof) "" (LineStream ls) of
+        Right items -> items
+        Left _ -> []
+
+
+-- | Parse or skip one line of @:show modules@ output.
+showModuleLine :: LineParser (Maybe (Text, FilePath))
+showModuleLine = do
+    line <- anySingle
+    pure $ TextParser.run showModuleLineP (LineParser.stripAnsi line)
+
+
+showModuleLineP :: TextParser (Text, FilePath)
+showModuleLineP = do
+    modName <- takeWhile1P Nothing (not . isSpace)
+    _ <- takeWhileP Nothing (/= '(')
+    _ <- char '('
+    _ <- char ' '
+    filePath <- takeWhile1P Nothing (\c -> c /= ',' && c /= ')')
+    pure (modName, toString (T.stripEnd filePath))
+
+
+-- | Parse the output of @:show targets@.
+--
+-- Each line names one target. In @cabal repl --enable-multi-repl@ these are
+-- dotted module names (e.g. @"Foo.Bar"@); in plain @ghci@ sessions they can
+-- also be file paths. A leading @*@ marks the active interactive target and
+-- is stripped. Blank lines are skipped.
+parseShowTargets :: [Text] -> [Text]
+parseShowTargets = mapMaybe parseLine
+  where
+    parseLine raw =
+        let cleaned = T.strip $ T.dropWhile (== '*') $ T.strip $ LineParser.stripAnsi raw
+        in  if T.null cleaned then Nothing else Just cleaned
diff --git a/src/Tricorder/Daemon/Ghci/Path.hs b/src/Tricorder/Daemon/Ghci/Path.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Path.hs
@@ -0,0 +1,41 @@
+module Tricorder.Daemon.Ghci.Path
+    ( isLocationLess
+    , pathSuffixesAsModuleName
+    )
+where
+
+import System.FilePath (dropExtension, normalise, splitDirectories)
+
+import Data.List qualified as List
+import Data.Text qualified as T
+
+
+-- | Whether a diagnostic file is a GHCi location-less pseudo-file — a fully
+-- @\<…\>@-bracketed marker such as @\<no location info\>@ or @\<interactive\>@
+-- rather than a real source path. These carry no source span but can still
+-- represent genuine build-level failures, so callers treat them specially
+-- (kept rather than dropped, cleared every cycle rather than retained).
+--
+-- We require both the opening @\<@ and a closing @\>@ so a real (if exotic)
+-- path that merely starts with @\<@, e.g. @\<generated\>/Foo.hs@, is not
+-- mistaken for a marker.
+isLocationLess :: FilePath -> Bool
+isLocationLess f = "<" `List.isPrefixOf` f && ">" `List.isSuffixOf` f
+
+
+-- | Candidate dotted module names for a file path.
+--
+-- We can't convert dotted target names to paths without cabal's
+-- source-dirs, so callers check the other direction: any uppercase-segment
+-- suffix of the path (with @/@ → @.@, extension dropped) is a candidate.
+-- E.g. @./tricorder/src/Tricorder/Version.hs@ yields candidates
+-- @"Tricorder.Version"@ and @"Version"@.
+pathSuffixesAsModuleName :: FilePath -> [Text]
+pathSuffixesAsModuleName fp =
+    let segments = filter (not . null) (splitDirectories (dropExtension (normalise fp)))
+        upperSegments = dropWhile (not . startsUpper) segments
+        suffixes = takeWhile (not . null) (iterate (drop 1) upperSegments)
+    in  [T.intercalate "." (map toText s) | s <- suffixes]
+  where
+    startsUpper (c : _) = c >= 'A' && c <= 'Z'
+    startsUpper _ = False
diff --git a/src/Tricorder/Daemon/Ghci/Position.hs b/src/Tricorder/Daemon/Ghci/Position.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Position.hs
@@ -0,0 +1,56 @@
+module Tricorder.Daemon.Ghci.Position
+    ( Position (..)
+    , parser
+    )
+where
+
+import Text.Megaparsec (Parsec, choice)
+import Text.Megaparsec.Char (char)
+import Prelude hiding (many)
+
+import Text.Megaparsec.Char.Lexer qualified as L
+
+
+-- | A source position (1-based line and column). @(0, 0)@ when unavailable.
+data Position = Position
+    { line :: Int
+    , col :: Int
+    }
+    deriving stock (Eq, Show)
+
+
+-- | Parse a GHCi position in one of the formats:
+--   @(L1,C1)-(L2,C2):@, @L:C:@, or @L:C-C2:@.
+-- Consumes the trailing colon.
+parser :: Parsec Void Text (Position, Position)
+parser = parenForm <|> simpleForm
+  where
+    parenForm = do
+        _ <- char '('
+        l1 <- L.decimal
+        _ <- char ','
+        c1 <- L.decimal
+        _ <- char ')'
+        _ <- char '-'
+        _ <- char '('
+        _ <- optional (char '(') -- some GHCi versions emit "(L1,C1)-((L2,C2):"
+        l2 <- L.decimal
+        _ <- char ','
+        c2 <- L.decimal
+        _ <- char ')'
+        _ <- char ':'
+        pure (Position l1 c1, Position l2 c2)
+    simpleForm = do
+        l <- L.decimal
+        _ <- char ':'
+        c <- L.decimal
+        choice
+            [ do
+                _ <- char ':'
+                pure (Position l c, Position l c)
+            , do
+                _ <- char '-'
+                c2 <- L.decimal
+                _ <- char ':'
+                pure (Position l c, Position l c2)
+            ]
diff --git a/src/Tricorder/Daemon/Ghci/Process.hs b/src/Tricorder/Daemon/Ghci/Process.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Process.hs
@@ -0,0 +1,547 @@
+module Tricorder.Daemon.Ghci.Process
+    ( Process (..)
+    , Error (..)
+    , UnexpectedExit (..)
+    , with
+    , waitForBannerOrFail
+    , exec
+    , drainUntil
+    , interruptGhci
+    , terminateProcess
+    , collectResult
+    , reload
+    , add
+    , unadd
+    )
+where
+
+import Atelier.Effects.Conc (Conc)
+import Atelier.Effects.File (BufferMode (..), File, Handle)
+import Atelier.Effects.Log (Log)
+import Atelier.Effects.Process
+    ( RunningProcess
+    , createPipe
+    , getStderr
+    , getStdin
+    , getStdout
+    , setStderr
+    , setStdin
+    , setStdout
+    , setWorkingDir
+    , shell
+    )
+import Atelier.Effects.Timeout (Timeout, timeout)
+import Control.Concurrent.STM (TVar, modifyTVar', readTVar, retry, writeTVar)
+import Data.Sequence ((|>))
+import Data.Time.Units (Second)
+import Effectful.Concurrent (Concurrent)
+import Effectful.Concurrent.STM (atomically, newTVarIO)
+import Effectful.Exception (finally, throwIO, trySync)
+
+import Atelier.Effects.Conc qualified as Conc
+import Atelier.Effects.File qualified as File
+import Atelier.Effects.Log qualified as Log
+import Atelier.Effects.Process qualified as Atelier
+import Atelier.Effects.Process qualified as Process
+import Data.Text qualified as T
+
+import Tricorder.Daemon.Ghci.Config (Config (..))
+import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..))
+import Tricorder.Daemon.Ghci.Loading (Loading)
+import Tricorder.Daemon.Ghci.Result (parseProgressLine)
+import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))
+
+import Tricorder.Daemon.Ghci.Diagnostic qualified as Diagnostic
+import Tricorder.Daemon.Ghci.InterruptDecision qualified as InterruptDecision
+import Tricorder.Daemon.Ghci.LineParser qualified as LineParser
+import Tricorder.Daemon.Ghci.Output qualified as Output
+import Tricorder.Daemon.Ghci.Result qualified as Result
+import Tricorder.Daemon.Ghci.Session.State qualified as Session (State)
+import Tricorder.Daemon.Ghci.Session.State qualified as Session.State
+
+
+-- | A handle to a running GHCi subprocess.
+data Process = Process
+    { stdin :: Handle
+    , stdout :: Handle
+    , stderr :: Handle
+    , handle :: RunningProcess Handle Handle Handle
+    , stateVar :: TVar Session.State
+    }
+
+
+-- | Errors that can occur during GHCi process management.
+data Error
+    = StartupTimeout
+    | UnexpectedExit UnexpectedExit
+    | -- | The build command exited (or printed nothing parseable) before GHCi
+      -- produced its version banner. The 'Text' is the captured stderr+stdout
+      -- output so callers can surface a useful error (e.g. cabal's dependency
+      -- resolution failure).
+      StartupFailed Text
+    deriving stock (Eq, Show)
+
+
+instance Exception Error where
+    displayException :: Error -> String
+    displayException StartupTimeout = "GHCi timed out while starting"
+    displayException (UnexpectedExit unexpected) =
+        "Encountered error while waiting for command output from GHCi: " <> toString unexpected.errorMessage
+    displayException (StartupFailed msg) = "Failed to start GHCi: " <> toString msg
+
+
+data UnexpectedExit = MkUnexpectedExit
+    { marker :: Text
+    , errorMessage :: Text
+    }
+    deriving stock (Eq, Show)
+
+
+-- | Set up the GHCi protocol on an already-started process and return its
+-- handle together with the output captured during startup.
+--
+-- The spawn and group teardown are owned by 'withProcess'.
+setupProcess
+    :: ( Conc :> es
+       , Concurrent :> es
+       , File :> es
+       , Log :> es
+       , Timeout :> es
+       )
+    => Config
+    -> RunningProcess Handle Handle Handle
+    -> (Loading -> Eff es ())
+    -- ^ Called as each @[N of M] Compiling …@ line is streamed during the
+    -- initial-build drain, so the UI can update the progress bar live
+    -- instead of replaying everything once compilation finishes.
+    -> (Process -> Eff es ())
+    -- ^ @onReady@. Called once the 'Process' is constructed but before the
+    -- banner wait and initial-build drain — so callers can register the process
+    -- for interruption while the slow @cabal repl@ startup (dependency build
+    -- and recompilation) is still in progress.
+    -> Eff es (Process, [Text])
+setupProcess config p onProgress onReady = do
+    let inp = getStdin p
+        out = getStdout p
+        err = getStderr p
+    File.hSetBuffering inp LineBuffering
+    File.hSetBuffering out LineBuffering
+    File.hSetBuffering err LineBuffering
+
+    -- Register the process for interruption before the (possibly slow) banner
+    -- wait, so an interrupt during @cabal repl@ startup can terminate it.
+    stateVar <- newTVarIO (Session.State.Idle 0)
+    let ghciProcess =
+            Process
+                { stdin = inp
+                , stdout = out
+                , stderr = err
+                , handle = p
+                , stateVar = stateVar
+                }
+    onReady ghciProcess
+
+    -- Send a blank line to kick GHCi into producing output
+    File.hPutTextLn inp ""
+    File.hFlush inp
+
+    -- Wait for the version banner. We concurrently capture stderr so that if
+    -- the build command exits before printing a banner (e.g. cabal's
+    -- dependency resolution fails) we can surface its error output.
+    waitForBannerOrFail config.startupTimeout out err
+
+    -- Send fixed setup commands (protocol requirements)
+    File.hPutTextLn inp ":set prompt \"\""
+    File.hPutTextLn inp ":set prompt-cont \"\""
+    -- Send any caller-supplied extra setup commands
+    for_ config.extraSetupCommands \c ->
+        File.hPutTextLn inp c
+    File.hFlush inp
+
+    -- Sync: drain until marker 1 seen, then set counter to 2.
+    -- Capture lines from both streams — the stderr output contains the initial
+    -- compilation progress and any startup diagnostics. The line hook fires
+    -- 'onProgress' for each "[N of M] Compiling …" line as it arrives, so the
+    -- UI can update the progress bar live during the initial build.
+    let marker1 = markerFor 1
+        hook = progressLineHook onProgress
+    sendSyncCommand inp marker1
+    initialLines <- Conc.scoped do
+        stdoutThread <- Conc.fork $ drainUntil out marker1 hook
+        stderrThread <- Conc.fork $ drainUntil err marker1 hook
+        stdoutLines <- Conc.await stdoutThread
+        stderrLines <- Conc.await stderrThread
+        pure (stdoutLines ++ stderrLines)
+    atomically $ writeTVar stateVar (Session.State.Idle 2)
+
+    pure (ghciProcess, initialLines)
+
+
+-- | Run a GHCi @cabal repl@ session for the duration of @action@.
+--
+-- The session runs in its own process group, so the whole group is torn down on
+-- exit; 'quitGhci' first asks GHCi to @:quit@ for a graceful shutdown. The
+-- action receives the process handle and the output captured during startup.
+-- See 'setupProcess' for the @onProgress@ and @onReady@ callbacks.
+with
+    :: (Atelier.Process :> es, Conc :> es, Concurrent :> es, File :> es, Log :> es, Timeout :> es)
+    => Config
+    -> RenderedCommand stage
+    -> FilePath
+    -> (Loading -> Eff es ())
+    -> (Process -> Eff es ())
+    -> (Process -> [Text] -> Eff es a)
+    -> Eff es a
+with config cmd dir onProgress onReady action =
+    Process.withProcessGroup processConfig \p -> do
+        (ghciProcess, initialLines) <- setupProcess config p onProgress onReady
+        action ghciProcess initialLines `finally` quitGhci config ghciProcess
+  where
+    processConfig =
+        setStdin createPipe
+            $ setStdout createPipe
+            $ setStderr createPipe
+            $ setWorkingDir dir
+            $ shell
+            $ toString cmd.getRenderedCommand
+
+
+-- | Execute a command in GHCi and return the combined stdout+stderr output
+-- lines. The @onProgress@ callback fires for each @[N of M] Compiling …@ line
+-- as it arrives, so reload/add/unadd progress is streamed live to the UI.
+-- Pass @\\_ -> pure ()@ for commands that do not trigger compilation.
+exec
+    :: ( Conc :> es
+       , Concurrent :> es
+       , File :> es
+       , Log :> es
+       )
+    => Process -> Text -> (Loading -> Eff es ()) -> Eff es [Text]
+exec ghciProcess command onProgress = do
+    n <- atomically do
+        readTVar ghciProcess.stateVar >>= \case
+            Session.State.Idle n -> writeTVar ghciProcess.stateVar (Session.State.Busy n) $> n
+            Session.State.Busy _ -> retry
+    doExec n `finally` atomically (writeTVar ghciProcess.stateVar (Session.State.Idle (n + 1)))
+  where
+    doExec n = do
+        let marker = markerFor n
+            hook = progressLineHook onProgress
+        File.hPutTextLn ghciProcess.stdin command
+        File.hFlush ghciProcess.stdin
+        sendSyncCommand ghciProcess.stdin marker
+        -- Scoped so that an exception from one drain (e.g. 'UnexpectedExit'
+        -- when the underlying process is terminated mid-command) is
+        -- contained here, re-raised by 'await', and caught by the caller's
+        -- 'trySync'. Without 'scoped', Ki propagates the exception to the
+        -- \*ambient* scope — typically the builder's listener scope — which
+        -- tears down the whole builder loop instead of just failing this
+        -- one command.
+        (stdoutLines, stderrLines) <- Conc.scoped do
+            stdoutThread <- Conc.fork $ drainUntil ghciProcess.stdout marker hook
+            stderrThread <- Conc.fork $ drainUntil ghciProcess.stderr marker hook
+            (,) <$> Conc.await stdoutThread <*> Conc.await stderrThread
+        pure (stdoutLines ++ stderrLines)
+
+
+-- | Interrupt the currently running GHCi command (if any).
+--
+-- If a command is in progress (state 'Busy n'), sends SIGINT to the GHCi
+-- process group and re-writes /that command's own/ sync marker (@markerFor n@)
+-- so the in-progress 'drainUntil', which is waiting for exactly that marker,
+-- unblocks. The next command runs as @n + 1@ and waits for @markerFor (n + 1)@,
+-- so any duplicate @markerFor n@ left in the buffers is skipped by 'drainUntil'
+-- rather than mistaken for the next command's marker.
+--
+-- When GHCi is 'Idle' this is a true no-op: sending SIGINT and a sync marker to
+-- an idle GHCi leaves leftover marker output in the buffers.
+interruptGhci :: (Atelier.Process :> es, Concurrent :> es, File :> es) => Process -> Eff es ()
+interruptGhci ghciProcess = do
+    decision <- atomically do
+        s <- readTVar ghciProcess.stateVar
+        let (s', d) = InterruptDecision.decide s
+        writeTVar ghciProcess.stateVar s'
+        pure d
+    case decision of
+        InterruptDecision.NoOpIdle -> pure ()
+        InterruptDecision.SendInterruptFor n -> do
+            Process.interruptProcessGroup ghciProcess.handle
+            sendSyncCommand ghciProcess.stdin (markerFor n)
+
+
+-- | Forcefully terminate a GHCi process and its whole group.
+--
+-- Stronger than 'interruptGhci': intended for one-shot processes (such as the
+-- per-suite @cabal repl test:…@ used by the test runner) where SIGINT is
+-- insufficient — test frameworks like @hspec@ and @tasty@ install SIGINT
+-- handlers that finalise the current run rather than aborting it. Safe to call
+-- from another thread while the session is running.
+terminateProcess :: (Atelier.Process :> es) => Process -> Eff es ()
+terminateProcess ghciProcess =
+    Process.terminateProcessGroup ghciProcess.handle
+
+
+-- | Ask GHCi to @:quit@ and wait briefly for it to exit cleanly, letting it
+-- shut down gracefully before the enclosing 'withProcess' tears down the
+-- group. Never throws.
+quitGhci :: (Atelier.Process :> es, File :> es, Timeout :> es) => Config -> Process -> Eff es ()
+quitGhci config ghciProcess = do
+    void $ trySync $ do
+        File.hPutTextLn ghciProcess.stdin ":quit"
+        File.hFlush ghciProcess.stdin
+    void $ timeout config.shutdownTimeout (Process.waitExitCode ghciProcess.handle)
+
+
+-- ---------------------------------------------------------------------------
+-- Internal helpers
+
+-- | Build the finish marker for counter value @n@.
+markerFor :: Int -> Text
+markerFor n = markerPrefix <> show n <> "~#"
+
+
+-- | The prefix shared by all finish markers.
+markerPrefix :: Text
+markerPrefix = "#~TRI-FINISH-"
+
+
+-- | Write the synchronisation Haskell statements to GHCi stdin.
+--
+-- After each user command, these cause GHCi to emit the finish marker on both
+-- stdout and stderr, so 'drainUntil' knows when to stop.
+--
+-- The marker is emitted as two standalone, fully-qualified statements — one per
+-- stream — using only 'System.IO.hPutStrLn'. This is deliberate: a
+-- SIGINT-interrupted ':reload' empties GHCi's interactive scope, dropping the
+-- implicit @import Prelude@, so bare names like @putStrLn@ (and even the @>>@
+-- operator) fall out of scope. A marker built from those would /error/ instead
+-- of printing — its marker would never appear and 'drainUntil' would block
+-- forever waiting for it (the "stuck Building…" stall). Fully-qualified
+-- 'System.IO.hPutStrLn' resolves via GHCi's implicit qualified imports even
+-- with an emptied scope, so the marker survives an interrupt.
+sendSyncCommand :: (File :> es) => Handle -> Text -> Eff es ()
+sendSyncCommand h marker = do
+    -- Use show to produce a valid Haskell string literal for the marker text.
+    let markerLit = toText (show @String (toString marker)) -- e.g. "\"#~TRI-FINISH-3~#\""
+    File.hPutTextLn h ("System.IO.hPutStrLn System.IO.stdout " <> markerLit)
+    File.hPutTextLn h ("System.IO.hPutStrLn System.IO.stderr " <> markerLit)
+    File.hFlush h
+
+
+-- | Read lines from a handle until /this command's/ finish marker is seen (or
+-- EOF).
+--
+-- Stops only on a line containing the exact @marker@ it was given. A line
+-- carrying a /different/ finish marker — a stale leftover from a prior command
+-- that was interrupted mid-flight — is skipped rather than matched, so it can
+-- never make a later drain return prematurely (the "0 modules"/hang desync).
+-- Each ordinary line is passed to @onLine@ as it arrives, so callers can stream
+-- progress without waiting for the full drain to complete. Returns accumulated
+-- non-marker lines in order. Throws 'UnexpectedExit' on EOF before the marker.
+drainUntil :: (File :> es, Log :> es) => Handle -> Text -> (Text -> Eff es ()) -> Eff es [Text]
+drainUntil h marker onLine = go mempty
+  where
+    go acc = do
+        result <- trySync $ File.hGetLine h
+        case result of
+            Left ex -> do
+                let accumulatedLines = T.intercalate "\n" $ toList acc
+                    errorMessage =
+                        T.intercalate
+                            "\n"
+                            [ "Reached EOF before reading marker from GHCi."
+                            , "Was looking for marker '" <> marker <> "', but no such marker was found."
+                            , ""
+                            ]
+                            <> if T.null accumulatedLines
+                                then
+                                    T.intercalate
+                                        "\n"
+                                        [ "GHCi returned no output before we reached what we believe is EOF."
+                                        , "Got the following exception when attempting to read from GHCi:"
+                                        , toText $ displayException ex
+                                        ]
+                                else
+                                    T.intercalate
+                                        "\n"
+                                        [ "Accumulated output from GHCi so far:"
+                                        , accumulatedLines
+                                        ]
+                Log.err errorMessage
+                throwIO
+                    $ UnexpectedExit
+                    $ MkUnexpectedExit {marker, errorMessage}
+            Right line
+                | marker `T.isInfixOf` line -> pure $ toList acc
+                -- A stale marker from an interrupted command: drop it, keep going.
+                | markerPrefix `T.isInfixOf` line -> go acc
+                | otherwise -> do
+                    onLine line
+                    go $ acc |> line
+
+
+-- | Convert a 'GhciLoading' progress callback into a per-line hook suitable
+-- for 'drainUntil'. Non-progress lines are ignored.
+progressLineHook :: (Loading -> Eff es ()) -> Text -> Eff es ()
+progressLineHook onProgress line = traverse_ onProgress (parseProgressLine line)
+
+
+-- | Wait up to the given number of seconds for a GHCi version banner on
+-- stdout, capturing stderr (and any non-banner stdout lines) in case the
+-- build command fails before printing a banner.
+--
+-- Throws 'StartupFailed' with the captured output if stdout EOFs (the build
+-- command exited) or 'StartupTimeout' if the banner never arrives.
+waitForBannerOrFail
+    :: ( Conc :> es
+       , Concurrent :> es
+       , File :> es
+       , Timeout :> es
+       )
+    => Second -> Handle -> Handle -> Eff es ()
+waitForBannerOrFail delay out err = do
+    capturedVar <- newTVarIO ([] :: [Text])
+    let captureLine line = atomically $ modifyTVar' capturedVar (line :)
+        drainStderr = drainUntilEof err captureLine
+        watchStdout = waitForBannerStdout out captureLine
+
+    Conc.scoped do
+        stderrThread <- Conc.fork drainStderr
+        result <- timeout delay $ trySync watchStdout
+        case result of
+            Just (Right ()) -> pure ()
+            Just (Left ex) -> do
+                -- stdout EOF before the banner: the build command exited. Wait
+                -- for the stderr drain to reach EOF too (bounded, in case the
+                -- pipe lingers) so we surface its *complete* output — e.g.
+                -- cabal's dependency-resolution error — rather than whatever
+                -- the drain happened to have read so far.
+                _ <- timeout (1 :: Second) (Conc.await stderrThread)
+                captured <- atomically $ readTVar capturedVar
+                throwIO $ StartupFailed $ fromMaybe (startupExitedMessage ex) (renderCapturedLines captured)
+            Nothing -> do
+                captured <- atomically $ readTVar capturedVar
+                throwIO $ maybe StartupTimeout StartupFailed (renderCapturedLines captured)
+
+
+waitForBannerStdout :: (File :> es) => Handle -> (Text -> Eff es ()) -> Eff es ()
+waitForBannerStdout h captureLine = go
+  where
+    isVersionLine :: Text -> Bool
+    isVersionLine line =
+        let stripped = LineParser.stripAnsi line
+        in  "GHCi, version " `T.isInfixOf` stripped
+                || "GHCJSi, version " `T.isInfixOf` stripped
+                || "Clashi, version " `T.isInfixOf` stripped
+
+    go = do
+        result <- trySync $ File.hGetLine h
+        case result of
+            Left ex -> throwIO ex
+            Right line ->
+                if isVersionLine line
+                    then
+                        pure ()
+                    else do
+                        captureLine line
+                        go
+
+
+drainUntilEof :: (File :> es) => Handle -> (Text -> Eff es ()) -> Eff es ()
+drainUntilEof h onLine = go
+  where
+    go = do
+        result <- trySync $ File.hGetLine h
+        case result of
+            Left _ -> pure ()
+            Right line -> onLine line >> go
+
+
+-- | Render the captured (reverse-order) output lines into an error message,
+-- stripping ANSI escapes and dropping blank lines. Returns 'Nothing' when
+-- nothing useful remains, so callers can fall back to a generic message.
+renderCapturedLines :: [Text] -> Maybe Text
+renderCapturedLines capturedRev =
+    let cleaned = filter (not . T.null . T.strip) (map LineParser.stripAnsi (reverse capturedRev))
+    in  if null cleaned then Nothing else Just (T.intercalate "\n" cleaned)
+
+
+-- | Fallback message when the build command exited before the banner without
+-- printing anything we could capture.
+startupExitedMessage :: SomeException -> Text
+startupExitedMessage ex =
+    "Build command exited before GHCi started: " <> toText (displayException ex)
+
+
+-- | Parse already-drained GHCi output lines into a 'LoadResult', fetching the
+-- current module list via @:show modules@.
+--
+-- Progress is emitted live by 'drainUntil' as lines arrive, so no replay
+-- callback is needed here — this function only assembles the final result.
+collectResult
+    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)
+    => Process
+    -> [Text]
+    -> FilePath
+    -> Eff es LoadResult
+collectResult process lines' projectRoot = do
+    let noProgress = \_ -> pure ()
+    moduleLines <- exec process ":show modules" noProgress
+    targetLines <- exec process ":show targets" noProgress
+    let result =
+            Result.collectResult
+                projectRoot
+                lines'
+                (Output.parseShowModules moduleLines)
+                (Output.parseShowTargets targetLines)
+    -- A failed load with no located error produces only the synthetic
+    -- 'unattributedFailure'. The parsed diagnostics tell the user nothing in
+    -- that case, so log the raw GHCi output — it's the only way to see what
+    -- actually went wrong, and the synthetic diagnostic points here.
+    when (any (== Diagnostic.unattributedFailure) result.diagnostics)
+        $ Log.info
+        $ "GHCi reported a failed load with no located error. Full GHCi output:\n"
+            <> T.unlines lines'
+    pure result
+
+
+-- | Execute @:reload@ and return the assembled 'LoadResult'. Progress events
+-- fire live via @onProgress@ as each @[N of M] Compiling …@ line is read.
+reload
+    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)
+    => Process
+    -> FilePath
+    -> (Loading -> Eff es ())
+    -> Eff es LoadResult
+reload process projectRoot onProgress = do
+    reloadLines <- exec process ":reload" onProgress
+    collectResult process reloadLines projectRoot
+
+
+-- | Execute @:add@ for the given file and return the assembled 'LoadResult'.
+-- Progress events fire live via @onProgress@ as compilation proceeds.
+add
+    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)
+    => Process
+    -> FilePath -- the file to :add
+    -> FilePath -- projectRoot
+    -> (Loading -> Eff es ())
+    -> Eff es LoadResult
+add process filePath projectRoot onProgress = do
+    addLines <- exec process (":add " <> T.pack filePath) onProgress
+    collectResult process addLines projectRoot
+
+
+-- | Execute @:unadd@ for the given module and return the assembled
+-- 'LoadResult'. Progress events fire live via @onProgress@ as compilation
+-- proceeds.
+unadd
+    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)
+    => Process
+    -> Text -- the module name to :unadd
+    -> FilePath -- projectRoot
+    -> (Loading -> Eff es ())
+    -> Eff es LoadResult
+unadd process moduleName projectRoot onProgress = do
+    unaddLines <- exec process (":unadd " <> moduleName) onProgress
+    collectResult process unaddLines projectRoot
diff --git a/src/Tricorder/Daemon/Ghci/Result.hs b/src/Tricorder/Daemon/Ghci/Result.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Result.hs
@@ -0,0 +1,230 @@
+module Tricorder.Daemon.Ghci.Result
+    ( collectResult
+    , collectResultCustom
+    , reloadFailed
+    , parseProgressLine
+    , parseReload
+    , extractTitle
+    , toAbsolute
+    , toRelative
+    )
+where
+
+import Data.Char (isDigit, isSpace, toLower)
+import System.FilePath (isAbsolute, makeRelative, normalise, splitDirectories, (</>))
+import Text.Megaparsec
+import Prelude hiding (many)
+
+import Data.List qualified as List
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+import Data.Text qualified as T
+
+import Tricorder.Build (Diagnostic (..), Severity (..))
+import Tricorder.Daemon.Ghci.LineParser (LineStream (..))
+import Tricorder.Daemon.Ghci.Load (Load)
+import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..))
+import Tricorder.Daemon.Ghci.LoadedModule (LoadedModule (..))
+import Tricorder.Daemon.Ghci.Loading (Loading)
+import Tricorder.Daemon.Ghci.Message (Message (..))
+import Tricorder.Daemon.Ghci.Position (Position (..))
+
+import Tricorder.Daemon.Ghci.Diagnostic qualified as Diagnostic
+import Tricorder.Daemon.Ghci.LineParser qualified as LineParser
+import Tricorder.Daemon.Ghci.Load qualified as Load
+import Tricorder.Daemon.Ghci.LoadOutcome qualified as LoadOutcome
+import Tricorder.Daemon.Ghci.Loading qualified as Loading
+import Tricorder.Daemon.Ghci.Path qualified as Path
+import Tricorder.Daemon.Ghci.Severity qualified as Severity
+import Tricorder.Daemon.Ghci.TextParser qualified as TextParser
+
+
+-- ---------------------------------------------------------------------------
+-- Line-level helpers
+-- ---------------------------------------------------------------------------
+
+-- ---------------------------------------------------------------------------
+-- parseReload
+-- ---------------------------------------------------------------------------
+
+-- | Parse the output of @:reload@ from GHCi into structured items.
+parseReload :: [Text] -> [Load]
+parseReload ls =
+    case parse (catMaybes <$> many Load.parser <* eof) "" (LineStream ls) of
+        Right items -> items
+        Left _ -> []
+
+
+-- | Parse a single line as a "[N of M] Compiling …" progress event.
+--
+-- Returns 'Nothing' for any line that is not a loading line. Used to stream
+-- progress updates as GHCi emits them, rather than parsing the whole reload
+-- output after the fact.
+parseProgressLine :: Text -> Maybe Loading
+parseProgressLine line =
+    let stripped = LineParser.stripAnsi line
+    in  if "[" `T.isPrefixOf` stripped
+            then case TextParser.run Load.textParser stripped of
+                Just (Load.Loading l) -> Just l
+                _ -> Nothing
+            else
+                Nothing
+
+
+-- ---------------------------------------------------------------------------
+-- Pure utilities (unchanged)
+-- ---------------------------------------------------------------------------
+
+-- | Make an absolute path relative to the given base directory, prefixed with @"./"@.
+-- Paths already relative, or absolute paths outside @base@, are returned unchanged.
+toRelative :: FilePath -> FilePath -> FilePath
+toRelative base path
+    | not (isAbsolute path) = path
+    | otherwise = case splitDirectories (makeRelative base path) of
+        (".." : _) -> path
+        rel -> "." </> List.foldr1 (</>) rel
+
+
+-- | Make a relative path absolute by prepending the given base directory.
+-- Paths already absolute are returned unchanged.
+toAbsolute :: FilePath -> FilePath -> FilePath
+toAbsolute base path
+    | isAbsolute path = path
+    | otherwise = base </> path
+
+
+-- | Assemble a 'LoadResult' from a project root, parsed reload items, the
+-- @:show modules@ output, and the @:show targets@ output.
+collectResultCustom :: FilePath -> [Load] -> [(Text, FilePath)] -> [Text] -> LoadResult
+collectResultCustom projectRoot loads modules targets =
+    let rel = toRelative projectRoot
+        abs' = toAbsolute projectRoot
+        compiledFiles = case [l.sourceFile | Load.Loading l <- loads] of
+            [] -> Set.fromList (map (rel . snd) modules)
+            fs -> Set.fromList (map rel fs)
+        mkEntry (mn, fp) =
+            ( normalise (abs' fp)
+            , LoadedModule {relPath = rel fp, moduleName = mn}
+            )
+    in  LoadResult
+            { moduleCount = length modules
+            , compiledFiles
+            , loadedModules = Map.fromList (map mkEntry modules)
+            , targetNames = targets
+            , diagnostics = toDiagnostics rel loads
+            }
+
+
+-- | Assemble a 'LoadResult' from the raw reload output plus the @:show
+-- modules@ / @:show targets@ output.
+--
+-- This is 'collectResultCustom' wrapped with a safety net: if GHCi's reload
+-- ended in a @Failed,@ summary but no error diagnostic was captured (e.g. a
+-- location-less failure we don't otherwise attribute to a file), a synthetic
+-- error diagnostic is appended so the build can never be reported as clean
+-- while GHCi considers it failed.
+collectResult :: FilePath -> [Text] -> [(Text, FilePath)] -> [Text] -> LoadResult
+collectResult projectRoot reloadLines modules targets =
+    let loads = parseReload reloadLines
+        base = collectResultCustom projectRoot loads modules targets
+        hasError = any (\d -> d.severity == SError) base.diagnostics
+    in  if reloadFailed loads && not hasError
+            then
+                base {diagnostics = base.diagnostics ++ [Diagnostic.unattributedFailure]}
+            else
+                base
+
+
+-- | Whether GHCi's load ended in failure, decided from the 'GSummary' items
+-- 'parseReload' produces — the single place that classifies GHCi's terminal
+-- @Ok, …@ / @Failed, …@ summary line.
+--
+-- GHCi emits the real summary last, after all compilation output. Output
+-- produced /during/ the load (a Template Haskell splice, top-level IO run while
+-- interpreting) can contain an earlier line that looks like a summary, so only
+-- the /last/ 'GSummary' reflects the true outcome.
+reloadFailed :: [Load] -> Bool
+reloadFailed loads =
+    case [outcome | Load.Summary outcome <- loads] of
+        [] -> False
+        outcomes -> List.last outcomes == LoadOutcome.LoadFailed
+
+
+toDiagnostics :: (FilePath -> FilePath) -> [Load] -> [Diagnostic]
+toDiagnostics rel loads = mapMaybe toMsg loads
+  where
+    -- Location-less messages (e.g. @<no location info>@, @<interactive>@) carry
+    -- no source span. We keep the *errors* — a @<no location info>@ error is a
+    -- genuine load failure (e.g. a home-unit GHC plugin that can't be loaded in
+    -- @--enable-multi-repl@) and must not be silently dropped — but discard
+    -- location-less warnings, which are just noise without a file to attach to.
+    toMsg (Load.Message m) | Path.isLocationLess m.file, m.severity /= Severity.Error = Nothing
+    toMsg (Load.Message m) =
+        Just
+            Diagnostic
+                { severity = case m.severity of
+                    Severity.Warning -> SWarning
+                    Severity.Error -> SError
+                , file = rel m.file
+                , line = m.startPos.line
+                , col = m.startPos.col
+                , endLine = m.endPos.line
+                , endCol = m.endPos.col
+                , title = extractTitle (map toString m.messageLines)
+                , text = unlines (map toText m.messageLines)
+                }
+    toMsg _ = Nothing
+
+
+-- | Extract a short human-readable title from GHCi message lines.
+--
+-- The header line (@"file:line:col: severity: rest"@) is the first element.
+-- The human-readable text is either:
+--
+--   * Inline, after @"error:"@ \/ @"warning:"@ in the header (old GHC style), or
+--   * On subsequent indented lines (new GHC style, when header ends with a
+--     diagnostic code such as @[GHC-83865]@ or @[-Wmissing-deriving-strategies]@).
+--
+-- Source-display lines (@"39 | ..."@, @"   | ^^^^"@) are skipped when
+-- scanning body lines for content.
+extractTitle :: [String] -> Text
+extractTitle [] = ""
+extractTitle (header : body) =
+    fromMaybe (firstBodyLine body) $ inlineFromHeader $ toString $ LineParser.stripAnsi $ toText header
+  where
+    inlineFromHeader :: String -> Maybe Text
+    inlineFromHeader h =
+        let lower = map toLower h
+        in  case headerAfter "error:" lower h <|> headerAfter "warning:" lower h of
+                Nothing -> Nothing
+                Just rest ->
+                    let content = stripDiagCodes (dropWhile isSpace rest)
+                    in  if null content then Nothing else Just (toText content)
+
+    headerAfter :: String -> String -> String -> Maybe String
+    headerAfter needle haystack original =
+        fmap (\i -> drop (i + length needle) original)
+            $ List.findIndex (needle `List.isPrefixOf`) (List.tails haystack)
+
+    stripDiagCodes :: String -> String
+    stripDiagCodes s = case dropWhile isSpace s of
+        '[' : rest ->
+            let after = dropWhile isSpace (drop 1 (dropWhile (/= ']') rest))
+            in  stripDiagCodes after
+        other -> other
+
+    firstBodyLine :: [String] -> Text
+    firstBodyLine xs =
+        case [ t
+             | x <- xs
+             , let t = dropWhile isSpace (toString (LineParser.stripAnsi (toText x)))
+             , not (null t)
+             , not (isSourceLine t)
+             ] of
+            (t : _) -> toText t
+            [] -> ""
+
+    isSourceLine :: String -> Bool
+    isSourceLine s = case dropWhile (\c -> isDigit c || c == ' ') s of
+        ('|' : _) -> True
+        _ -> not (null s) && all (\c -> c `elem` ("^~_ " :: String)) s
diff --git a/src/Tricorder/Daemon/Ghci/Session.hs b/src/Tricorder/Daemon/Ghci/Session.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Session.hs
@@ -0,0 +1,131 @@
+module Tricorder.Daemon.Ghci.Session
+    ( -- * Effect
+      Session
+    , with
+
+      -- * Interpreters
+    , run
+    , runScripted
+    )
+where
+
+import Atelier.Effects.Conc (Conc)
+import Atelier.Effects.File (File)
+import Atelier.Effects.Log (Log)
+import Atelier.Effects.Process (Process)
+import Atelier.Effects.Timeout (Timeout)
+import Data.Default (def)
+import Effectful
+    ( Effect
+    , Limit (..)
+    , Persistence (..)
+    , UnliftStrategy (..)
+    )
+import Effectful.Concurrent (Concurrent)
+import Effectful.Dispatch.Dynamic
+    ( interpret
+    , localLift
+    , localSeqLift
+    , localSeqUnlift
+    , localUnlift
+    , reinterpret
+    )
+import Effectful.Exception (throwIO)
+import Effectful.State.Static.Shared (State, evalState, state)
+import Effectful.TH (makeEffect)
+
+import Tricorder.Build (BuildProgress (..))
+import Tricorder.Daemon.Ghci.Controls (Controls (..))
+import Tricorder.Daemon.Ghci.LoadResult (LoadResult)
+import Tricorder.Daemon.Ghci.Loading (Loading (..))
+import Tricorder.Runtime (ProjectRoot (..))
+import Tricorder.Session.Command.RenderedCommand (RenderedCommand)
+import Tricorder.Session.Stage (Stage (..))
+
+import Tricorder.Daemon.Ghci.Controls qualified as Controls
+import Tricorder.Daemon.Ghci.Process qualified as Ghci.Process
+import Tricorder.Daemon.Ghci.Process qualified as Process
+
+
+data Session :: Effect where
+    -- | Start a new GHCi session and run the handler with that session active.
+    -- The handler is also provided an action to reload the GHCi session,
+    -- returning new messages with module counts. The GHCi session is closed
+    -- when the handler returns.
+    With
+        :: (BuildProgress -> m ())
+        -- ^ Action to run when reporting progress
+        -> RenderedCommand 'Build
+        -> ProjectRoot
+        -> (LoadResult -> Controls m -> m a)
+        -> Session m a
+
+
+makeEffect ''Session
+
+
+-- | Scripted interpreter for testing.
+--
+-- Each call to 'startGhci' or 'reloadGhci' pops the next result from the
+-- pre-loaded list. 'Left' results are re-thrown as exceptions, simulating
+-- GHCi crashes. 'stopGhci' is always a no-op.
+runScripted
+    :: forall es a. [Either SomeException LoadResult] -> Eff (Session : es) a -> Eff es a
+runScripted results = reinterpret (evalState results) $ \env ->
+    let popResult :: Eff (State [Either SomeException LoadResult] : es) LoadResult
+        popResult = do
+            x <- state \case
+                x : xs -> (x, xs)
+                [] -> error "GhciSessionScripted: no more results in queue"
+            case x of
+                Left ex -> throwIO ex
+                Right r -> pure r
+    in  \case
+            With _ _ _ handler -> do
+                initial <- popResult
+                localSeqLift env \liftEff ->
+                    localSeqUnlift env \unlift ->
+                        unlift
+                            $ handler
+                                initial
+                                Controls
+                                    { reload = liftEff popResult
+                                    , interrupt = pure ()
+                                    , add = \_ -> liftEff popResult
+                                    , unadd = \_ -> liftEff popResult
+                                    }
+
+
+-- | GHCi session manager backed by 'Tricorder.Daemon.GhciSession.GhciProcess'
+-- and 'Tricorder.Daemon.GhciSession.GhciParser'.
+run
+    :: ( Conc :> es
+       , Concurrent :> es
+       , File :> es
+       , Log :> es
+       , Process :> es
+       , Timeout :> es
+       )
+    => Eff (Session : es) a -> Eff es a
+run = interpret $ \env -> \case
+    With onProgress cmd (ProjectRoot dir) handler -> do
+        localLift env (ConcUnlift Persistent Unlimited) \liftEff ->
+            localUnlift env (ConcUnlift Persistent Unlimited) \unlift -> do
+                let reportProgress loading =
+                        unlift
+                            $ onProgress
+                            $ BuildProgress
+                                { compiled = loading.index
+                                , total = loading.total
+                                }
+                Process.with def cmd dir reportProgress (\_ -> pure ()) \process startupLines -> do
+                    initialResult <- Ghci.Process.collectResult process startupLines dir
+                    unlift
+                        $ handler initialResult
+                        $ Controls.lift liftEff
+                        $ Controls
+                            { reload = Ghci.Process.reload process dir reportProgress
+                            , interrupt = Ghci.Process.interruptGhci process
+                            , add = \fp -> Ghci.Process.add process fp dir reportProgress
+                            , unadd = \mn -> Ghci.Process.unadd process mn dir reportProgress
+                            }
diff --git a/src/Tricorder/Daemon/Ghci/Session/State.hs b/src/Tricorder/Daemon/Ghci/Session/State.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Session/State.hs
@@ -0,0 +1,5 @@
+module Tricorder.Daemon.Ghci.Session.State (State (..)) where
+
+
+data State = Idle Int | Busy Int
+    deriving stock (Eq, Show)
diff --git a/src/Tricorder/Daemon/Ghci/Severity.hs b/src/Tricorder/Daemon/Ghci/Severity.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/Severity.hs
@@ -0,0 +1,20 @@
+module Tricorder.Daemon.Ghci.Severity
+    ( Severity (..)
+    , parse
+    )
+where
+
+import Data.Text qualified as T
+
+
+-- | Severity of a GHCi diagnostic message.
+data Severity = Warning | Error
+    deriving stock (Eq, Ord, Show)
+
+
+-- | Determine severity from the text after the position (lowercased, stripped).
+parse :: Text -> Maybe Severity
+parse lower
+    | "warning:" `T.isPrefixOf` lower = Just Warning
+    | "error:" `T.isPrefixOf` lower = Just Error
+    | otherwise = Nothing
diff --git a/src/Tricorder/Daemon/Ghci/TextParser.hs b/src/Tricorder/Daemon/Ghci/TextParser.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/Ghci/TextParser.hs
@@ -0,0 +1,19 @@
+module Tricorder.Daemon.Ghci.TextParser
+    ( TextParser
+    , run
+    )
+where
+
+import Text.Megaparsec (Parsec, parse)
+import Prelude hiding (many)
+
+
+-- | Parser over a single 'Text' value.
+type TextParser = Parsec Void Text
+
+
+-- | Run a 'TextParser' on a 'Text' value, returning 'Nothing' on failure.
+run :: TextParser a -> Text -> Maybe a
+run p t = case parse p "" t of
+    Right x -> Just x
+    Left _ -> Nothing
diff --git a/src/Tricorder/Daemon/GhciSession.hs b/src/Tricorder/Daemon/GhciSession.hs
deleted file mode 100644
--- a/src/Tricorder/Daemon/GhciSession.hs
+++ /dev/null
@@ -1,175 +0,0 @@
-module Tricorder.Daemon.GhciSession
-    ( -- * Effect
-      GhciSession
-    , Controls (..)
-    , transformControls
-    , withGhciWith
-    , withGhci
-
-      -- * Types
-    , LoadResult (..)
-    , LoadedModule (..)
-
-      -- * Interpreters
-    , runGhciSession
-    , runGhciSessionScripted
-    )
-where
-
-import Atelier.Effects.Conc (Conc)
-import Atelier.Effects.File (File)
-import Atelier.Effects.Log (Log)
-import Atelier.Effects.Process (Process)
-import Atelier.Effects.Publishing.Pub (Pub)
-import Atelier.Effects.Timeout (Timeout)
-import Data.Default (def)
-import Effectful
-    ( Effect
-    , Limit (..)
-    , Persistence (..)
-    , UnliftStrategy (..)
-    )
-import Effectful.Concurrent (Concurrent)
-import Effectful.Dispatch.Dynamic
-    ( interpret
-    , localLift
-    , localSeqLift
-    , localSeqUnlift
-    , localUnlift
-    , reinterpret
-    )
-import Effectful.Exception (throwIO)
-import Effectful.State.Static.Shared (State, evalState, state)
-import Effectful.TH (makeEffect)
-
-import Atelier.Effects.Publishing.Pub qualified as Pub
-
-import Tricorder.Build (BuildProgress (..))
-import Tricorder.Daemon.GhciSession.GhciParser
-    ( GhciLoading (..)
-    , LoadResult (..)
-    , LoadedModule (..)
-    )
-import Tricorder.Daemon.GhciSession.GhciProcess
-    ( addGhci
-    , collectGhciResult
-    , interruptGhci
-    , reloadGhci
-    , unaddGhci
-    , withGhciProcess
-    )
-import Tricorder.Runtime (ProjectRoot (..))
-import Tricorder.Session.Command.RenderedCommand (RenderedCommand)
-import Tricorder.Session.Stage (Stage (..))
-
-
-data GhciSession :: Effect where
-    -- | Start a new GHCi session and run the handler with that session active.
-    -- The handler is also provided an action to reload the GHCi session,
-    -- returning new messages with module counts. The GHCi session is closed
-    -- when the handler returns.
-    WithGhciWith
-        :: (BuildProgress -> m ())
-        -- ^ Action to run when reporting progress
-        -> RenderedCommand 'Build
-        -> ProjectRoot
-        -> (LoadResult -> Controls m -> m a)
-        -> GhciSession m a
-
-
-data Controls m = Controls
-    { reload :: m LoadResult
-    , interrupt :: m ()
-    , add :: FilePath -> m LoadResult
-    , unadd :: Text -> m LoadResult
-    }
-
-
-makeEffect ''GhciSession
-
-
-transformControls :: (forall a. m a -> n a) -> Controls m -> Controls n
-transformControls f ctrls =
-    Controls
-        { reload = f ctrls.reload
-        , interrupt = f ctrls.interrupt
-        , add = f . ctrls.add
-        , unadd = f . ctrls.unadd
-        }
-
-
-withGhci
-    :: (GhciSession :> es, Pub BuildProgress :> es)
-    => RenderedCommand 'Build
-    -> ProjectRoot
-    -> (LoadResult -> Controls (Eff es) -> Eff es a)
-    -> Eff es a
-withGhci cmd root handler = do
-    withGhciWith Pub.publish cmd root handler
-
-
--- | Scripted interpreter for testing.
---
--- Each call to 'startGhci' or 'reloadGhci' pops the next result from the
--- pre-loaded list. 'Left' results are re-thrown as exceptions, simulating
--- GHCi crashes. 'stopGhci' is always a no-op.
-runGhciSessionScripted
-    :: forall es a. [Either SomeException LoadResult] -> Eff (GhciSession : es) a -> Eff es a
-runGhciSessionScripted results = reinterpret (evalState results) $ \env ->
-    let popResult :: Eff (State [Either SomeException LoadResult] : es) LoadResult
-        popResult = do
-            x <- state \case
-                x : xs -> (x, xs)
-                [] -> error "GhciSessionScripted: no more results in queue"
-            case x of
-                Left ex -> throwIO ex
-                Right r -> pure r
-    in  \case
-            WithGhciWith _ _ _ handler -> do
-                initial <- popResult
-                localSeqLift env \liftEff ->
-                    localSeqUnlift env \unlift ->
-                        unlift
-                            $ handler
-                                initial
-                                Controls
-                                    { reload = liftEff popResult
-                                    , interrupt = pure ()
-                                    , add = \_ -> liftEff popResult
-                                    , unadd = \_ -> liftEff popResult
-                                    }
-
-
--- | GHCi session manager backed by 'Tricorder.Daemon.GhciSession.GhciProcess'
--- and 'Tricorder.Daemon.GhciSession.GhciParser'.
-runGhciSession
-    :: ( Conc :> es
-       , Concurrent :> es
-       , File :> es
-       , Log :> es
-       , Process :> es
-       , Timeout :> es
-       )
-    => Eff (GhciSession : es) a -> Eff es a
-runGhciSession = interpret $ \env -> \case
-    WithGhciWith onProgress cmd (ProjectRoot dir) handler -> do
-        localLift env (ConcUnlift Persistent Unlimited) \liftEff ->
-            localUnlift env (ConcUnlift Persistent Unlimited) \unlift -> do
-                let reportProgress loading =
-                        unlift
-                            $ onProgress
-                            $ BuildProgress
-                                { compiled = loading.index
-                                , total = loading.total
-                                }
-                withGhciProcess def cmd dir reportProgress (\_ -> pure ()) \process startupLines -> do
-                    initialResult <- collectGhciResult process startupLines dir
-                    unlift
-                        $ handler initialResult
-                        $ transformControls liftEff
-                        $ Controls
-                            { reload = reloadGhci process dir reportProgress
-                            , interrupt = interruptGhci process
-                            , add = \fp -> addGhci process fp dir reportProgress
-                            , unadd = \mn -> unaddGhci process mn dir reportProgress
-                            }
diff --git a/src/Tricorder/Daemon/GhciSession/GhciParser.hs b/src/Tricorder/Daemon/GhciSession/GhciParser.hs
deleted file mode 100644
--- a/src/Tricorder/Daemon/GhciSession/GhciParser.hs
+++ /dev/null
@@ -1,674 +0,0 @@
-module Tricorder.Daemon.GhciSession.GhciParser
-    ( GhciLoad (..)
-    , GhciLoading (..)
-    , GhciMessage (..)
-    , GhciSeverity (..)
-    , LoadOutcome (..)
-    , LoadResult (..)
-    , LoadedModule (..)
-    , Position (..)
-    , collectResult
-    , collectResultCustom
-    , isLocationLess
-    , reloadFailed
-    , parseProgressLine
-    , parseReload
-    , parseShowModules
-    , parseShowTargets
-    , pathSuffixesAsModuleName
-    , resolveKnownTargets
-    , stripAnsi
-    , extractTitle
-    , toAbsolute
-    , toRelative
-    , unattributedFailure
-    )
-where
-
-import Data.Char (isAlpha, isDigit, isSpace, toLower)
-import System.FilePath (dropExtension, isAbsolute, makeRelative, normalise, splitDirectories, (</>))
-import Text.Megaparsec
-import Text.Megaparsec.Char (char, string)
-import Prelude hiding (many)
-
-import Data.List qualified as List
-import Data.Map.Strict qualified as Map
-import Data.Set qualified as Set
-import Data.Text qualified as T
-import Text.Megaparsec.Char.Lexer qualified as L
-
-import Tricorder.Build (Diagnostic (..), Severity (..))
-
-
--- | Severity of a GHCi diagnostic message.
-data GhciSeverity = GWarning | GError
-    deriving stock (Eq, Ord, Show)
-
-
--- | Payload for a @[N of M] Compiling Mod ( file, ... )@ line.
-data GhciLoading = GhciLoading
-    { index :: Int
-    -- ^ N (this module's index in the compilation sequence)
-    , total :: Int
-    -- ^ M (total modules to compile)
-    , moduleName :: Text
-    , sourceFile :: FilePath
-    }
-    deriving stock (Eq, Show)
-
-
--- | A source position (1-based line and column). @(0, 0)@ when unavailable.
-data Position = Position
-    { line :: Int
-    , col :: Int
-    }
-    deriving stock (Eq, Show)
-
-
--- | Payload for a compiler diagnostic (error or warning).
-data GhciMessage = GhciMessage
-    { severity :: GhciSeverity
-    , file :: FilePath
-    , startPos :: Position
-    , endPos :: Position
-    -- ^ equals 'startPos' when no span
-    , messageLines :: [Text]
-    -- ^ raw lines (with any ANSI), header first
-    }
-    deriving stock (Eq, Show)
-
-
--- | The terminal summary GHCi emits to close a load: @Ok, N modules loaded.@
--- on success or @Failed, …@ on failure.
-data LoadOutcome = LoadSucceeded | LoadFailed
-    deriving stock (Eq, Show)
-
-
--- | A structured item from GHCi's reload output.
-data GhciLoad
-    = GLoading GhciLoading
-    | GMessage GhciMessage
-    | GLoadConfig FilePath
-    | GSummary LoadOutcome
-    deriving stock (Eq, Show)
-
-
--- | A module currently loaded in the GHCi session.
-data LoadedModule = LoadedModule
-    { relPath :: FilePath
-    -- ^ Path relative to the project root (e.g. @"./src/Foo.hs"@).
-    , moduleName :: Text
-    }
-    deriving stock (Eq, Show)
-
-
--- | The result of a GHCi load or reload operation.
-data LoadResult = LoadResult
-    { moduleCount :: Int
-    , compiledFiles :: Set FilePath
-    -- ^ Files compiled in this cycle (derived from 'GLoading' items).
-    -- Used by the session layer to decide which files' previous diagnostics to replace vs. retain.
-    , loadedModules :: Map FilePath LoadedModule
-    -- ^ Map from canonical absolute path to module metadata, derived from @:show modules@ output.
-    -- Lists only modules that compiled successfully this cycle — GHCi drops
-    -- failed-compile modules from @:show modules@.
-    , targetNames :: [Text]
-    -- ^ Raw entries from @:show targets@ — typically dotted module names
-    -- in @cabal repl --enable-multi-repl@. Unlike 'loadedModules' this
-    -- survives failed compiles, so the Builder feeds it into
-    -- @KnownTargetNames@ for the dispatcher's fallback lookup.
-    , diagnostics :: [Diagnostic]
-    }
-    deriving stock (Eq, Show)
-
-
--- ---------------------------------------------------------------------------
--- LineStream: a Stream instance for [Text] where Token = Text
--- ---------------------------------------------------------------------------
-
--- | Wrapper so we can define Stream / VisualStream / TraversableStream for
---   a list of 'Text' lines without orphan-instance conflicts.
-newtype LineStream = LineStream [Text]
-
-
-instance Stream LineStream where
-    type Token LineStream = Text
-    type Tokens LineStream = [Text]
-    tokenToChunk Proxy = pure
-    tokensToChunk Proxy = id
-    chunkToTokens Proxy = id
-    chunkLength Proxy = length
-    chunkEmpty Proxy = null
-    take1_ (LineStream []) = Nothing
-    take1_ (LineStream (t : ts)) = Just (t, LineStream ts)
-    takeN_ n (LineStream s)
-        | n <= 0 = Just ([], LineStream s)
-        | null s = Nothing
-        | otherwise = let (a, b) = splitAt n s in Just (a, LineStream b)
-    takeWhile_ p (LineStream s) =
-        let (a, b) = span p s in (a, LineStream b)
-
-
--- ---------------------------------------------------------------------------
--- Parser type aliases
--- ---------------------------------------------------------------------------
-
--- | Parser over a stream of 'Text' lines.
-type LineParser = Parsec Void LineStream
-
-
--- | Parser over a single 'Text' value.
-type TextParser = Parsec Void Text
-
-
--- ---------------------------------------------------------------------------
--- Text-level sub-parsers (helpers for diagnostic header parsing)
--- ---------------------------------------------------------------------------
-
--- | Run a 'TextParser' on a 'Text' value, returning 'Nothing' on failure.
-runTP :: TextParser a -> Text -> Maybe a
-runTP p t = case parse p "" t of
-    Right x -> Just x
-    Left _ -> Nothing
-
-
--- | Parse a GHCi position in one of the formats:
---   @(L1,C1)-(L2,C2):@, @L:C:@, or @L:C-C2:@.
--- Consumes the trailing colon.
-positionP :: TextParser (Position, Position)
-positionP = parenForm <|> simpleForm
-  where
-    parenForm = do
-        _ <- char '('
-        l1 <- L.decimal
-        _ <- char ','
-        c1 <- L.decimal
-        _ <- char ')'
-        _ <- char '-'
-        _ <- char '('
-        _ <- optional (char '(') -- some GHCi versions emit "(L1,C1)-((L2,C2):"
-        l2 <- L.decimal
-        _ <- char ','
-        c2 <- L.decimal
-        _ <- char ')'
-        _ <- char ':'
-        pure (Position l1 c1, Position l2 c2)
-    simpleForm = do
-        l <- L.decimal
-        _ <- char ':'
-        c <- L.decimal
-        choice
-            [ do
-                _ <- char ':'
-                pure (Position l c, Position l c)
-            , do
-                _ <- char '-'
-                c2 <- L.decimal
-                _ <- char ':'
-                pure (Position l c, Position l c2)
-            ]
-
-
--- | Parse the full diagnostic header:
---   @file:pos:@ or @drive:path:pos:@ for Windows.
--- Returns @(file, startPos, endPos, textAfterColon)@.
-diagHeaderP :: TextParser (Text, Position, Position, Text)
-diagHeaderP = do
-    a <- takeWhile1P Nothing (/= ':')
-    _ <- char ':'
-    filePart <-
-        if T.length a == 1 && isAlpha (T.head a)
-            then do
-                pathRest <- takeWhile1P Nothing (/= ':')
-                _ <- char ':'
-                pure (a <> ":" <> pathRest)
-            else
-                pure a
-    (sp, ep) <- positionP
-    afterPos <- getInput
-    pure (filePart, sp, ep, afterPos)
-
-
--- ---------------------------------------------------------------------------
--- Line-level helpers
--- ---------------------------------------------------------------------------
-
--- | Check if a line is a continuation of a diagnostic message body.
-isMessageBody :: Text -> Bool
-isMessageBody line =
-    " " `T.isPrefixOf` line
-        || "\t" `T.isPrefixOf` line
-        || case T.break (== '|') line of
-            (prefix, rest)
-                | not (T.null rest) ->
-                    T.all (\c -> isSpace c || isDigit c) prefix
-            _ -> False
-
-
--- | Consume a line whose stripped form satisfies the predicate.
--- Returns @(originalLine, strippedLine)@.
-satisfyStripped :: (Text -> Bool) -> LineParser (Text, Text)
-satisfyStripped p = token testLine mempty
-  where
-    testLine line =
-        let stripped = stripAnsi line
-        in  if p stripped then Just (line, stripped) else Nothing
-
-
--- ---------------------------------------------------------------------------
--- parseReload
--- ---------------------------------------------------------------------------
-
--- | Parse the output of @:reload@ from GHCi into structured items.
-parseReload :: [Text] -> [GhciLoad]
-parseReload ls =
-    case parse (catMaybes <$> many reloadItem <* eof) "" (LineStream ls) of
-        Right items -> items
-        Left _ -> []
-
-
--- | Parse one item (or skip one line) from the reload output.
-reloadItem :: LineParser (Maybe GhciLoad)
-reloadItem =
-    choice
-        [ -- Pattern: "Loaded GHCi configuration from <path>"
-          fmap Just $ do
-            (_, stripped) <- satisfyStripped ("Loaded GHCi configuration from " `T.isPrefixOf`)
-            let file = toString $ T.drop (T.length "Loaded GHCi configuration from ") stripped
-            pure (GLoadConfig file)
-        , -- Pattern: "[N of M] Compiling ..."
-          do
-            (_, stripped) <- satisfyStripped ("[" `T.isPrefixOf`)
-            pure (runTP loadingLineP stripped)
-        , -- Pattern: terminal summary line "Ok, ..." / "Failed, ..."
-          fmap Just $ do
-            (_, stripped) <-
-                satisfyStripped (\s -> "Ok, " `T.isPrefixOf` s || "Failed, " `T.isPrefixOf` s)
-            pure
-                $ GSummary
-                $ if "Failed, " `T.isPrefixOf` stripped then LoadFailed else LoadSucceeded
-        , -- Pattern: "<no location info>: error:"
-          fmap Just $ do
-            (origLine, _) <- satisfyStripped ("<no location info>: error:" `T.isPrefixOf`)
-            body <- many (satisfy isMessageBody)
-            pure
-                $ GMessage
-                    GhciMessage
-                        { severity = GError
-                        , file = "<no location info>"
-                        , startPos = Position 0 0
-                        , endPos = Position 0 0
-                        , messageLines = origLine : body
-                        }
-        , -- Pattern: diagnostic "file:pos:severity: ..."
-          do
-            (origLine, stripped) <-
-                satisfyStripped
-                    ( \s ->
-                        not (T.null s)
-                            && not (" " `T.isPrefixOf` s)
-                            && not ("\t" `T.isPrefixOf` s)
-                    )
-            case runTP diagHeaderP stripped of
-                Nothing -> pure Nothing
-                Just (fileT, sp, ep, afterPos) ->
-                    let lower = T.toLower (T.stripStart afterPos)
-                    in  case parseSeverity lower of
-                            Nothing -> pure Nothing
-                            Just sev -> do
-                                body <- many (satisfy isMessageBody)
-                                pure
-                                    $ Just
-                                    $ GMessage
-                                        GhciMessage
-                                            { severity = sev
-                                            , file = toString fileT
-                                            , startPos = sp
-                                            , endPos = ep
-                                            , messageLines = origLine : body
-                                            }
-        , -- Fallback: skip any other line
-          fmap (const Nothing) anySingle
-        ]
-
-
--- | Parse a single line as a "[N of M] Compiling …" progress event.
---
--- Returns 'Nothing' for any line that is not a loading line. Used to stream
--- progress updates as GHCi emits them, rather than parsing the whole reload
--- output after the fact.
-parseProgressLine :: Text -> Maybe GhciLoading
-parseProgressLine line =
-    let stripped = stripAnsi line
-    in  if "[" `T.isPrefixOf` stripped
-            then case runTP loadingLineP stripped of
-                Just (GLoading l) -> Just l
-                _ -> Nothing
-            else
-                Nothing
-
-
--- | Parse a "[N of M] Compiling Mod ( file, ... )" loading line.
-loadingLineP :: TextParser GhciLoad
-loadingLineP = do
-    _ <- char '['
-    _ <- takeWhileP Nothing (== ' ')
-    n <- L.decimal
-    _ <- string " of "
-    m <- L.decimal
-    _ <- takeWhileP Nothing (== ' ')
-    _ <- char ']'
-    _ <- takeWhile1P Nothing (== ' ')
-    _ <- string "Compiling"
-    _ <- takeWhile1P Nothing (== ' ')
-    modName <- takeWhile1P Nothing (not . isSpace)
-    _ <- takeWhileP Nothing (/= '(')
-    _ <- char '('
-    _ <- takeWhileP Nothing (== ' ')
-    filePath <- takeWhile1P Nothing (\c -> c /= ',' && c /= ')')
-    pure
-        $ GLoading
-            GhciLoading
-                { index = n
-                , total = m
-                , moduleName = modName
-                , sourceFile = toString (T.stripEnd filePath)
-                }
-
-
--- | Determine severity from the text after the position (lowercased, stripped).
-parseSeverity :: Text -> Maybe GhciSeverity
-parseSeverity lower
-    | "warning:" `T.isPrefixOf` lower = Just GWarning
-    | "error:" `T.isPrefixOf` lower = Just GError
-    | otherwise = Nothing
-
-
--- ---------------------------------------------------------------------------
--- parseShowModules
--- ---------------------------------------------------------------------------
-
--- | Parse the output of @:show modules@ into (module name, file path) pairs.
-parseShowModules :: [Text] -> [(Text, FilePath)]
-parseShowModules ls =
-    case parse (catMaybes <$> many showModuleLine <* eof) "" (LineStream ls) of
-        Right items -> items
-        Left _ -> []
-
-
--- | Parse or skip one line of @:show modules@ output.
-showModuleLine :: LineParser (Maybe (Text, FilePath))
-showModuleLine = do
-    line <- anySingle
-    pure $ runTP showModuleLineP (stripAnsi line)
-
-
-showModuleLineP :: TextParser (Text, FilePath)
-showModuleLineP = do
-    modName <- takeWhile1P Nothing (not . isSpace)
-    _ <- takeWhileP Nothing (/= '(')
-    _ <- char '('
-    _ <- char ' '
-    filePath <- takeWhile1P Nothing (\c -> c /= ',' && c /= ')')
-    pure (modName, toString (T.stripEnd filePath))
-
-
--- ---------------------------------------------------------------------------
--- parseShowTargets
--- ---------------------------------------------------------------------------
-
--- | Parse the output of @:show targets@.
---
--- Each line names one target. In @cabal repl --enable-multi-repl@ these are
--- dotted module names (e.g. @"Foo.Bar"@); in plain @ghci@ sessions they can
--- also be file paths. A leading @*@ marks the active interactive target and
--- is stripped. Blank lines are skipped.
-parseShowTargets :: [Text] -> [Text]
-parseShowTargets = mapMaybe parseLine
-  where
-    parseLine raw =
-        let cleaned = T.strip (T.dropWhile (== '*') (T.strip (stripAnsi raw)))
-        in  if T.null cleaned then Nothing else Just cleaned
-
-
--- ---------------------------------------------------------------------------
--- Pure utilities (unchanged)
--- ---------------------------------------------------------------------------
-
--- | Make an absolute path relative to the given base directory, prefixed with @"./"@.
--- Paths already relative, or absolute paths outside @base@, are returned unchanged.
-toRelative :: FilePath -> FilePath -> FilePath
-toRelative base path
-    | not (isAbsolute path) = path
-    | otherwise = case splitDirectories (makeRelative base path) of
-        (".." : _) -> path
-        rel -> "." </> List.foldr1 (</>) rel
-
-
--- | Make a relative path absolute by prepending the given base directory.
--- Paths already absolute are returned unchanged.
-toAbsolute :: FilePath -> FilePath -> FilePath
-toAbsolute base path
-    | isAbsolute path = path
-    | otherwise = base </> path
-
-
--- | Candidate dotted module names for a file path.
---
--- We can't convert dotted target names to paths without cabal's
--- source-dirs, so callers check the other direction: any uppercase-segment
--- suffix of the path (with @/@ → @.@, extension dropped) is a candidate.
--- E.g. @./tricorder/src/Tricorder/Version.hs@ yields candidates
--- @"Tricorder.Version"@ and @"Version"@.
-pathSuffixesAsModuleName :: FilePath -> [Text]
-pathSuffixesAsModuleName fp =
-    let segments = filter (not . null) (splitDirectories (dropExtension (normalise fp)))
-        upperSegments = dropWhile (not . startsUpper) segments
-        suffixes = takeWhile (not . null) (iterate (drop 1) upperSegments)
-    in  [T.intercalate "." (map toText s) | s <- suffixes]
-  where
-    startsUpper (c : _) = c >= 'A' && c <= 'Z'
-    startsUpper _ = False
-
-
--- | Strip ANSI escape sequences of the form @ESC [ \<params\> \<letter\>@.
-stripAnsi :: Text -> Text
-stripAnsi t = case T.uncons t of
-    Nothing -> t
-    Just ('\ESC', rest) -> case T.uncons rest of
-        Just ('[', rest') ->
-            let afterParams = T.dropWhile (not . isAlpha) rest'
-            in  stripAnsi (T.drop 1 afterParams)
-        _ -> T.cons '\ESC' (stripAnsi rest)
-    Just (c, rest) -> T.cons c (stripAnsi rest)
-
-
--- | Assemble a 'LoadResult' from a project root, parsed reload items, the
--- @:show modules@ output, and the @:show targets@ output.
-collectResultCustom :: FilePath -> [GhciLoad] -> [(Text, FilePath)] -> [Text] -> LoadResult
-collectResultCustom projectRoot loads modules targets =
-    let rel = toRelative projectRoot
-        abs' = toAbsolute projectRoot
-        compiledFiles = case [l.sourceFile | GLoading l <- loads] of
-            [] -> Set.fromList (map (rel . snd) modules)
-            fs -> Set.fromList (map rel fs)
-        mkEntry (mn, fp) =
-            ( normalise (abs' fp)
-            , LoadedModule {relPath = rel fp, moduleName = mn}
-            )
-    in  LoadResult
-            { moduleCount = length modules
-            , compiledFiles
-            , loadedModules = Map.fromList (map mkEntry modules)
-            , targetNames = targets
-            , diagnostics = toDiagnostics rel loads
-            }
-
-
--- | Assemble a 'LoadResult' from the raw reload output plus the @:show
--- modules@ / @:show targets@ output.
---
--- This is 'collectResultCustom' wrapped with a safety net: if GHCi's reload
--- ended in a @Failed,@ summary but no error diagnostic was captured (e.g. a
--- location-less failure we don't otherwise attribute to a file), a synthetic
--- error diagnostic is appended so the build can never be reported as clean
--- while GHCi considers it failed.
-collectResult :: FilePath -> [Text] -> [(Text, FilePath)] -> [Text] -> LoadResult
-collectResult projectRoot reloadLines modules targets =
-    let loads = parseReload reloadLines
-        base = collectResultCustom projectRoot loads modules targets
-        hasError = any (\d -> d.severity == SError) base.diagnostics
-    in  if reloadFailed loads && not hasError
-            then
-                base {diagnostics = base.diagnostics ++ [unattributedFailure]}
-            else
-                base
-
-
--- | Synthetic diagnostic for a failed load with no located error. Without a
--- source span it cannot point anywhere, but it stops the build reading as
--- clean and tells the user where to look.
-unattributedFailure :: Diagnostic
-unattributedFailure =
-    Diagnostic
-        { severity = SError
-        , file = "<no location info>"
-        , line = 0
-        , col = 0
-        , endLine = 0
-        , endCol = 0
-        , title = "GHCi reported a failed load with no located error"
-        , text =
-            "GHCi reported a failed load with no located error.\nRun `tricorder log` to see the full GHCi output.\n"
-        }
-
-
--- | Whether GHCi's load ended in failure, decided from the 'GSummary' items
--- 'parseReload' produces — the single place that classifies GHCi's terminal
--- @Ok, …@ / @Failed, …@ summary line.
---
--- GHCi emits the real summary last, after all compilation output. Output
--- produced /during/ the load (a Template Haskell splice, top-level IO run while
--- interpreting) can contain an earlier line that looks like a summary, so only
--- the /last/ 'GSummary' reflects the true outcome.
-reloadFailed :: [GhciLoad] -> Bool
-reloadFailed loads =
-    case [outcome | GSummary outcome <- loads] of
-        [] -> False
-        outcomes -> List.last outcomes == LoadFailed
-
-
--- | Whether a diagnostic file is a GHCi location-less pseudo-file — a fully
--- @\<…\>@-bracketed marker such as @\<no location info\>@ or @\<interactive\>@
--- rather than a real source path. These carry no source span but can still
--- represent genuine build-level failures, so callers treat them specially
--- (kept rather than dropped, cleared every cycle rather than retained).
---
--- We require both the opening @\<@ and a closing @\>@ so a real (if exotic)
--- path that merely starts with @\<@, e.g. @\<generated\>/Foo.hs@, is not
--- mistaken for a marker.
-isLocationLess :: FilePath -> Bool
-isLocationLess f = "<" `List.isPrefixOf` f && ">" `List.isSuffixOf` f
-
-
--- | Path↔module-name map for the next cycle: this round's @:show modules@
--- plus carryover from @prev@ for targets that failed mid-session and so
--- dropped out. Never-compiled targets are absent here (no path↔name
--- mapping); the dispatcher recognises those via 'KnownTargetNames'.
-resolveKnownTargets
-    :: Map FilePath LoadedModule
-    -- ^ Previous known-targets map (carryover source).
-    -> LoadResult
-    -> Map FilePath LoadedModule
-resolveKnownTargets prev lr =
-    let primary = lr.loadedModules
-        primaryNames = Set.fromList [lm.moduleName | lm <- Map.elems primary]
-        prevByName = Map.fromList [(lm.moduleName, (path, lm)) | (path, lm) <- Map.toList prev]
-        carryover =
-            Map.fromList
-                [ (path, lm)
-                | name <- lr.targetNames
-                , not (Set.member name primaryNames)
-                , Just (path, lm) <- [Map.lookup name prevByName]
-                ]
-    in  Map.union primary carryover
-
-
-toDiagnostics :: (FilePath -> FilePath) -> [GhciLoad] -> [Diagnostic]
-toDiagnostics rel loads = mapMaybe toMsg loads
-  where
-    -- Location-less messages (e.g. @<no location info>@, @<interactive>@) carry
-    -- no source span. We keep the *errors* — a @<no location info>@ error is a
-    -- genuine load failure (e.g. a home-unit GHC plugin that can't be loaded in
-    -- @--enable-multi-repl@) and must not be silently dropped — but discard
-    -- location-less warnings, which are just noise without a file to attach to.
-    toMsg (GMessage m) | isLocationLess m.file, m.severity /= GError = Nothing
-    toMsg (GMessage m) =
-        Just
-            Diagnostic
-                { severity = case m.severity of
-                    GWarning -> SWarning
-                    GError -> SError
-                , file = rel m.file
-                , line = m.startPos.line
-                , col = m.startPos.col
-                , endLine = m.endPos.line
-                , endCol = m.endPos.col
-                , title = extractTitle (map toString m.messageLines)
-                , text = unlines (map toText m.messageLines)
-                }
-    toMsg _ = Nothing
-
-
--- | Extract a short human-readable title from GHCi message lines.
---
--- The header line (@"file:line:col: severity: rest"@) is the first element.
--- The human-readable text is either:
---
---   * Inline, after @"error:"@ \/ @"warning:"@ in the header (old GHC style), or
---   * On subsequent indented lines (new GHC style, when header ends with a
---     diagnostic code such as @[GHC-83865]@ or @[-Wmissing-deriving-strategies]@).
---
--- Source-display lines (@"39 | ..."@, @"   | ^^^^"@) are skipped when
--- scanning body lines for content.
-extractTitle :: [String] -> Text
-extractTitle [] = ""
-extractTitle (header : body) =
-    fromMaybe (firstBodyLine body) (inlineFromHeader (toString (stripAnsi (toText header))))
-  where
-    inlineFromHeader :: String -> Maybe Text
-    inlineFromHeader h =
-        let lower = map toLower h
-        in  case headerAfter "error:" lower h <|> headerAfter "warning:" lower h of
-                Nothing -> Nothing
-                Just rest ->
-                    let content = stripDiagCodes (dropWhile isSpace rest)
-                    in  if null content then Nothing else Just (toText content)
-
-    headerAfter :: String -> String -> String -> Maybe String
-    headerAfter needle haystack original =
-        fmap (\i -> drop (i + length needle) original)
-            $ List.findIndex (needle `List.isPrefixOf`) (List.tails haystack)
-
-    stripDiagCodes :: String -> String
-    stripDiagCodes s = case dropWhile isSpace s of
-        '[' : rest ->
-            let after = dropWhile isSpace (drop 1 (dropWhile (/= ']') rest))
-            in  stripDiagCodes after
-        other -> other
-
-    firstBodyLine :: [String] -> Text
-    firstBodyLine xs =
-        case [ t
-             | x <- xs
-             , let t = dropWhile isSpace (toString (stripAnsi (toText x)))
-             , not (null t)
-             , not (isSourceLine t)
-             ] of
-            (t : _) -> toText t
-            [] -> ""
-
-    isSourceLine :: String -> Bool
-    isSourceLine s = case dropWhile (\c -> isDigit c || c == ' ') s of
-        ('|' : _) -> True
-        _ -> not (null s) && all (\c -> c `elem` ("^~_ " :: String)) s
diff --git a/src/Tricorder/Daemon/GhciSession/GhciProcess.hs b/src/Tricorder/Daemon/GhciSession/GhciProcess.hs
deleted file mode 100644
--- a/src/Tricorder/Daemon/GhciSession/GhciProcess.hs
+++ /dev/null
@@ -1,590 +0,0 @@
-module Tricorder.Daemon.GhciSession.GhciProcess
-    ( Config (..)
-    , GhciProcess (..)
-    , GhciProcessError (..)
-    , UnexpectedExit (..)
-    , SessionState (..)
-    , InterruptDecision (..)
-    , decideInterrupt
-    , waitForBannerOrFail
-    , withGhciProcess
-    , execGhci
-    , drainUntil
-    , interruptGhci
-    , terminateGhciProcess
-    , collectGhciResult
-    , reloadGhci
-    , addGhci
-    , unaddGhci
-    )
-where
-
-import Atelier.Effects.Conc (Conc)
-import Atelier.Effects.File (BufferMode (..), File, Handle)
-import Atelier.Effects.Log (Log)
-import Atelier.Effects.Process
-    ( Process
-    , RunningProcess
-    , createPipe
-    , getStderr
-    , getStdin
-    , getStdout
-    , setStderr
-    , setStdin
-    , setStdout
-    , setWorkingDir
-    , shell
-    )
-import Atelier.Effects.Timeout (Timeout, timeout)
-import Control.Concurrent.STM (TVar, modifyTVar', readTVar, retry, writeTVar)
-import Data.Default (Default (..))
-import Data.Sequence ((|>))
-import Data.Time.Units (Second)
-import Effectful.Concurrent (Concurrent)
-import Effectful.Concurrent.STM (atomically, newTVarIO)
-import Effectful.Exception (finally, throwIO, trySync)
-
-import Atelier.Effects.Conc qualified as Conc
-import Atelier.Effects.File qualified as File
-import Atelier.Effects.Log qualified as Log
-import Atelier.Effects.Process qualified as Process
-import Data.Text qualified as T
-
-import Tricorder.Daemon.GhciSession.GhciParser
-    ( GhciLoading (..)
-    , LoadResult (..)
-    , collectResult
-    , parseProgressLine
-    , parseShowModules
-    , parseShowTargets
-    , stripAnsi
-    , unattributedFailure
-    )
-import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))
-
-
--- | Configuration for GHCi process management.
-data Config = Config
-    { startupTimeout :: Second
-    -- ^ How long to wait for the GHCi version banner on startup.
-    , shutdownTimeout :: Second
-    -- ^ How long to wait for the process to exit gracefully before force-killing.
-    , extraSetupCommands :: [Text]
-    -- ^ Additional GHCi commands to send after the fixed setup block.
-    -- Use this to inject session-wide options such as @:set -XOverloadedStrings@.
-    }
-
-
-instance Default Config where
-    def =
-        Config
-            { startupTimeout = 60
-            , shutdownTimeout = 5
-            , extraSetupCommands = []
-            }
-
-
-data SessionState = Idle Int | Busy Int
-    deriving stock (Eq, Show)
-
-
--- | The outcome of consulting the 'SessionState' when an interrupt arrives.
---
--- 'NoOpIdle' means the GHCi session is at the prompt — sending SIGINT and a
--- sync marker would dirty the buffers and desync the next 'execGhci'.
--- 'SendInterruptFor n' means a command was in flight (state 'Busy n') and
--- the caller should SIGINT the process group and re-write @markerFor n@ (that
--- command's own marker) to unblock the in-progress 'drainUntil'.
-data InterruptDecision
-    = NoOpIdle
-    | SendInterruptFor Int
-    deriving stock (Eq, Show)
-
-
--- | Pure state machine for 'interruptGhci'. Returns the new 'SessionState'
--- to install along with the 'InterruptDecision' the caller should act on.
-decideInterrupt :: SessionState -> (SessionState, InterruptDecision)
-decideInterrupt s@(Idle _) = (s, NoOpIdle)
-decideInterrupt (Busy n) = (Idle (n + 1), SendInterruptFor n)
-
-
--- | A handle to a running GHCi subprocess.
-data GhciProcess = GhciProcess
-    { stdin :: Handle
-    , stdout :: Handle
-    , stderr :: Handle
-    , handle :: RunningProcess Handle Handle Handle
-    , stateVar :: TVar SessionState
-    }
-
-
--- | Errors that can occur during GHCi process management.
-data GhciProcessError
-    = StartupTimeout
-    | UnexpectedExit UnexpectedExit
-    | -- | The build command exited (or printed nothing parseable) before GHCi
-      -- produced its version banner. The 'Text' is the captured stderr+stdout
-      -- output so callers can surface a useful error (e.g. cabal's dependency
-      -- resolution failure).
-      StartupFailed Text
-    deriving stock (Eq, Show)
-
-
-data UnexpectedExit = MkUnexpectedExit
-    { marker :: Text
-    , errorMessage :: Text
-    }
-    deriving stock (Eq, Show)
-
-
-instance Exception GhciProcessError
-
-
--- | Set up the GHCi protocol on an already-started process and return its
--- handle together with the output captured during startup.
---
--- The spawn and group teardown are owned by 'withGhciProcess'.
-setupGhciProcess
-    :: ( Conc :> es
-       , Concurrent :> es
-       , File :> es
-       , Log :> es
-       , Timeout :> es
-       )
-    => Config
-    -> RunningProcess Handle Handle Handle
-    -> (GhciLoading -> Eff es ())
-    -- ^ Called as each @[N of M] Compiling …@ line is streamed during the
-    -- initial-build drain, so the UI can update the progress bar live
-    -- instead of replaying everything once compilation finishes.
-    -> (GhciProcess -> Eff es ())
-    -- ^ @onReady@. Called once the 'GhciProcess' is constructed but before the
-    -- banner wait and initial-build drain — so callers can register the process
-    -- for interruption while the slow @cabal repl@ startup (dependency build
-    -- and recompilation) is still in progress.
-    -> Eff es (GhciProcess, [Text])
-setupGhciProcess config p onProgress onReady = do
-    let inp = getStdin p
-        out = getStdout p
-        err = getStderr p
-    File.hSetBuffering inp LineBuffering
-    File.hSetBuffering out LineBuffering
-    File.hSetBuffering err LineBuffering
-
-    -- Register the process for interruption before the (possibly slow) banner
-    -- wait, so an interrupt during @cabal repl@ startup can terminate it.
-    stateVar <- newTVarIO (Idle 0)
-    let ghciProcess =
-            GhciProcess
-                { stdin = inp
-                , stdout = out
-                , stderr = err
-                , handle = p
-                , stateVar = stateVar
-                }
-    onReady ghciProcess
-
-    -- Send a blank line to kick GHCi into producing output
-    File.hPutTextLn inp ""
-    File.hFlush inp
-
-    -- Wait for the version banner. We concurrently capture stderr so that if
-    -- the build command exits before printing a banner (e.g. cabal's
-    -- dependency resolution fails) we can surface its error output.
-    waitForBannerOrFail config.startupTimeout out err
-
-    -- Send fixed setup commands (protocol requirements)
-    File.hPutTextLn inp ":set prompt \"\""
-    File.hPutTextLn inp ":set prompt-cont \"\""
-    -- Send any caller-supplied extra setup commands
-    for_ config.extraSetupCommands \c ->
-        File.hPutTextLn inp c
-    File.hFlush inp
-
-    -- Sync: drain until marker 1 seen, then set counter to 2.
-    -- Capture lines from both streams — the stderr output contains the initial
-    -- compilation progress and any startup diagnostics. The line hook fires
-    -- 'onProgress' for each "[N of M] Compiling …" line as it arrives, so the
-    -- UI can update the progress bar live during the initial build.
-    let marker1 = markerFor 1
-        hook = progressLineHook onProgress
-    sendSyncCommand inp marker1
-    initialLines <- Conc.scoped do
-        stdoutThread <- Conc.fork $ drainUntil out marker1 hook
-        stderrThread <- Conc.fork $ drainUntil err marker1 hook
-        stdoutLines <- Conc.await stdoutThread
-        stderrLines <- Conc.await stderrThread
-        pure (stdoutLines ++ stderrLines)
-    atomically $ writeTVar stateVar (Idle 2)
-
-    pure (ghciProcess, initialLines)
-
-
--- | Run a GHCi @cabal repl@ session for the duration of @action@.
---
--- The session runs in its own process group, so the whole group is torn down on
--- exit; 'quitGhci' first asks GHCi to @:quit@ for a graceful shutdown. The
--- action receives the process handle and the output captured during startup.
--- See 'setupGhciProcess' for the @onProgress@ and @onReady@ callbacks.
-withGhciProcess
-    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es, Process :> es, Timeout :> es)
-    => Config
-    -> RenderedCommand stage
-    -> FilePath
-    -> (GhciLoading -> Eff es ())
-    -> (GhciProcess -> Eff es ())
-    -> (GhciProcess -> [Text] -> Eff es a)
-    -> Eff es a
-withGhciProcess config cmd dir onProgress onReady action =
-    Process.withProcessGroup processConfig \p -> do
-        (ghciProcess, initialLines) <- setupGhciProcess config p onProgress onReady
-        action ghciProcess initialLines `finally` quitGhci config ghciProcess
-  where
-    processConfig =
-        setStdin createPipe
-            $ setStdout createPipe
-            $ setStderr createPipe
-            $ setWorkingDir dir
-            $ shell
-            $ toString cmd.getRenderedCommand
-
-
--- | Execute a command in GHCi and return the combined stdout+stderr output
--- lines. The @onProgress@ callback fires for each @[N of M] Compiling …@ line
--- as it arrives, so reload/add/unadd progress is streamed live to the UI.
--- Pass @\\_ -> pure ()@ for commands that do not trigger compilation.
-execGhci
-    :: ( Conc :> es
-       , Concurrent :> es
-       , File :> es
-       , Log :> es
-       )
-    => GhciProcess -> Text -> (GhciLoading -> Eff es ()) -> Eff es [Text]
-execGhci ghciProcess command onProgress = do
-    n <- atomically do
-        readTVar ghciProcess.stateVar >>= \case
-            Idle n -> writeTVar ghciProcess.stateVar (Busy n) $> n
-            Busy _ -> retry
-    doExec n `finally` atomically (writeTVar ghciProcess.stateVar (Idle (n + 1)))
-  where
-    doExec n = do
-        let marker = markerFor n
-            hook = progressLineHook onProgress
-        File.hPutTextLn ghciProcess.stdin command
-        File.hFlush ghciProcess.stdin
-        sendSyncCommand ghciProcess.stdin marker
-        -- Scoped so that an exception from one drain (e.g. 'UnexpectedExit'
-        -- when the underlying process is terminated mid-command) is
-        -- contained here, re-raised by 'await', and caught by the caller's
-        -- 'trySync'. Without 'scoped', Ki propagates the exception to the
-        -- \*ambient* scope — typically the builder's listener scope — which
-        -- tears down the whole builder loop instead of just failing this
-        -- one command.
-        (stdoutLines, stderrLines) <- Conc.scoped do
-            stdoutThread <- Conc.fork $ drainUntil ghciProcess.stdout marker hook
-            stderrThread <- Conc.fork $ drainUntil ghciProcess.stderr marker hook
-            (,) <$> Conc.await stdoutThread <*> Conc.await stderrThread
-        pure (stdoutLines ++ stderrLines)
-
-
--- | Interrupt the currently running GHCi command (if any).
---
--- If a command is in progress (state 'Busy n'), sends SIGINT to the GHCi
--- process group and re-writes /that command's own/ sync marker (@markerFor n@)
--- so the in-progress 'drainUntil', which is waiting for exactly that marker,
--- unblocks. The next command runs as @n + 1@ and waits for @markerFor (n + 1)@,
--- so any duplicate @markerFor n@ left in the buffers is skipped by 'drainUntil'
--- rather than mistaken for the next command's marker.
---
--- When GHCi is 'Idle' this is a true no-op: sending SIGINT and a sync marker to
--- an idle GHCi leaves leftover marker output in the buffers.
-interruptGhci :: (Concurrent :> es, File :> es, Process :> es) => GhciProcess -> Eff es ()
-interruptGhci ghciProcess = do
-    decision <- atomically do
-        s <- readTVar ghciProcess.stateVar
-        let (s', d) = decideInterrupt s
-        writeTVar ghciProcess.stateVar s'
-        pure d
-    case decision of
-        NoOpIdle -> pure ()
-        SendInterruptFor n -> do
-            Process.interruptProcessGroup ghciProcess.handle
-            sendSyncCommand ghciProcess.stdin (markerFor n)
-
-
--- | Forcefully terminate a GHCi process and its whole group.
---
--- Stronger than 'interruptGhci': intended for one-shot processes (such as the
--- per-suite @cabal repl test:…@ used by the test runner) where SIGINT is
--- insufficient — test frameworks like @hspec@ and @tasty@ install SIGINT
--- handlers that finalise the current run rather than aborting it. Safe to call
--- from another thread while the session is running.
-terminateGhciProcess :: (Process :> es) => GhciProcess -> Eff es ()
-terminateGhciProcess ghciProcess =
-    Process.terminateProcessGroup ghciProcess.handle
-
-
--- | Ask GHCi to @:quit@ and wait briefly for it to exit cleanly, letting it
--- shut down gracefully before the enclosing 'withGhciProcess' tears down the
--- group. Never throws.
-quitGhci :: (File :> es, Process :> es, Timeout :> es) => Config -> GhciProcess -> Eff es ()
-quitGhci config ghciProcess = do
-    void $ trySync $ do
-        File.hPutTextLn ghciProcess.stdin ":quit"
-        File.hFlush ghciProcess.stdin
-    void $ timeout config.shutdownTimeout (Process.waitExitCode ghciProcess.handle)
-
-
--- ---------------------------------------------------------------------------
--- Internal helpers
-
--- | Build the finish marker for counter value @n@.
-markerFor :: Int -> Text
-markerFor n = markerPrefix <> show n <> "~#"
-
-
--- | The prefix shared by all finish markers.
-markerPrefix :: Text
-markerPrefix = "#~TRI-FINISH-"
-
-
--- | Write the synchronisation Haskell statements to GHCi stdin.
---
--- After each user command, these cause GHCi to emit the finish marker on both
--- stdout and stderr, so 'drainUntil' knows when to stop.
---
--- The marker is emitted as two standalone, fully-qualified statements — one per
--- stream — using only 'System.IO.hPutStrLn'. This is deliberate: a
--- SIGINT-interrupted ':reload' empties GHCi's interactive scope, dropping the
--- implicit @import Prelude@, so bare names like @putStrLn@ (and even the @>>@
--- operator) fall out of scope. A marker built from those would /error/ instead
--- of printing — its marker would never appear and 'drainUntil' would block
--- forever waiting for it (the "stuck Building…" stall). Fully-qualified
--- 'System.IO.hPutStrLn' resolves via GHCi's implicit qualified imports even
--- with an emptied scope, so the marker survives an interrupt.
-sendSyncCommand :: (File :> es) => Handle -> Text -> Eff es ()
-sendSyncCommand h marker = do
-    -- Use show to produce a valid Haskell string literal for the marker text.
-    let markerLit = toText (show @String (toString marker)) -- e.g. "\"#~TRI-FINISH-3~#\""
-    File.hPutTextLn h ("System.IO.hPutStrLn System.IO.stdout " <> markerLit)
-    File.hPutTextLn h ("System.IO.hPutStrLn System.IO.stderr " <> markerLit)
-    File.hFlush h
-
-
--- | Read lines from a handle until /this command's/ finish marker is seen (or
--- EOF).
---
--- Stops only on a line containing the exact @marker@ it was given. A line
--- carrying a /different/ finish marker — a stale leftover from a prior command
--- that was interrupted mid-flight — is skipped rather than matched, so it can
--- never make a later drain return prematurely (the "0 modules"/hang desync).
--- Each ordinary line is passed to @onLine@ as it arrives, so callers can stream
--- progress without waiting for the full drain to complete. Returns accumulated
--- non-marker lines in order. Throws 'UnexpectedExit' on EOF before the marker.
-drainUntil :: (File :> es, Log :> es) => Handle -> Text -> (Text -> Eff es ()) -> Eff es [Text]
-drainUntil h marker onLine = go mempty
-  where
-    go acc = do
-        result <- trySync $ File.hGetLine h
-        case result of
-            Left ex -> do
-                let accumulatedLines = T.intercalate "\n" $ toList acc
-                    errorMessage =
-                        T.intercalate
-                            "\n"
-                            [ "Reached EOF before reading marker from GHCi."
-                            , "Was looking for marker '" <> marker <> "', but no such marker was found."
-                            , ""
-                            ]
-                            <> if T.null accumulatedLines
-                                then
-                                    T.intercalate
-                                        "\n"
-                                        [ "GHCi returned no output before we reached what we believe is EOF."
-                                        , "Got the following exception when attempting to read from GHCi:"
-                                        , toText $ displayException ex
-                                        ]
-                                else
-                                    T.intercalate
-                                        "\n"
-                                        [ "Accumulated output from GHCi so far:"
-                                        , accumulatedLines
-                                        ]
-                Log.err errorMessage
-                throwIO
-                    $ UnexpectedExit
-                    $ MkUnexpectedExit {marker, errorMessage}
-            Right line
-                | marker `T.isInfixOf` line -> pure $ toList acc
-                -- A stale marker from an interrupted command: drop it, keep going.
-                | markerPrefix `T.isInfixOf` line -> go acc
-                | otherwise -> do
-                    onLine line
-                    go $ acc |> line
-
-
--- | Convert a 'GhciLoading' progress callback into a per-line hook suitable
--- for 'drainUntil'. Non-progress lines are ignored.
-progressLineHook :: (GhciLoading -> Eff es ()) -> Text -> Eff es ()
-progressLineHook onProgress line = traverse_ onProgress (parseProgressLine line)
-
-
--- | Wait up to the given number of seconds for a GHCi version banner on
--- stdout, capturing stderr (and any non-banner stdout lines) in case the
--- build command fails before printing a banner.
---
--- Throws 'StartupFailed' with the captured output if stdout EOFs (the build
--- command exited) or 'StartupTimeout' if the banner never arrives.
-waitForBannerOrFail
-    :: ( Conc :> es
-       , Concurrent :> es
-       , File :> es
-       , Timeout :> es
-       )
-    => Second -> Handle -> Handle -> Eff es ()
-waitForBannerOrFail delay out err = do
-    capturedVar <- newTVarIO ([] :: [Text])
-    let captureLine line = atomically $ modifyTVar' capturedVar (line :)
-        drainStderr = drainUntilEof err captureLine
-        watchStdout = waitForBannerStdout out captureLine
-
-    Conc.scoped do
-        stderrThread <- Conc.fork drainStderr
-        result <- timeout delay $ trySync watchStdout
-        case result of
-            Just (Right ()) -> pure ()
-            Just (Left ex) -> do
-                -- stdout EOF before the banner: the build command exited. Wait
-                -- for the stderr drain to reach EOF too (bounded, in case the
-                -- pipe lingers) so we surface its *complete* output — e.g.
-                -- cabal's dependency-resolution error — rather than whatever
-                -- the drain happened to have read so far.
-                _ <- timeout (1 :: Second) (Conc.await stderrThread)
-                captured <- atomically $ readTVar capturedVar
-                throwIO $ StartupFailed $ fromMaybe (startupExitedMessage ex) (renderCapturedLines captured)
-            Nothing -> do
-                captured <- atomically $ readTVar capturedVar
-                throwIO $ maybe StartupTimeout StartupFailed (renderCapturedLines captured)
-
-
-waitForBannerStdout :: (File :> es) => Handle -> (Text -> Eff es ()) -> Eff es ()
-waitForBannerStdout h captureLine = go
-  where
-    isVersionLine :: Text -> Bool
-    isVersionLine line =
-        let stripped = stripAnsi line
-        in  "GHCi, version " `T.isInfixOf` stripped
-                || "GHCJSi, version " `T.isInfixOf` stripped
-                || "Clashi, version " `T.isInfixOf` stripped
-
-    go = do
-        result <- trySync $ File.hGetLine h
-        case result of
-            Left ex -> throwIO ex
-            Right line ->
-                if isVersionLine line
-                    then
-                        pure ()
-                    else do
-                        captureLine line
-                        go
-
-
-drainUntilEof :: (File :> es) => Handle -> (Text -> Eff es ()) -> Eff es ()
-drainUntilEof h onLine = go
-  where
-    go = do
-        result <- trySync $ File.hGetLine h
-        case result of
-            Left _ -> pure ()
-            Right line -> onLine line >> go
-
-
--- | Render the captured (reverse-order) output lines into an error message,
--- stripping ANSI escapes and dropping blank lines. Returns 'Nothing' when
--- nothing useful remains, so callers can fall back to a generic message.
-renderCapturedLines :: [Text] -> Maybe Text
-renderCapturedLines capturedRev =
-    let cleaned = filter (not . T.null . T.strip) (map stripAnsi (reverse capturedRev))
-    in  if null cleaned then Nothing else Just (T.intercalate "\n" cleaned)
-
-
--- | Fallback message when the build command exited before the banner without
--- printing anything we could capture.
-startupExitedMessage :: SomeException -> Text
-startupExitedMessage ex =
-    "Build command exited before GHCi started: " <> toText (displayException ex)
-
-
--- | Parse already-drained GHCi output lines into a 'LoadResult', fetching the
--- current module list via @:show modules@.
---
--- Progress is emitted live by 'drainUntil' as lines arrive, so no replay
--- callback is needed here — this function only assembles the final result.
-collectGhciResult
-    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)
-    => GhciProcess
-    -> [Text]
-    -> FilePath
-    -> Eff es LoadResult
-collectGhciResult process lines' projectRoot = do
-    let noProgress = \_ -> pure ()
-    moduleLines <- execGhci process ":show modules" noProgress
-    targetLines <- execGhci process ":show targets" noProgress
-    let result =
-            collectResult
-                projectRoot
-                lines'
-                (parseShowModules moduleLines)
-                (parseShowTargets targetLines)
-    -- A failed load with no located error produces only the synthetic
-    -- 'unattributedFailure'. The parsed diagnostics tell the user nothing in
-    -- that case, so log the raw GHCi output — it's the only way to see what
-    -- actually went wrong, and the synthetic diagnostic points here.
-    when (any (== unattributedFailure) result.diagnostics)
-        $ Log.info
-        $ "GHCi reported a failed load with no located error. Full GHCi output:\n"
-            <> T.unlines lines'
-    pure result
-
-
--- | Execute @:reload@ and return the assembled 'LoadResult'. Progress events
--- fire live via @onProgress@ as each @[N of M] Compiling …@ line is read.
-reloadGhci
-    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)
-    => GhciProcess
-    -> FilePath
-    -> (GhciLoading -> Eff es ())
-    -> Eff es LoadResult
-reloadGhci process projectRoot onProgress = do
-    reloadLines <- execGhci process ":reload" onProgress
-    collectGhciResult process reloadLines projectRoot
-
-
--- | Execute @:add@ for the given file and return the assembled 'LoadResult'.
--- Progress events fire live via @onProgress@ as compilation proceeds.
-addGhci
-    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)
-    => GhciProcess
-    -> FilePath -- the file to :add
-    -> FilePath -- projectRoot
-    -> (GhciLoading -> Eff es ())
-    -> Eff es LoadResult
-addGhci process filePath projectRoot onProgress = do
-    addLines <- execGhci process (":add " <> T.pack filePath) onProgress
-    collectGhciResult process addLines projectRoot
-
-
--- | Execute @:unadd@ for the given module and return the assembled
--- 'LoadResult'. Progress events fire live via @onProgress@ as compilation
--- proceeds.
-unaddGhci
-    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)
-    => GhciProcess
-    -> Text -- the module name to :unadd
-    -> FilePath -- projectRoot
-    -> (GhciLoading -> Eff es ())
-    -> Eff es LoadResult
-unaddGhci process moduleName projectRoot onProgress = do
-    unaddLines <- execGhci process (":unadd " <> moduleName) onProgress
-    collectGhciResult process unaddLines projectRoot
diff --git a/src/Tricorder/Daemon/HookDispatch.hs b/src/Tricorder/Daemon/HookDispatch.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/HookDispatch.hs
@@ -0,0 +1,34 @@
+module Tricorder.Daemon.HookDispatch (main) where
+
+import Atelier.Effects.FileWatcher (FileEvent (..))
+import Atelier.Effects.Process (Process)
+import Atelier.Effects.PubSub.Sub (Sub)
+import Effectful.Reader.Static (Reader)
+
+import Atelier.Effects.PubSub.Sub qualified as Sub
+import System.FilePath qualified as Path
+
+import Tricorder.Daemon.Watch (WatchedFile (..))
+import Tricorder.Runtime (ProjectRoot)
+import Tricorder.Session (Session (..))
+import Tricorder.Session.HaskellHooks (HaskellHooks (..))
+import Tricorder.Session.Hooks (Hooks (..))
+
+import Tricorder.Session.Hook qualified as Hook
+
+
+main :: (Process :> es, Reader ProjectRoot :> es, Sub WatchedFile :> es) => Session -> Eff es Void
+main session =
+    Sub.listen_ \case
+        (WatchedFile path event)
+            | Path.takeExtension path == "hs"
+            , Just hooks <- session.hooks.haskell ->
+                runHsHook hooks event
+            | otherwise -> pure ()
+
+
+runHsHook :: (Process :> es, Reader ProjectRoot :> es) => HaskellHooks -> FileEvent -> Eff es ()
+runHsHook hooks = \case
+    Added -> whenJust hooks.fileAdded Hook.run
+    Modified -> whenJust hooks.fileModified Hook.run
+    Removed -> whenJust hooks.fileRemoved Hook.run
diff --git a/src/Tricorder/Daemon/Hpack.hs b/src/Tricorder/Daemon/Hpack.hs
--- a/src/Tricorder/Daemon/Hpack.hs
+++ b/src/Tricorder/Daemon/Hpack.hs
@@ -6,20 +6,22 @@
 import Atelier.Effects.Debounce (Debounce)
 import Atelier.Effects.FileWatcher (FileWatcher)
 import Atelier.Effects.Log (Log)
-import Atelier.Effects.Publishing (runPubSub)
-import Atelier.Effects.Publishing.Pub (Pub)
+import Atelier.Effects.PubSub.Pub (Pub)
 import Effectful.Reader.Static (Reader)
 
 import Atelier.Effects.Conc qualified as Conc
 import Atelier.Effects.FileWatcher qualified as FileWatcher
 import Atelier.Effects.Log qualified as Log
-import Atelier.Effects.Publishing.Pub qualified as Pub
-import Atelier.Effects.Publishing.Sub qualified as Sub
+import Atelier.Effects.PubSub qualified as PubSub
+import Atelier.Effects.PubSub.Pub qualified as Pub
+import Atelier.Effects.PubSub.Sub qualified as Sub
 import Data.List qualified as List
 import Effectful.Reader.Static qualified as Reader
 
 import Tricorder.Daemon.Hpack.Effect (Hpack)
 import Tricorder.Runtime (ProjectRoot (..))
+import Tricorder.Session (Session (..))
+import Tricorder.Session.GenerateWithHpack (GenerateWithHpack (..))
 
 import Tricorder.Daemon.Hpack.Effect qualified as Hpack
 
@@ -34,13 +36,14 @@
        , Log :> es
        , Reader ProjectRoot :> es
        )
-    => Eff es ()
-main = runPubSub @RePack $ Conc.scoped do
-    hpackInPath <- Hpack.hpackIsInPath
-    when hpackInPath do
-        projectRoot <- Reader.ask
-        Conc.fork_ $ Sub.listen_ \(RePack path) -> runHpack path
-        vacuous $ watchPackageYaml projectRoot
+    => Session -> Eff es ()
+main session = PubSub.run @RePack $ Conc.scoped do
+    when (coerce session.generateWithHpack) do
+        hpackInPath <- Hpack.hpackIsInPath
+        when hpackInPath do
+            projectRoot <- Reader.ask
+            Conc.fork_ $ Sub.listen_ \(RePack path) -> runHpack path
+            vacuous $ watchPackageYaml projectRoot
 
 
 runHpack :: (Hpack :> es, Log :> es) => FilePath -> Eff es ()
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
@@ -1,6 +1,5 @@
 module Tricorder.Daemon.Main (main) where
 
-import Atelier.Effects.Cache (runCacheTtl)
 import Atelier.Effects.Chan (runChan)
 import Atelier.Effects.Clock (runClock)
 import Atelier.Effects.Conc (runConc)
@@ -12,52 +11,53 @@
 import Atelier.Effects.FileSystem (runFileSystemIO)
 import Atelier.Effects.FileWatcher (runFileWatcherIO)
 import Atelier.Effects.Process (runProcessIO)
-import Atelier.Effects.Publishing (runPubSub)
 import Atelier.Effects.Timeout (runTimeout)
 import Atelier.Signal (installTerminationHandler)
 import Data.Default (def)
 import Effectful (runEff)
 import Effectful.Concurrent (runConcurrent)
-import Effectful.Reader.Static (runReader)
-import Effectful.State.Static.Shared (evalState)
-import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery)
 
-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
+import Atelier.Effects.PubSub qualified as PubSub
+import Atelier.Effects.PubSub.Pub qualified as Pub
+import Atelier.Effects.PubSub.Sub qualified as Sub
+import Effectful.Reader.Static qualified as Reader
+import Effectful.State.Static.Shared qualified as State
 
 import Tricorder.Build (BuildId (..), BuildPhase)
-import Tricorder.Config (inputLoadedConfig)
-import Tricorder.Daemon.GhciSession (runGhciSession)
+import Tricorder.Build.Changes (CabalChangeDetected, SourceChangeDetected)
+import Tricorder.Config (asInput)
+import Tricorder.Daemon.Watch (WatchedFile)
 import Tricorder.Logging (runLogging)
 import Tricorder.Runtime (runLogPath, runProjectRoot, runRuntimeDir, runSocketPath)
-import Tricorder.Session (inputSession)
 import Tricorder.Session.CabalFile (inputCabalFiles)
-import Tricorder.Session.IdleTimeout (IdleTimeout)
-import Tricorder.Session.Repl (Repl)
-import Tricorder.Socket.UnixSocket (runUnixSocketIO)
 import Tricorder.SourceLookup.GhcPkg (runGhcPkgIO)
-import Tricorder.SourceLookup.PackageId (PackageId)
 
+import Tricorder.Build qualified as BuildPhase
+import Tricorder.Config.LoadedConfig qualified as LoadedConfig
 import Tricorder.Daemon.Core qualified as Core
 import Tricorder.Daemon.EvalCommentRunner qualified as EvalCommentRunner
+import Tricorder.Daemon.Ghci.Session qualified as Ghci.Session
+import Tricorder.Daemon.HookDispatch qualified as HookDispatch
+import Tricorder.Daemon.Hpack qualified as Hpack
 import Tricorder.Daemon.Hpack.Effect qualified as Hpack
 import Tricorder.Daemon.IdleTimer qualified as IdleTimer
 import Tricorder.Daemon.TestRunner qualified as TestRunner
-import Tricorder.Session.Repl qualified as Repl
+import Tricorder.Daemon.Watch qualified as Watch
+import Tricorder.Session qualified as Session
 import Tricorder.Session.StackProject qualified as StackProject
+import Tricorder.Session.State qualified as Session.State
+import Tricorder.Session.State.Invalid qualified as Session.State.Result.Invalid
+import Tricorder.Session.State.Result qualified as Session.State.Result
 import Tricorder.Socket.Server qualified as Server
-import Tricorder.SourceLookup qualified as SourceLookup
-import Tricorder.SourceLookup.Hackage qualified as Hackage
-import Tricorder.SourceLookup.PackageStore qualified as PackageStore
+import Tricorder.Socket.UnixSocket qualified as UnixSocket
 import Tricorder.Version qualified as Version
 import Tricorder.Waiters qualified as Waiters
 
 
--- | Run the daemon for the given project root.
--- Blocks forever; all work happens inside the component system.
 main :: IO ()
 main =
     runEff
@@ -77,36 +77,51 @@
         . runSocketPath
         . runLogPath
         . runEnv
-        . inputLoadedConfig
+        . LoadedConfig.asInput
         . runLogging
         . runChan
         . StackProject.inputWithCache
         . inputCabalFiles
-        . inputSession
-        . runReader @CacheConfig.Config def
-        . runCacheTtl @ModuleName @PackageId
-        . runCacheTtl @(PackageId, SourceQuery) @SourceLookup.ModuleSourceResult
         . runProcessIO
         . runGhcPkgIO
-        . runUnixSocketIO
-        . runGhciSession
-        . evalState (BuildId 1)
+        . UnixSocket.runIO
+        . Ghci.Session.run
+        . State.evalState (BuildId 1)
         . Input.fromState @BuildId
-        . evalState Repl.Unknown
-        . Input.fromState @Repl
-        . evalState @IdleTimeout def
-        . Input.fromState @IdleTimeout
-        . IdleTimer.quitOnTimeout
-        . runPubSub @BuildPhase
         . Hpack.run
-        . Hackage.run
-        . PackageStore.run
         . EvalCommentRunner.run
         . TestRunner.run
         . Waiters.run
+        . PubSub.run @BuildPhase
+        . PubSub.run @CabalChangeDetected
+        . PubSub.run @SourceChangeDetected
+        . PubSub.run @WatchedFile
+        . Tricorder.Config.asInput
+        . Session.State.eval
+        . Input.withEff
+            ( maybe def Session.idleTimeout
+                . Session.State.Result.toMaybe
+                <$> Session.State.currentSession
+            )
+        . IdleTimer.quitOnTimeout
         $ do
             installTerminationHandler
             Log.info $ "Starting tricorder " <> Version.gitHash
-            Conc.fork_ Core.main
+            Conc.fork_ $ Sub.listen_ Watch.publishChange
             Conc.fork_ Server.main
-            Conc.awaitAll
+
+            vacuous $ Session.State.withSession \sessionResult -> do
+                case sessionResult of
+                    Session.State.Result.Invalid invalid -> do
+                        let msg =
+                                "Unable to start builder due to invalid session: "
+                                    <> Session.State.Result.Invalid.reason invalid
+                        Log.err msg
+                        Pub.publish $ BuildPhase.Failed msg
+                    Session.State.Result.Ok session -> do
+                        void $ Conc.fork $ Hpack.main session
+                        Conc.fork_ $ HookDispatch.main session
+
+                        root <- Reader.ask
+                        Conc.fork_ $ Watch.files root session
+                        vacuous $ Core.main session
diff --git a/src/Tricorder/Daemon/TestRunner.hs b/src/Tricorder/Daemon/TestRunner.hs
--- a/src/Tricorder/Daemon/TestRunner.hs
+++ b/src/Tricorder/Daemon/TestRunner.hs
@@ -53,13 +53,7 @@
 import Data.List qualified as List
 import Data.Text qualified as T
 
-import Tricorder.Daemon.GhciSession.GhciParser (GhciLoading (..), parseProgressLine)
-import Tricorder.Daemon.GhciSession.GhciProcess
-    ( GhciProcessError (..)
-    , UnexpectedExit (..)
-    , execGhci
-    , withGhciProcess
-    )
+import Tricorder.Daemon.Ghci.Loading (Loading (..))
 import Tricorder.Runtime (ProjectRoot (..))
 import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))
 import Tricorder.Session.Stage (Stage (..))
@@ -70,6 +64,9 @@
 import Tricorder.TestOutput (parseHspecDuration, parseHspecOutput)
 
 import Tricorder.Build.Test qualified as Test
+import Tricorder.Daemon.Ghci.Loading qualified as Ghci (Loading)
+import Tricorder.Daemon.Ghci.Process qualified as Ghci.Process
+import Tricorder.Daemon.Ghci.Result qualified as Ghci.Result
 
 
 data TestRunner :: Effect where
@@ -117,7 +114,7 @@
        , Reader ProjectRoot :> es
        , Timeout :> es
        )
-    => (GhciLoading -> Eff es ())
+    => (Ghci.Loading -> Eff es ())
     -> TestTimeout
     -> RenderedCommand 'Test
     -> Eff es Test.Suite
@@ -137,7 +134,7 @@
             unlessM (File.hIsEOF h) do
                 line <- File.hGetLine h
                 atomically $ modifyTVar' outputVar (|> line)
-                traverse_ onProgress (parseProgressLine line)
+                traverse_ onProgress (Ghci.Result.parseProgressLine line)
                 drain h
 
         runToCompletion p = do
@@ -192,14 +189,14 @@
        , Reader ProjectRoot :> es
        , Timeout :> es
        )
-    => (GhciLoading -> Eff es ())
+    => (Ghci.Loading -> Eff es ())
     -> TestTimeout
     -> RenderedCommand 'Test
     -> Eff es Test.Suite
 runTestSuiteWithGHCi onProgress testTimeout cmd = do
     ProjectRoot projectRoot <- ask
     result <- trySync
-        $ withGhciProcess
+        $ Ghci.Process.with
             def
             cmd
             projectRoot
@@ -207,22 +204,22 @@
             noReady
             \ghci _ ->
                 case testTimeout of
-                    TestTimeout secs | secs <= 0 -> Right <$> execGhci ghci ":main" noProgress
+                    TestTimeout secs | secs <= 0 -> Right <$> Ghci.Process.exec ghci ":main" noProgress
                     TestTimeout secs ->
                         let duration = fromIntegral secs :: Second
                         in  maybeToRight secs
-                                <$> timeout duration (execGhci ghci ":main" noProgress)
+                                <$> timeout duration (Ghci.Process.exec ghci ":main" noProgress)
     case result of
         Left ex -> case fromException ex of
-            Just (e :: GhciProcessError) -> case e of
-                UnexpectedExit (MkUnexpectedExit marker msg) ->
+            Just (e :: Ghci.Process.Error) -> case e of
+                Ghci.Process.UnexpectedExit (Ghci.Process.MkUnexpectedExit marker msg) ->
                     pure
                         $ Test.SuiteErrored
                         $ Test.SuiteError
                             { message = "Test suite failed while waiting for marker (" <> marker <> "):\n" <> msg
                             }
-                StartupTimeout -> pure $ Test.SuiteErrored $ Test.SuiteError "Test suite timed out before it could finish"
-                StartupFailed msg ->
+                Ghci.Process.StartupTimeout -> pure $ Test.SuiteErrored $ Test.SuiteError "Test suite timed out before it could finish"
+                Ghci.Process.StartupFailed msg ->
                     pure
                         $ Test.SuiteErrored
                         $ Test.SuiteError
@@ -279,7 +276,7 @@
             Right r : rest -> put rest >> pure r
 
 
-loadingToProgress :: GhciLoading -> Test.Suite
+loadingToProgress :: Ghci.Loading -> Test.Suite
 loadingToProgress loading =
     Test.SuiteRunning
         $ Just
diff --git a/src/Tricorder/Daemon/Watch.hs b/src/Tricorder/Daemon/Watch.hs
--- a/src/Tricorder/Daemon/Watch.hs
+++ b/src/Tricorder/Daemon/Watch.hs
@@ -17,13 +17,13 @@
     , dirWhere
     , excluding
     )
-import Atelier.Effects.Publishing.Pub (Pub)
+import Atelier.Effects.PubSub.Pub (Pub)
 import System.FilePath (takeExtension, takeFileName)
 import Text.Regex.TDFA (ExecOption (..), blankCompOpt, blankExecOpt, match)
 import Text.Regex.TDFA.TDFA (patternToRegex)
 
 import Atelier.Effects.FileWatcher qualified as FileWatcher
-import Atelier.Effects.Publishing.Pub qualified as Pub
+import Atelier.Effects.PubSub.Pub qualified as Pub
 
 import Tricorder.Build.Changes
     ( CabalChangeDetected (..)
diff --git a/src/Tricorder/Logging.hs b/src/Tricorder/Logging.hs
--- a/src/Tricorder/Logging.hs
+++ b/src/Tricorder/Logging.hs
@@ -1,19 +1,26 @@
 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 Atelier.Effects.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.Input qualified as Input
 import Atelier.Effects.Log qualified as Log
 
+import Tricorder.Config.LoadedConfig (LoadedConfig)
 import Tricorder.Runtime (LogPath (..))
 
+import Tricorder.Config.LoadedConfig qualified as LoadedConfig
 
+
 runLogging
     :: ( Env :> es
        , File :> es
@@ -26,16 +33,25 @@
     path <- asks @LogPath (.getLogPath)
     File.withFile path AppendMode \h -> do
         File.hSetBuffering h LineBuffering
-        sev <- getMinimumSeverity
-        runLogToHandle h sev act
+        (maybeErr, sev) <- getMinimumSeverity
+        runLogToHandle h sev do
+            whenJust maybeErr \err -> Log.err err
+            act
 
 
-getMinimumSeverity :: (Env :> es, Input LoadedConfig :> es) => Eff es Severity
+getMinimumSeverity
+    :: ( Env :> es
+       , Input LoadedConfig :> es
+       )
+    => Eff es (Maybe Text, 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
+        Just sev -> pure (Nothing, sev)
+        Nothing -> LoadedConfig.asNestedInput @"session.logging" @Log.Config do
+            logConfigRes <- Input.input
+            case logConfigRes of
+                Left err -> do
+                    pure (Just $ "failed to parse log config: " <> err, Log.INFO)
+                Right cfg ->
+                    pure (Nothing, cfg.minimumSeverity)
diff --git a/src/Tricorder/Session.hs b/src/Tricorder/Session.hs
--- a/src/Tricorder/Session.hs
+++ b/src/Tricorder/Session.hs
@@ -1,23 +1,21 @@
 module Tricorder.Session
     ( Session (..)
-    , loadSession
-    , inputSession
-    , show
+    , load
+    , display
     )
 where
 
-import Atelier.Config (LoadedConfig, extractConfig)
 import Atelier.Effects.FileSystem (FileSystem)
-import Atelier.Effects.Input (Input, input, runInputEff)
+import Atelier.Effects.Input (Input)
 import Atelier.Effects.Log (Log)
 import Data.Default (Default (..))
-import Effectful.Reader.Static (Reader, ask)
+import Effectful.Reader.Static (Reader)
 import Text.Regex.TDFA.Pattern (showPattern)
-import Prelude hiding (show)
 
+import Atelier.Effects.Input qualified as Input
 import Atelier.Effects.Log qualified as Log
 import Data.Text qualified as T
-import Prelude qualified as P
+import Effectful.Reader.Static qualified as Reader
 
 import Tricorder.Build.ByteSize (ByteSize)
 import Tricorder.Runtime (ProjectRoot (..))
@@ -40,7 +38,7 @@
 import Tricorder.Session.Target (definesCustomPrelude)
 import Tricorder.Session.TestTarget (getTestTarget)
 import Tricorder.Session.TestTimeout (TestTimeout (..))
-import Tricorder.Session.Util (indent, showList)
+import Tricorder.Session.Util (displayList, indent)
 import Tricorder.Session.WatchDirs (WatchDirs (..))
 import Tricorder.Session.WatchExclusionPatterns
     ( WatchExclusionPatterns (..)
@@ -48,6 +46,8 @@
     )
 
 import Tricorder.Build.ByteSize qualified as ByteSize
+import Tricorder.Config qualified as Tricorder
+import Tricorder.Session.Hooks qualified as Hooks
 import Tricorder.Session.Stage qualified as Stage
 import Tricorder.Session.Stage.Build.Session qualified as BuildSession
 import Tricorder.Session.Stage.Eval.Command qualified as EvalCommand
@@ -89,24 +89,30 @@
             }
 
 
-loadSession
+load
     :: ( FileSystem :> es
-       , Input LoadedConfig :> es
+       , Input (Either Text Tricorder.Config) :> es
        , Input [CabalFile] :> es
        , Log :> es
        , Reader ProjectRoot :> es
        )
-    => Eff es Session
-loadSession = do
-    projectRoot <- ask @ProjectRoot
-    loadedCfg <- input
-    Log.debug "Got config file"
-    projectFiles <- input
-    Log.debug "Got project files"
+    => Eff es (Either Text Session)
+load = do
+    cfg <- Input.input
 
-    let cfgFile = extractConfig @"session" @Config loadedCfg
-        hooks = fromMaybe def cfgFile.hooks
+    case cfg of
+        Left err -> do
+            Log.err $ "Failed to parse config: " <> err
+            pure $ Left $ "Failed to parse Tricorder config: " <> err
+        Right (Tricorder.Config cfgFile) -> Right <$> mkSession (fromMaybe def cfgFile)
 
+
+mkSession
+    :: (FileSystem :> es, Input [CabalFile] :> es, Log :> es, Reader ProjectRoot :> es)
+    => Config -> Eff es Session
+mkSession cfgFile = do
+    projectRoot <- Reader.ask @ProjectRoot
+    projectFiles <- Input.input
     warnDeprecatedConfig cfgFile
     Log.debug "Warned about deprecated config stuff"
 
@@ -171,22 +177,11 @@
             , replBuildDir = ReplBuildDir cfgFile.replBuildDir
             , testTimeout = TestTimeout cfgFile.testTimeout
             , generateWithHpack = GenerateWithHpack cfgFile.generateWithHpack
-            , hooks
+            , hooks = fromMaybe def cfgFile.hooks
             , idleTimeout = IdleTimeout $ fromIntegral cfgFile.idleTimeoutSeconds
             }
 
 
-inputSession
-    :: ( FileSystem :> es
-       , Input LoadedConfig :> es
-       , Input [CabalFile] :> es
-       , Log :> es
-       , Reader ProjectRoot :> es
-       )
-    => Eff (Input Session : es) a -> Eff es a
-inputSession = runInputEff loadSession
-
-
 -- | Warn once per session load for each deprecated top-level config key
 -- still in use, regardless of whether its replacement also takes
 -- precedence.
@@ -236,27 +231,29 @@
         _ -> pure ()
 
 
-show :: Session -> Text
-show session =
+display :: Session -> Text
+display session =
     T.intercalate
         "\n"
         [ "Loaded session"
         , "Build configuration:"
-        , indent $ BuildSession.show session.buildSession
+        , indent $ BuildSession.display session.buildSession
         , "Test configuration:"
-        , indent $ TestSession.show session.testSession
+        , indent $ TestSession.display session.testSession
         , "Eval comments configuration:"
-        , indent $ EvalSession.show session.evalSession
+        , indent $ EvalSession.display session.evalSession
         , "Watch dirs:"
-        , indent $ showList toText session.watchDirs.getWatchDirs
+        , indent $ displayList toText session.watchDirs.getWatchDirs
         , "Watch exclusion patterns:"
         , indent
-            $ showList
+            $ displayList
                 (toText . showPattern . fst)
                 session.watchExclusionPatterns.getWatchExclusionPatterns
         , "Repl build dir: " <> toText session.replBuildDir.getReplBuildDir
         , -- TODO: Remove " seconds" when TestTimeout is converted to a proper time unit.
-          "Test timeout: " <> P.show session.testTimeout.getTestTimeout <> " seconds"
-        , "Test memory limit: " <> P.show session.testMemoryLimit
-        , "Generate with hpack: " <> P.show session.generateWithHpack.getGenerateWithHpack
+          "Test timeout: " <> show session.testTimeout.getTestTimeout <> " seconds"
+        , "Test memory limit: " <> show session.testMemoryLimit
+        , "Generate with hpack: " <> show session.generateWithHpack.getGenerateWithHpack
+        , "Hooks:"
+        , indent $ Hooks.display session.hooks
         ]
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
@@ -11,24 +11,26 @@
 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, input, runInputEff)
+import Atelier.Effects.Input (Input)
 import Atelier.Effects.Log (Log)
 import Data.Traversable (for)
 import Distribution.Fields (Field (..), FieldLine (..), Name (..), readFields)
 import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe)
 import Distribution.Types.GenericPackageDescription (GenericPackageDescription)
 import Effectful.Exception (catch, throwIO)
-import Effectful.Reader.Static (Reader, ask)
+import Effectful.Reader.Static (Reader)
 import System.FilePath (normalise, takeExtension, (</>))
 import System.FilePath.Glob (compile)
 import System.IO.Error (userError)
 
 import Atelier.Effects.Env qualified as Env
 import Atelier.Effects.FileSystem qualified as FileSystem
+import Atelier.Effects.Input qualified as Input
 import Atelier.Effects.Log qualified as Log
 import Data.ByteString.Char8 qualified as BC
 import Data.List qualified as List
 import Data.Text qualified as T
+import Effectful.Reader.Static qualified as Reader
 
 import Tricorder.Runtime (ProjectRoot (..))
 import Tricorder.Session.StackProject (StackProject (..))
@@ -50,7 +52,7 @@
        , Reader ProjectRoot :> es
        )
     => Eff (Input [CabalFile] : es) a -> Eff es a
-inputCabalFiles = runInputEff $ Log.withNamespace "CabalFile" do
+inputCabalFiles = Input.withEff $ Log.withNamespace "CabalFile" do
     packageRes <- discoverPackages
     case packageRes of
         Left err -> throwIO $ userError $ toString err
@@ -108,7 +110,7 @@
        )
     => Eff es (Either Text [FilePath])
 discoverPackages = do
-    ProjectRoot projectRoot <- ask
+    ProjectRoot projectRoot <- Reader.ask
     hasStackYaml <- FileSystem.doesFileExist $ projectRoot </> "stack.yaml"
     if hasStackYaml
         then discoverStackPackages
@@ -125,7 +127,7 @@
        )
     => Eff es (Either Text [FilePath])
 discoverCabalPackages = do
-    ProjectRoot projectRoot <- ask
+    ProjectRoot projectRoot <- Reader.ask
     homeCabalFiles <- maybe [] (one . (</> ".cabal/config")) <$> Env.lookupEnv "HOME"
     let projectFilePaths = projectCabalFiles projectRoot <> homeCabalFiles
     projectFiles <- filterM doesFileExist projectFilePaths
@@ -163,8 +165,8 @@
     :: (Input StackProject :> es, Reader ProjectRoot :> es)
     => Eff es (Either Text [FilePath])
 discoverStackPackages = do
-    ProjectRoot projectRoot <- ask
-    project <- input
+    ProjectRoot projectRoot <- Reader.ask
+    project <- Input.input
     pure $ Right $ normalise . (projectRoot </>) <$> project.packages
 
 
diff --git a/src/Tricorder/Session/CommandConfig.hs b/src/Tricorder/Session/CommandConfig.hs
--- a/src/Tricorder/Session/CommandConfig.hs
+++ b/src/Tricorder/Session/CommandConfig.hs
@@ -1,6 +1,6 @@
 module Tricorder.Session.CommandConfig (CommandConfig (..)) where
 
-import Atelier.Types.QuietSnake (QuietSnake (..))
+import Atelier.Types.JSON.QuietSnake (QuietSnake (..))
 import Data.Aeson (FromJSON, ToJSON)
 import Data.Default (Default (..))
 
diff --git a/src/Tricorder/Session/CommandTemplate.hs b/src/Tricorder/Session/CommandTemplate.hs
--- a/src/Tricorder/Session/CommandTemplate.hs
+++ b/src/Tricorder/Session/CommandTemplate.hs
@@ -5,21 +5,19 @@
     , hasPlaceholder
     , targetsPlaceholder
     , targetPlaceholder
-    , show
+    , display
     )
 where
 
 import Data.Default (Default (..))
-import Prelude hiding (show)
 
 import Data.List qualified as List
 import Data.Text qualified as T
-import Prelude qualified as P
 
 import Tricorder.Session.Repl (Repl (..))
 import Tricorder.Session.Stage (Stage (..))
 import Tricorder.Session.Target (Target (..))
-import Tricorder.Session.Util (indent, showList)
+import Tricorder.Session.Util (displayList, indent)
 
 import Tricorder.Session.Target qualified as Target
 
@@ -111,13 +109,13 @@
 hasPlaceholder placeholderName template = ("{" <> placeholderName <> "}") `T.isInfixOf` template
 
 
-show :: CommandTemplate stage -> Text
-show tmpl =
+display :: CommandTemplate stage -> Text
+display tmpl =
     T.intercalate
         "\n"
         [ "Template: " <> tmpl.template
-        , "Repl: " <> P.show tmpl.repl
-        , "Template placeholder: " <> P.show tmpl.placeholder
+        , "Repl: " <> show tmpl.repl
+        , "Template placeholder: " <> show tmpl.placeholder
         , "Arguments:"
-        , indent $ showList id tmpl.arguments
+        , indent $ displayList id tmpl.arguments
         ]
diff --git a/src/Tricorder/Session/Config.hs b/src/Tricorder/Session/Config.hs
--- a/src/Tricorder/Session/Config.hs
+++ b/src/Tricorder/Session/Config.hs
@@ -1,8 +1,8 @@
 module Tricorder.Session.Config (Config (..)) where
 
-import Atelier.Types.QuietSnake (QuietSnake (..))
+import Atelier.Types.JSON.QuietSnake (QuietSnake (..))
 import Atelier.Types.WithDefaults (WithDefaults (..))
-import Data.Aeson (FromJSON (..))
+import Data.Aeson (FromJSON (..), ToJSON)
 import Data.Default (Default (..))
 
 import Tricorder.Session.CommandConfig (CommandConfig)
@@ -35,6 +35,7 @@
     -- TODO: Remove at or after version 0.8.0.0.
     }
     deriving stock (Eq, Generic, Show)
+    deriving (ToJSON) via QuietSnake Config
     deriving (FromJSON) via WithDefaults (QuietSnake Config)
 
 
diff --git a/src/Tricorder/Session/HaskellHooks.hs b/src/Tricorder/Session/HaskellHooks.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Session/HaskellHooks.hs
@@ -0,0 +1,43 @@
+module Tricorder.Session.HaskellHooks
+    ( HaskellHooks (..)
+    , display
+    )
+where
+
+import Atelier.Types.JSON.QuietSnake (QuietSnake (..))
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Default (Default (..))
+
+import Data.Text qualified as T
+
+import Tricorder.Session.Hook (Hook)
+
+import Tricorder.Session.Hook qualified as Hook
+
+
+data HaskellHooks = HaskellHooks
+    { fileAdded :: Maybe Hook
+    , fileModified :: Maybe Hook
+    , fileRemoved :: Maybe Hook
+    }
+    deriving stock (Eq, Generic, Show)
+    deriving (FromJSON, ToJSON) via QuietSnake HaskellHooks
+
+
+instance Default HaskellHooks where
+    def =
+        HaskellHooks
+            { fileAdded = Nothing
+            , fileModified = Nothing
+            , fileRemoved = Nothing
+            }
+
+
+display :: HaskellHooks -> Text
+display hooks =
+    T.intercalate
+        "\n"
+        [ "file_added: " <> maybe "<unset>" Hook.getHook hooks.fileAdded
+        , "file_modified: " <> maybe "<unset>" Hook.getHook hooks.fileModified
+        , "file_removed: " <> maybe "<unset>" Hook.getHook hooks.fileRemoved
+        ]
diff --git a/src/Tricorder/Session/Hook.hs b/src/Tricorder/Session/Hook.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Session/Hook.hs
@@ -0,0 +1,31 @@
+module Tricorder.Session.Hook
+    ( Hook (..)
+    , run
+    )
+where
+
+import Atelier.Effects.Process (Process)
+import Data.Aeson (FromJSON, ToJSON)
+import Effectful.Exception (trySync)
+import Effectful.Reader.Static (Reader)
+
+import Atelier.Effects.Process qualified as Process
+import Effectful.Reader.Static qualified as Reader
+
+import Tricorder.Runtime (ProjectRoot (..))
+
+
+newtype Hook = Hook {getHook :: Text}
+    deriving stock (Eq, Generic, Show)
+    deriving (FromJSON, ToJSON) via Text
+
+
+run :: (Process :> es, Reader ProjectRoot :> es) => Hook -> Eff es ()
+run (Hook hook) = do
+    ProjectRoot root <- Reader.ask
+    void
+        $ trySync
+        $ Process.runProcess
+        $ Process.setWorkingDir root
+        $ Process.shell
+        $ toString hook
diff --git a/src/Tricorder/Session/Hooks.hs b/src/Tricorder/Session/Hooks.hs
--- a/src/Tricorder/Session/Hooks.hs
+++ b/src/Tricorder/Session/Hooks.hs
@@ -1,29 +1,36 @@
+{-# LANGUAGE QuantifiedConstraints #-}
+{-# LANGUAGE UndecidableInstances #-}
+
 module Tricorder.Session.Hooks
     ( Hooks (..)
-    , Hook (..)
-    , runHook
+    , HookPair (..)
+    , display
     )
 where
 
-import Atelier.Effects.Process (Process)
-import Atelier.Types.QuietSnake (QuietSnake (..))
+import Atelier.Types.JSON.QuietSnake (QuietSnake (..))
+import Atelier.Types.WithDefaults (WithDefaults (..))
 import Data.Aeson (FromJSON, ToJSON)
 import Data.Default (Default (..))
-import Effectful.Exception (trySync)
-import Effectful.Reader.Static (Reader)
 
-import Atelier.Effects.Process qualified as Process
-import Effectful.Reader.Static qualified as Reader
+import Data.Text qualified as T
 
-import Tricorder.Runtime (ProjectRoot (..))
+import Tricorder.Session.HaskellHooks (HaskellHooks)
+import Tricorder.Session.Hook (Hook)
+import Tricorder.Session.Util (indent)
 
+import Tricorder.Session.HaskellHooks qualified as HaskellHooks
+import Tricorder.Session.Hook qualified as Hook
 
+
 data Hooks = Hooks
-    { start :: Maybe Hook
-    , reload :: Maybe Hook
+    { start :: Maybe HookPair
+    , reload :: Maybe HookPair
+    , haskell :: Maybe HaskellHooks
     }
     deriving stock (Eq, Generic, Show)
-    deriving (FromJSON, ToJSON) via QuietSnake Hooks
+    deriving (ToJSON) via QuietSnake Hooks
+    deriving (FromJSON) via WithDefaults (QuietSnake Hooks)
 
 
 instance Default Hooks where
@@ -31,31 +38,43 @@
         Hooks
             { start = def
             , reload = def
+            , haskell = def
             }
 
 
-data Hook = Hook
-    { before :: Maybe Text
-    , after :: Maybe Text
+display :: Hooks -> Text
+display hooks =
+    T.intercalate
+        "\n"
+        [ "start:" <> displayMaybeHook displayHookPair hooks.start
+        , "reload:" <> displayMaybeHook displayHookPair hooks.reload
+        , "haskell:" <> displayMaybeHook HaskellHooks.display hooks.haskell
+        ]
+  where
+    displayMaybeHook f = maybe " <unset>" (("\n" <>) . indent . f)
+
+
+data HookPair = HookPair
+    { before :: Maybe Hook
+    , after :: Maybe Hook
     }
     deriving stock (Eq, Generic, Show)
-    deriving (FromJSON, ToJSON) via QuietSnake Hook
+    deriving (ToJSON) via QuietSnake HookPair
+    deriving (FromJSON) via WithDefaults (QuietSnake HookPair)
 
 
-instance Default Hook where
+instance Default HookPair where
     def =
-        Hook
+        HookPair
             { before = Nothing
             , after = Nothing
             }
 
 
-runHook :: (Process :> es, Reader ProjectRoot :> es) => Text -> Eff es ()
-runHook hook = do
-    ProjectRoot root <- Reader.ask
-    void
-        $ trySync
-        $ Process.runProcess
-        $ Process.setWorkingDir root
-        $ Process.shell
-        $ toString hook
+displayHookPair :: HookPair -> Text
+displayHookPair pair =
+    T.intercalate
+        "\n"
+        [ "before: " <> maybe "<unset>" Hook.getHook pair.before
+        , "after: " <> maybe "<unset>" Hook.getHook pair.after
+        ]
diff --git a/src/Tricorder/Session/StackProject.hs b/src/Tricorder/Session/StackProject.hs
--- a/src/Tricorder/Session/StackProject.hs
+++ b/src/Tricorder/Session/StackProject.hs
@@ -5,7 +5,7 @@
 where
 
 import Atelier.Effects.FileSystem (FileSystem)
-import Atelier.Effects.Input (Input, runInputEff)
+import Atelier.Effects.Input (Input)
 import Data.Aeson (FromJSON, ToJSON)
 import Data.Time (UTCTime)
 import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
@@ -24,6 +24,7 @@
 import System.IO.Error (userError)
 
 import Atelier.Effects.FileSystem qualified as FileSystem
+import Atelier.Effects.Input qualified as Input
 import Data.Yaml qualified as Yaml
 
 import Tricorder.Runtime (ProjectRoot (..))
@@ -45,7 +46,7 @@
 inputWithCache act = do
     cache :: TVar StackProject <- newTVarIO $ StackProject []
     lastModified :: TVar UTCTime <- newTVarIO $ posixSecondsToUTCTime 0
-    flip runInputEff act do
+    flip Input.withEff act do
         ProjectRoot projectRoot <- ask
         let stackYamlPath = projectRoot </> "stack.yaml"
         exists <- FileSystem.doesFileExist stackYamlPath
diff --git a/src/Tricorder/Session/Stage/Build/Session.hs b/src/Tricorder/Session/Stage/Build/Session.hs
--- a/src/Tricorder/Session/Stage/Build/Session.hs
+++ b/src/Tricorder/Session/Stage/Build/Session.hs
@@ -1,14 +1,13 @@
 module Tricorder.Session.Stage.Build.Session
     ( BuildSession (..)
     , resolve
-    , show
+    , display
     )
 where
 
 import Atelier.Effects.FileSystem (FileSystem)
 import Data.Default (Default (..))
 import Effectful.Reader.Static (Reader)
-import Prelude hiding (show)
 
 import Data.Text qualified as T
 import Effectful.Reader.Static qualified as Reader
@@ -20,7 +19,7 @@
 import Tricorder.Session.Config (Config (..))
 import Tricorder.Session.Repl (Repl)
 import Tricorder.Session.Target (Target)
-import Tricorder.Session.Util (indent, showList)
+import Tricorder.Session.Util (displayList, indent)
 
 import Tricorder.Session.CommandTemplate qualified as CommandTemplate
 import Tricorder.Session.Stage qualified as Stage
@@ -55,12 +54,12 @@
     effectiveTargets = Target.resolve projectFiles rawBuildTargets
 
 
-show :: BuildSession -> Text
-show cfg =
+display :: BuildSession -> Text
+display cfg =
     T.intercalate
         "\n"
         [ "Command template:"
-        , indent $ CommandTemplate.show cfg.commandTemplate
+        , indent $ CommandTemplate.display cfg.commandTemplate
         , "Targets:"
-        , indent $ showList Target.render cfg.targets
+        , indent $ displayList Target.render cfg.targets
         ]
diff --git a/src/Tricorder/Session/Stage/Eval/Session.hs b/src/Tricorder/Session/Stage/Eval/Session.hs
--- a/src/Tricorder/Session/Stage/Eval/Session.hs
+++ b/src/Tricorder/Session/Stage/Eval/Session.hs
@@ -1,11 +1,9 @@
 module Tricorder.Session.Stage.Eval.Session
     ( EvalSession
-    , show
+    , display
     )
 where
 
-import Prelude hiding (show)
-
 import Data.Text qualified as T
 
 import Tricorder.Session.CommandTemplate (CommandTemplate)
@@ -18,10 +16,10 @@
 type EvalSession = CommandTemplate 'Stage.Eval
 
 
-show :: EvalSession -> Text
-show cfg =
+display :: EvalSession -> Text
+display cfg =
     T.intercalate
         "\n"
         [ "Command template:"
-        , indent $ CommandTemplate.show cfg
+        , indent $ CommandTemplate.display cfg
         ]
diff --git a/src/Tricorder/Session/Stage/Test/Config.hs b/src/Tricorder/Session/Stage/Test/Config.hs
--- a/src/Tricorder/Session/Stage/Test/Config.hs
+++ b/src/Tricorder/Session/Stage/Test/Config.hs
@@ -5,7 +5,7 @@
     )
 where
 
-import Atelier.Types.QuietSnake (QuietSnake (..))
+import Atelier.Types.JSON.QuietSnake (QuietSnake (..))
 import Data.Aeson (FromJSON (..), ToJSON, Value (..))
 import Data.Aeson.Types (ToJSON (..))
 import Data.Default (Default (..))
diff --git a/src/Tricorder/Session/Stage/Test/Session.hs b/src/Tricorder/Session/Stage/Test/Session.hs
--- a/src/Tricorder/Session/Stage/Test/Session.hs
+++ b/src/Tricorder/Session/Stage/Test/Session.hs
@@ -3,15 +3,13 @@
     , ResolvedTestOptions (..)
     , resolve
     , defaultTestTemplate
-    , show
+    , display
     )
 where
 
 import Data.Default (Default (..))
-import Prelude hiding (show)
 
 import Data.Text qualified as T
-import Prelude qualified as P
 
 import Tricorder.Session.CommandConfig (CommandConfig (..))
 import Tricorder.Session.CommandTemplate (CommandTemplate (..), targetPlaceholder)
@@ -20,7 +18,7 @@
 import Tricorder.Session.Stage.Test.Config (Options (..), OutputMode (..), TestConfig (..))
 import Tricorder.Session.Target (Target)
 import Tricorder.Session.TestTarget (TestTarget (..))
-import Tricorder.Session.Util (indent, showList)
+import Tricorder.Session.Util (displayList, indent)
 
 import Tricorder.Session.CommandTemplate qualified as CommandTemplate
 import Tricorder.Session.Stage qualified as Stage
@@ -92,14 +90,14 @@
     Unknown -> "cabal repl {target}"
 
 
-show :: TestSession -> Text
-show cfg =
+display :: TestSession -> Text
+display cfg =
     T.intercalate
         "\n"
         [ "Command template:"
-        , indent $ CommandTemplate.show cfg.commandTemplate
+        , indent $ CommandTemplate.display cfg.commandTemplate
         , "Test targets:"
-        , indent $ showList TestTarget.render cfg.targets
+        , indent $ displayList TestTarget.render cfg.targets
         , "Options:"
         , indent $ showTestOptions cfg.options
         ]
@@ -107,5 +105,5 @@
     showTestOptions opts =
         T.intercalate
             "\n"
-            [ "Output mode: " <> P.show opts.outputMode
+            [ "Output mode: " <> show opts.outputMode
             ]
diff --git a/src/Tricorder/Session/State.hs b/src/Tricorder/Session/State.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Session/State.hs
@@ -0,0 +1,173 @@
+module Tricorder.Session.State
+    ( State
+    , currentSession
+    , withSession
+    , asInput
+    , eval
+    )
+where
+
+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 (FileWatcher)
+import Atelier.Effects.Input (Input)
+import Atelier.Effects.Log (Log)
+import Atelier.Effects.PubSub.Pub (Pub)
+import Atelier.Effects.PubSub.Sub (Sub)
+import Effectful (Effect, Limit (..), Persistence (..), UnliftStrategy (..), inject)
+import Effectful.Concurrent.STM (Concurrent, TVar)
+import Effectful.Dispatch.Dynamic (interpretWith, localUnlift)
+import Effectful.Reader.Static (Reader)
+import Effectful.TH (makeEffect)
+import System.FilePath ((</>))
+
+import Atelier.Effects.Conc qualified as Conc
+import Atelier.Effects.FileSystem qualified as FileSystem
+import Atelier.Effects.FileWatcher qualified as FileWatcher
+import Atelier.Effects.Input qualified as Input
+import Atelier.Effects.Log qualified as Log
+import Atelier.Effects.PubSub qualified as PubSub
+import Atelier.Effects.PubSub.Pub qualified as Pub
+import Atelier.Effects.PubSub.Sub qualified as Sub
+import Data.List qualified as List
+import Data.Text qualified as T
+import Effectful.Concurrent.STM qualified as STM
+import Effectful.Reader.Static qualified as Reader
+
+import Tricorder.Runtime (ProjectRoot (..))
+import Tricorder.Session.CabalFile (CabalFile)
+import Tricorder.Session.State.Invalid (Invalid (..))
+import Tricorder.Session.State.Result (Result)
+import Tricorder.Session.Util (indent)
+import Tricorder.Waiters (Waiters)
+
+import Tricorder.Config qualified as Tricorder
+import Tricorder.Config.LoadedConfig qualified as LoadedConfig
+import Tricorder.Session qualified as Session
+import Tricorder.Session.State.Result qualified as Result
+import Tricorder.Waiters qualified as Waiters
+
+
+data State :: Effect where
+    -- | Returns the current session record.
+    CurrentSession :: State m Result
+    -- | Cancels and reruns the passed action with a new session every time the
+    -- session record changes.
+    WithSession :: (Result -> m a) -> State m Void
+
+
+makeEffect ''State
+
+
+asInput :: (State :> es) => Eff (Input Result : es) a -> Eff es a
+asInput = Input.withEff currentSession
+
+
+eval
+    :: ( Chan :> es
+       , Clock :> es
+       , Conc :> es
+       , Concurrent :> es
+       , Debounce FilePath :> es
+       , FileSystem :> es
+       , FileWatcher :> es
+       , Input (Either Text Tricorder.Config) :> es
+       , Input [CabalFile] :> es
+       , Log :> es
+       , Reader ProjectRoot :> es
+       , Waiters :> es
+       )
+    => Eff (State : es) a -> Eff es a
+eval action = PubSub.run @Reloaded $ PubSub.run @Reload do
+    session0 <- Result.fromEither <$> Session.load
+
+    logSession session0
+    var <- STM.newTVarIO session0
+    Conc.fork_ watchConfigFile
+    conditionallyWatchStackYaml
+    Conc.fork_ $ Sub.listen_ @Reload $ const $ updateSession var
+    interpretWith (inject action) \env -> \case
+        CurrentSession -> STM.readTVarIO var
+        WithSession act -> do
+            localUnlift env (ConcUnlift Persistent Unlimited) \unlift ->
+                Conc.restartableFork waitForReloadedSession do
+                    unlift . act =<< STM.readTVarIO var
+
+
+waitForReloadedSession :: (Sub Reloaded :> es, Waiters :> es) => Eff es Reloaded
+waitForReloadedSession = Waiters.wait $ Sub.listenOnce_ @Reloaded
+
+
+updateSession
+    :: ( Concurrent :> es
+       , FileSystem :> es
+       , Input (Either Text Tricorder.Config) :> es
+       , Input [CabalFile] :> es
+       , Log :> es
+       , Pub Reloaded :> es
+       , Reader ProjectRoot :> es
+       )
+    => TVar Result -> Eff es ()
+updateSession var = do
+    oldSession <- STM.readTVarIO var
+    newSession <- Result.fromEither <$> Session.load
+    when (oldSession /= newSession) do
+        STM.atomically $ STM.writeTVar var newSession
+        logSession newSession
+        Pub.publish Reloaded
+
+
+data Reload = Reload
+
+
+data Reloaded = Reloaded
+
+
+watchConfigFile
+    :: ( Debounce FilePath :> es
+       , FileWatcher :> es
+       , Pub Reload :> es
+       , Reader ProjectRoot :> es
+       )
+    => Eff es Void
+watchConfigFile = do
+    root <- Reader.ask
+    FileWatcher.watchFilePathsDebounced
+        [FileWatcher.dirWhere root.getProjectRoot (LoadedConfig.configFileName `List.isSuffixOf`)]
+        \_ _ -> Pub.publish Reload
+
+
+conditionallyWatchStackYaml
+    :: ( Conc :> es
+       , Debounce FilePath :> es
+       , FileSystem :> es
+       , FileWatcher :> es
+       , Pub Reload :> es
+       , Reader ProjectRoot :> es
+       )
+    => Eff es ()
+conditionallyWatchStackYaml = do
+    root <- Reader.ask
+    exists <- FileSystem.doesFileExist $ root.getProjectRoot </> "stack.yaml"
+    when exists do
+        Conc.fork_ $ FileWatcher.watchFilePathsDebounced
+            [FileWatcher.dirWhere root.getProjectRoot ("stack.yaml" `List.isSuffixOf`)]
+            \_ _ -> Pub.publish Reload
+
+
+logSession :: (Log :> es) => Result -> Eff es ()
+logSession = \case
+    Result.Invalid invalid ->
+        Log.err
+            $ T.intercalate
+                "\n"
+                [ "Invalid session:"
+                , indent $ "reason: " <> invalid.reason
+                , indent $ "fallback:"
+                , indent $ indent $ Session.display invalid.fallback
+                ]
+    Result.Ok session ->
+        Log.info . (<> "\n") $ Session.display session
diff --git a/src/Tricorder/Session/State/Invalid.hs b/src/Tricorder/Session/State/Invalid.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Session/State/Invalid.hs
@@ -0,0 +1,10 @@
+module Tricorder.Session.State.Invalid (Invalid (..)) where
+
+import Tricorder.Session (Session)
+
+
+data Invalid = Invalid
+    { reason :: Text
+    , fallback :: Session
+    }
+    deriving stock (Eq)
diff --git a/src/Tricorder/Session/State/Result.hs b/src/Tricorder/Session/State/Result.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Session/State/Result.hs
@@ -0,0 +1,43 @@
+module Tricorder.Session.State.Result
+    ( Result (..)
+    , fromEither
+    , toEither
+    , toMaybe
+    )
+where
+
+import Data.Default (def)
+
+import Tricorder.Session (Session)
+import Tricorder.Session.State.Invalid (Invalid)
+
+import Tricorder.Session.State.Invalid qualified as Invalid
+
+
+data Result
+    = Ok Session
+    | Invalid Invalid
+    deriving stock (Eq)
+
+
+fromEither :: Either Text Session -> Result
+fromEither = \case
+    Left reason ->
+        Invalid
+            Invalid.Invalid
+                { reason
+                , fallback = def
+                }
+    Right session -> Ok session
+
+
+toEither :: Result -> Either Invalid Session
+toEither = \case
+    Ok a -> Right a
+    Invalid b -> Left b
+
+
+toMaybe :: Result -> Maybe Session
+toMaybe = \case
+    Ok x -> Just x
+    Invalid _ -> Nothing
diff --git a/src/Tricorder/Session/TestTarget.hs b/src/Tricorder/Session/TestTarget.hs
--- a/src/Tricorder/Session/TestTarget.hs
+++ b/src/Tricorder/Session/TestTarget.hs
@@ -29,19 +29,18 @@
 
 -- | Parse raw target strings (e.g. the @test_targets@ config) and project them
 -- onto their test suites — non-test entries are dropped.
-parse :: [Text] -> [TestTarget]
-parse = project . map Target.parse
+parse :: Text -> Maybe TestTarget
+parse = project . Target.parse
 
 
 -- | [tag:test_targets_invariant] Project a target list onto its test suites —
 -- the only way to build a 'TestTargets', so the @test:@-only invariant holds by
 -- construction.
-project :: [Target] -> [TestTarget]
-project = mapMaybe mk
-  where
-    mk tgt@(Qualified Test _) = Just $ TestTarget tgt
-    mk tgt@(PackageQualified _ Test _) = Just $ TestTarget tgt
-    mk _ = Nothing
+project :: Target -> Maybe TestTarget
+project = \case
+    tgt@(Qualified Test _) -> Just $ TestTarget tgt
+    tgt@(PackageQualified _ Test _) -> Just $ TestTarget tgt
+    _ -> Nothing
 
 
 -- | Resolve which test suites to run after a clean build. The explicit
@@ -52,5 +51,5 @@
 -- set, falls back to deriving test targets from the build 'targets'.
 resolve :: Config -> [Target] -> [TestTarget]
 resolve cfg targets = case cfg.test.commandConfig.targets <|> cfg.testTargets of
-    Just explicit -> parse explicit
-    Nothing -> project targets
+    Just explicit -> mapMaybe parse explicit
+    Nothing -> mapMaybe project targets
diff --git a/src/Tricorder/Session/Util.hs b/src/Tricorder/Session/Util.hs
--- a/src/Tricorder/Session/Util.hs
+++ b/src/Tricorder/Session/Util.hs
@@ -1,5 +1,5 @@
 module Tricorder.Session.Util
-    ( showList
+    ( displayList
     , indent
     )
 where
@@ -7,8 +7,8 @@
 import Data.Text qualified as T
 
 
-showList :: (a -> Text) -> [a] -> Text
-showList f xs
+displayList :: (a -> Text) -> [a] -> Text
+displayList f xs
     | null xs = "<empty list>"
     | otherwise = T.intercalate "\n" $ (("- " <>) . f) <$> xs
 
diff --git a/src/Tricorder/Session/WatchDirs.hs b/src/Tricorder/Session/WatchDirs.hs
--- a/src/Tricorder/Session/WatchDirs.hs
+++ b/src/Tricorder/Session/WatchDirs.hs
@@ -2,6 +2,7 @@
     ( WatchDirs (..)
     , resolve
     , sourceDirsForTarget
+    , allSourceDirs
     )
 where
 
@@ -151,3 +152,18 @@
     dirsForComponent components name =
         let ucn = mkUnqualComponentName (toString name)
         in  concatMap (componentDirs . condTreeData . snd) $ filter ((== ucn) . fst) components
+
+
+-- | The @hs-source-dirs@ of every component in the package, relative to the
+-- package's own directory.
+allSourceDirs :: GenericPackageDescription -> [FilePath]
+allSourceDirs gpd =
+    nub
+        . sourceDirsForTarget gpd
+        . Bare
+        . toText
+        . unPackageName
+        . pkgName
+        . package
+        . packageDescription
+        $ gpd
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
@@ -20,6 +20,7 @@
 import Effectful.Reader.Static (Reader, ask)
 import Effectful.State.Static.Shared (evalState, get, modify, put)
 import System.IO.Error (isEOFError)
+import Tricorder.CLI.Command (Force)
 import Prelude hiding (force)
 
 import Atelier.Effects.Delay qualified as Delay
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
@@ -2,18 +2,22 @@
 
 import Atelier.Effects.Conc (Conc)
 import Atelier.Effects.Exit (Exit, exitSuccess)
+import Atelier.Effects.File (File)
+import Atelier.Effects.FileSystem (FileSystem)
 import Atelier.Effects.Input (Input, input)
 import Atelier.Effects.Log (Log)
-import Atelier.Effects.Publishing.Sub (Sub)
+import Atelier.Effects.PubSub.Sub (Sub)
 import Data.Aeson (ToJSON, decode, encode)
-import Effectful.Exception (IOException, finally)
+import Effectful.Exception (IOException, finally, trySync)
 import Effectful.Reader.Static (Reader, ask)
 import Effectful.State.Static.Shared (State)
 import System.IO (Handle)
 
 import Atelier.Effects.Conc qualified as Conc
+import Atelier.Effects.File qualified as File
+import Atelier.Effects.FileSystem qualified as FileSystem
 import Atelier.Effects.Log qualified as Log
-import Atelier.Effects.Publishing.Sub qualified as Sub
+import Atelier.Effects.PubSub.Sub qualified as Sub
 import Data.ByteString.Lazy qualified as BSL
 import Effectful.State.Static.Shared qualified as State
 
@@ -27,15 +31,7 @@
     , Query (..)
     , StatusQuery (..)
     )
-import Tricorder.Socket.UnixSocket
-    ( UnixSocket
-    , acceptHandle
-    , bindSocket
-    , closeHandle
-    , readLine
-    , removeSocketFile
-    , sendLine
-    )
+import Tricorder.Socket.UnixSocket (UnixSocket)
 import Tricorder.Version (VersionMismatch (..), checkVersion)
 import Tricorder.Waiters (Waiters)
 
@@ -44,6 +40,7 @@
 import Tricorder.Build.Test qualified as Test
 import Tricorder.Daemon.IdleTimer qualified as IdleTimer
 import Tricorder.Socket.Protocol qualified as Protocol
+import Tricorder.Socket.UnixSocket qualified as UnixSocket
 import Tricorder.Waiters qualified as Waiters
 
 
@@ -54,6 +51,8 @@
 main
     :: ( Conc :> es
        , Exit :> es
+       , File :> es
+       , FileSystem :> es
        , IdleTimer :> es
        , Input BuildId :> es
        , Log :> es
@@ -67,13 +66,14 @@
     Conc.fork_ $ Sub.listen_ @BuildPhase State.put
 
     SocketPath sockPath <- ask
-    removeSocketFile sockPath
+    void $ trySync $ FileSystem.removeFile sockPath
     acceptTrigger
 
 
 acceptTrigger
     :: ( Conc :> es
        , Exit :> es
+       , File :> es
        , IdleTimer :> es
        , Input BuildId :> es
        , Log :> es
@@ -86,28 +86,28 @@
     => Eff es Void
 acceptTrigger = do
     SocketPath sockPath <- ask
-    sock <- bindSocket sockPath
+    sock <- UnixSocket.bind sockPath
     forever do
-        h <- acceptHandle sock
+        h <- UnixSocket.accept sock
         void $ Conc.forkTry @IOException do
-            handleConnection h `finally` closeHandle h
+            handleConnection h `finally` File.hClose h
 
 
 handleConnection
     :: ( Conc :> es
        , Exit :> es
+       , File :> es
        , IdleTimer :> es
        , Input BuildId :> es
        , Log :> es
        , State BuildPhase :> es
        , Sub BuildPhase :> es
-       , UnixSocket :> es
        , Waiters :> es
        )
     => Handle
     -> Eff es ()
 handleConnection h = IdleTimer.withActivity do
-    line <- readLine h
+    line <- File.hGetLine h
     case decode (BSL.fromStrict (encodeUtf8 line)) of
         Nothing -> sendJson h (ErrorResponse "invalid request")
         Just ClientMessage {clientVersion, payload} -> do
@@ -122,11 +122,11 @@
 dispatch
     :: ( Conc :> es
        , Exit :> es
+       , File :> es
        , Input BuildId :> es
        , Log :> es
        , State BuildPhase :> es
        , Sub BuildPhase :> es
-       , UnixSocket :> es
        , Waiters :> es
        )
     => Query
@@ -142,8 +142,8 @@
 
 quit
     :: ( Exit :> es
+       , File :> es
        , Log :> es
-       , UnixSocket :> es
        , Waiters :> es
        )
     => Handle -> Protocol.Waiters -> Eff es ()
@@ -158,9 +158,9 @@
 
 
 respondOnce
-    :: ( Input BuildId :> es
+    :: ( File :> es
+       , Input BuildId :> es
        , State BuildPhase :> es
-       , UnixSocket :> es
        )
     => Handle -> Eff es ()
 respondOnce h = do
@@ -170,10 +170,10 @@
 
 respondWhenDone
     :: ( Conc :> es
+       , File :> es
        , Input BuildId :> es
        , State BuildPhase :> es
        , Sub BuildPhase :> es
-       , UnixSocket :> es
        )
     => Handle -> Eff es ()
 respondWhenDone h = awaitResult >>= mkBuildState >>= sendJson h
@@ -194,10 +194,10 @@
 
 -- | Stream a JSON object after each state change event.
 watchStream
-    :: ( Input BuildId :> es
+    :: ( File :> es
+       , Input BuildId :> es
        , State BuildPhase :> es
        , Sub BuildPhase :> es
-       , UnixSocket :> es
        )
     => Handle -> Eff es ()
 watchStream h = do
@@ -207,7 +207,7 @@
         mkBuildState progress >>= sendJson h
 
 
-respondDiagnostic :: (State BuildPhase :> es, UnixSocket :> es) => Int -> Handle -> Eff es ()
+respondDiagnostic :: (File :> es, State BuildPhase :> es) => Int -> Handle -> Eff es ()
 respondDiagnostic idx h = do
     progress <- State.get
     case progress of
@@ -228,8 +228,8 @@
         _ -> sendJson h $ ErrorResponse "Build in progress"
 
 
-sendJson :: (ToJSON a, UnixSocket :> es) => Handle -> a -> Eff es ()
-sendJson h val = sendLine h (decodeUtf8 (BSL.toStrict (encode val)))
+sendJson :: (File :> es, ToJSON a) => Handle -> a -> Eff es ()
+sendJson h val = File.hPutTextLn h (decodeUtf8 (BSL.toStrict (encode val))) >> File.hFlush h
 
 
 mkBuildState :: (Input BuildId :> es) => BuildPhase -> Eff es BuildState
diff --git a/src/Tricorder/Socket/UnixSocket.hs b/src/Tricorder/Socket/UnixSocket.hs
--- a/src/Tricorder/Socket/UnixSocket.hs
+++ b/src/Tricorder/Socket/UnixSocket.hs
@@ -1,24 +1,18 @@
 module Tricorder.Socket.UnixSocket
     ( -- * Effect
       UnixSocket
-    , bindSocket
-    , acceptHandle
+    , bind
+    , accept
     , withConnection
-    , readLine
-    , sendLine
-    , closeHandle
-    , removeSocketFile
-    , socketFileExists
 
       -- * Interpreters
-    , runUnixSocketIO
-    , runUnixSocketScripted
+    , runIO
+    , runScripted
     , SocketScript (..)
     )
 where
 
 import Atelier.Effects.File (BufferMode (..), File, Handle)
-import Atelier.Exception (trySyncIO)
 import Effectful (Effect, IOE)
 import Effectful.Dispatch.Dynamic (interpretWith, localSeqUnlift, reinterpret)
 import Effectful.Exception (finally)
@@ -29,38 +23,22 @@
     , SockAddr (..)
     , Socket
     , SocketType (..)
-    , accept
-    , bind
-    , defaultProtocol
-    , listen
-    , socket
-    , socketToHandle
     )
-import System.Directory (doesPathExist, removeFile)
 import System.IO (hSetEncoding, utf8)
 
 import Atelier.Effects.File qualified as File
 import Network.Socket qualified as Net
+import Network.Socket qualified as Socket
 
 
 data UnixSocket :: Effect where
     -- | Create, bind, and listen on a Unix socket at the given path.
-    BindSocket :: FilePath -> UnixSocket m Socket
+    Bind :: FilePath -> UnixSocket m Socket
     -- | Accept the next incoming connection and return a line-buffered 'Handle'.
-    AcceptHandle :: Socket -> UnixSocket m Handle
+    Accept :: Socket -> UnixSocket m Handle
     -- | Connect to a Unix socket and run the callback with the resulting handle.
     -- The handle is closed when the callback returns.
     WithConnection :: FilePath -> (Handle -> m a) -> UnixSocket m a
-    -- | Read a line from a connected handle.
-    ReadLine :: Handle -> UnixSocket m Text
-    -- | Write a line to a connected handle and flush.
-    SendLine :: Handle -> Text -> UnixSocket m ()
-    -- | Close a connected handle.
-    CloseHandle :: Handle -> UnixSocket m ()
-    -- | Remove the socket file, ignoring errors (e.g. file not found).
-    RemoveSocketFile :: FilePath -> UnixSocket m ()
-    -- | Check whether the socket file exists.
-    SocketFileExists :: FilePath -> UnixSocket m Bool
 
 
 makeEffect ''UnixSocket
@@ -71,17 +49,17 @@
 -- Socket creation and encoding setup require raw IO (no @File@ equivalent),
 -- but the handle-level reads, writes, buffering and closing go through the
 -- 'File' effect.
-runUnixSocketIO :: (File :> es, IOE :> es) => Eff (UnixSocket : es) a -> Eff es a
-runUnixSocketIO eff = interpretWith eff \env -> \case
-    BindSocket path -> liftIO do
-        sock <- socket AF_UNIX Stream defaultProtocol
-        bind sock (SockAddrUnix path)
-        listen sock 5
+runIO :: (File :> es, IOE :> es) => Eff (UnixSocket : es) a -> Eff es a
+runIO eff = interpretWith eff \env -> \case
+    Bind path -> liftIO do
+        sock <- Socket.socket AF_UNIX Stream Socket.defaultProtocol
+        Socket.bind sock (SockAddrUnix path)
+        Socket.listen sock 5
         pure sock
-    AcceptHandle sock -> do
+    Accept sock -> do
         h <- liftIO do
-            (conn, _) <- accept sock
-            h <- socketToHandle conn ReadWriteMode
+            (conn, _) <- Socket.accept sock
+            h <- Socket.socketToHandle conn ReadWriteMode
             hSetEncoding h utf8
             pure h
         File.hSetBuffering h LineBuffering
@@ -89,20 +67,13 @@
     WithConnection sockPath callback ->
         localSeqUnlift env \unlift -> do
             h <- liftIO do
-                sock <- socket AF_UNIX Stream defaultProtocol
+                sock <- Socket.socket AF_UNIX Stream Socket.defaultProtocol
                 Net.connect sock (SockAddrUnix sockPath)
-                h <- socketToHandle sock ReadWriteMode
+                h <- Socket.socketToHandle sock ReadWriteMode
                 hSetEncoding h utf8
                 pure h
             File.hSetBuffering h LineBuffering
             unlift (callback h) `finally` File.hClose h
-    ReadLine h -> File.hGetLine h
-    SendLine h line -> File.hPutTextLn h line >> File.hFlush h
-    CloseHandle h -> File.hClose h
-    RemoveSocketFile path ->
-        liftIO $ void $ trySyncIO $ removeFile path
-    SocketFileExists path ->
-        liftIO $ doesPathExist path
 
 
 -- | Script element for the test interpreter.
@@ -125,12 +96,12 @@
 -- line buffering on it. 'removeSocketFile' is always a no-op.
 -- 'socketFileExists' pops the next 'NextFileCheck' entry.
 -- 'withConnection' pops the next 'NextConnect' entry and passes it to the callback.
-runUnixSocketScripted
+runScripted
     :: (File :> es, IOE :> es) => [SocketScript] -> Eff (UnixSocket : es) a -> Eff es a
-runUnixSocketScripted script = reinterpret (evalState script) \env -> \case
-    BindSocket _ ->
-        liftIO $ Net.socket AF_UNIX Stream defaultProtocol
-    AcceptHandle _ ->
+runScripted script = reinterpret (evalState script) \env -> \case
+    Bind _ ->
+        liftIO $ Net.socket AF_UNIX Stream Socket.defaultProtocol
+    Accept _ ->
         get >>= \case
             NextAccept h : rest -> do
                 put rest
@@ -143,14 +114,3 @@
                 put rest
                 localSeqUnlift env \unlift -> unlift (callback h)
             _ -> error "UnixSocketScripted: expected NextConnect but queue was empty or mismatched"
-    ReadLine _ ->
-        get >>= \case
-            NextReadLine line : rest -> put rest >> pure line
-            _ -> error "UnixSocketScripted: expected NextReadLine but queue was empty or mismatched"
-    SendLine _ _ -> pure ()
-    CloseHandle _ -> pure ()
-    RemoveSocketFile _ -> pure ()
-    SocketFileExists _ ->
-        get >>= \case
-            NextFileCheck b : rest -> put rest >> pure b
-            _ -> error "UnixSocketScripted: expected NextFileCheck but queue was empty or mismatched"
diff --git a/src/Tricorder/SourceLookup.hs b/src/Tricorder/SourceLookup.hs
--- a/src/Tricorder/SourceLookup.hs
+++ b/src/Tricorder/SourceLookup.hs
@@ -1,29 +1,39 @@
 module Tricorder.SourceLookup
     ( ModuleSourceResult (..)
+    , ReexportLimits (..)
+    , defaultReexportLimits
     , lookupModuleSource
+    , ProjectSourceDirs (..)
+    , projectSourceDirs
     )
 where
 
-import Atelier.Effects.Cache (Cache, cacheInsert, cacheLookup)
-import Atelier.Effects.FileSystem (FileSystem)
+import Atelier.Effects.FileSystem (FileSystem, doesFileExist, readFileLbs)
 import Atelier.Effects.Input (Input, input)
 import Atelier.Effects.Log (Log)
 import Data.Aeson (FromJSON, ToJSON)
 import GHC.Generics (Generically (..))
+import System.FilePath (normalise, takeDirectory, (</>))
 import Tricorder.SourceLookup.SourceQuery (ModuleName (..), SourceQuery (..))
 
 import Atelier.Effects.Log qualified as Log
+import Data.Set qualified as Set
+import Data.Text qualified as T
 
+import Tricorder.Session.CabalFile (CabalFile (..))
 import Tricorder.Session.Repl (Repl)
+import Tricorder.Session.WatchDirs (allSourceDirs)
 import Tricorder.SourceLookup.GhcPkg (GhcPkg)
 import Tricorder.SourceLookup.Hackage (Hackage)
 import Tricorder.SourceLookup.PackageId (PackageId (..))
 import Tricorder.SourceLookup.PackageStore (PackageStore)
+import Tricorder.SourceLookup.Reexport (declaresMember, exportsByName, reexportCandidates)
 import Tricorder.SourceLookup.Slice (sliceSymbol)
 import Tricorder.SourceLookup.Tarball
     ( TarballOutcome (..)
     , obtainTarball
     , readModuleMember
+    , sourceExtensions
     )
 
 import Tricorder.SourceLookup.GhcPkg qualified as GhcPkg
@@ -33,80 +43,254 @@
 data ModuleSourceResult
     = -- | Source was found; contains the module (or single-symbol) source text.
       SourceFound SourceQuery Text
-    | -- | The module is not provided by any installed package.
+    | -- | The module is neither in the project nor in any installed package.
       SourceNotFound SourceQuery
     | -- | The package was resolved but no source tarball could be located or
       -- fetched (no index, offline, yanked, or the archive could not be read).
       SourceUnavailable SourceQuery PackageId
+    | -- | The symbol is not defined in the queried module but re-exported by
+      -- it; contains the module that defines it and the symbol's source there.
+      SourceReexported SourceQuery ModuleName Text
     | -- | The module source was found but the requested symbol was not in it.
       FunctionNotFound SourceQuery
     deriving stock (Eq, Generic, Show)
     deriving (FromJSON, ToJSON) via Generically ModuleSourceResult
 
 
+-- | Bounds on the re-export search for a symbol the queried module does not
+-- define itself (see 'followReexports').
+data ReexportLimits = ReexportLimits
+    { maxDepth :: Word
+    -- ^ Most re-export hops to follow; @0@ disables following re-exports.
+    , maxModules :: Word
+    -- ^ Most modules to read during the search.
+    }
+    deriving stock (Eq, Show)
+
+
+-- | Enough for the deepest chains seen in practice (@Prelude#Maybe@ takes 3
+-- hops through @ghc-internal@) while keeping a miss cheap.
+defaultReexportLimits :: ReexportLimits
+defaultReexportLimits = ReexportLimits {maxDepth = 5, maxModules = 24}
+
+
+-- | The directories holding the project's own modules: the @hs-source-dirs@ of
+-- every component of every project package, resolved against the package dir.
+newtype ProjectSourceDirs = ProjectSourceDirs [FilePath]
+    deriving stock (Eq, Show)
+
+
+projectSourceDirs :: [CabalFile] -> ProjectSourceDirs
+projectSourceDirs cabalFiles =
+    ProjectSourceDirs
+        $ ordNub
+            [ normalise (takeDirectory cabalFile.projectFilePath </> dir)
+            | cabalFile <- cabalFiles
+            , dir <- orRoot (allSourceDirs cabalFile.projectPackageDescription)
+            ]
+  where
+    -- A component without @hs-source-dirs@ keeps its sources in the package dir.
+    orRoot [] = ["."]
+    orRoot dirs = dirs
+
+
+-- | Where a module's source was found.
+data Origin
+    = -- | A source file under one of the project's @hs-source-dirs@.
+      InProject
+    | -- | The sdist tarball of an installed package.
+      InPackage PackageId
+
+
 -- ── Lookup logic ───────────────────────────────────────────────────────────
 
 -- | Resolve and return the source for a single module.
 --
--- Resolves the module to its project-pinned 'PackageId' via @ghc-pkg@, then
--- serves source from that package's sdist tarball in cabal's global cache,
--- fetching it on demand if absent. A symbol query slices the relevant
--- declaration (with its doc comment) from the module source. Both resolution
--- steps are cached, so the fetch + read cost is paid at most once per
--- (package, query).
+-- A module defined in the project itself is read straight from its source
+-- file. Otherwise the module is resolved to its project-pinned 'PackageId' via
+-- @ghc-pkg@, and source is served from that package's sdist tarball in cabal's
+-- global cache, fetching it on demand if absent. A symbol query slices the
+-- relevant declaration (with its doc comment) from the module source. Both
+-- package resolution steps are cached, so the fetch + read cost is paid at most
+-- once per (package, query); project sources are always read fresh.
+--
+-- A symbol the module does not define itself is searched for in the modules it
+-- may re-export it from (see 'followReexports').
 lookupModuleSource
-    :: ( Cache (PackageId, SourceQuery) ModuleSourceResult :> es
-       , Cache ModuleName PackageId :> es
-       , FileSystem :> es
+    :: ( FileSystem :> es
        , GhcPkg :> es
        , Hackage :> es
        , Input Repl :> es
        , Log :> es
        , PackageStore :> es
        )
-    => SourceQuery
+    => ProjectSourceDirs
+    -> ReexportLimits
+    -> SourceQuery
     -> Eff es ModuleSourceResult
-lookupModuleSource query = do
-    mPkg <- resolvePackage query.moduleName
-    case mPkg of
-        Nothing -> pure (SourceNotFound query)
-        Just p -> do
-            mCached <- cacheLookup @(PackageId, SourceQuery) @ModuleSourceResult (p, query)
-            case mCached of
-                Just result -> do
-                    Log.debug $ "Source: " <> unModuleName query.moduleName <> " source hit (cached)"
-                    pure result
-                Nothing -> serveFromTarball query p
+lookupModuleSource dirs limits query = do
+    (mOrigin, result) <- lookupLocal dirs InProject query
+    case (result, mOrigin, query.function) of
+        (FunctionNotFound _, Just origin, Just symbol) ->
+            fromMaybe result <$> followReexports dirs limits query symbol origin
+        _ -> pure result
 
 
+-- | Look up a query in the module's own source, without following re-exports.
+--
+-- @parent@ is where the module was reached from. Reached from the project (or
+-- queried directly), the project's own sources are searched first. Reached from
+-- a package they are skipped, since a package cannot import project modules,
+-- and when @ghc-pkg@ cannot place the module (e.g. a hidden @other-modules@
+-- entry) the parent package is used instead. Also returns where the module was
+-- found, if anywhere.
+lookupLocal
+    :: ( FileSystem :> es
+       , GhcPkg :> es
+       , Hackage :> es
+       , Input Repl :> es
+       , Log :> es
+       , PackageStore :> es
+       )
+    => ProjectSourceDirs
+    -> Origin
+    -> SourceQuery
+    -> Eff es (Maybe Origin, ModuleSourceResult)
+lookupLocal dirs parent query = do
+    mFile <- case parent of
+        InProject -> findProjectModule dirs query.moduleName
+        InPackage _ -> pure Nothing
+    case mFile of
+        Just path -> do
+            Log.debug $ "Source: " <> unModuleName query.moduleName <> " → " <> toText path
+            src <- decodeUtf8 . toStrict <$> readFileLbs path
+            pure (Just InProject, fromModuleSource query src)
+        Nothing -> do
+            mPkg <- (<|> fallback) <$> resolvePackage query.moduleName
+            case mPkg of
+                Nothing -> pure (Nothing, SourceNotFound query)
+                Just p -> do
+                    (Just (InPackage p),) <$> serveFromTarball query p
+  where
+    fallback = case parent of
+        InProject -> Nothing
+        InPackage p -> Just p
+
+
+-- | Find the source file of a module defined in the project: the first
+-- candidate path, in source dir then extension order, that exists.
+findProjectModule
+    :: (FileSystem :> es)
+    => ProjectSourceDirs -> ModuleName -> Eff es (Maybe FilePath)
+findProjectModule (ProjectSourceDirs dirs) modName =
+    firstExisting [dir </> relPath <> toString ext | dir <- dirs, ext <- sourceExtensions]
+  where
+    relPath = toString $ T.replace "." "/" (unModuleName modName)
+    firstExisting [] = pure Nothing
+    firstExisting (path : rest) = ifM (doesFileExist path) (pure (Just path)) (firstExisting rest)
+
+
+-- | The result of a query against the source of the module it names.
+fromModuleSource :: SourceQuery -> Text -> ModuleSourceResult
+fromModuleSource query moduleSrc = case query.function of
+    Nothing -> SourceFound query moduleSrc
+    Just symbol -> case sliceSymbol symbol moduleSrc of
+        Just slice -> SourceFound query slice
+        Nothing -> FunctionNotFound query
+
+
+-- | Search the modules a symbol may be re-exported from (see
+-- 'reexportCandidates'), starting at the queried module found at @origin@,
+-- for the module that defines it.
+--
+-- The search is best-first: once a module's export list names the symbol (see
+-- 'exportsByName'), its leads — and theirs, down the chain — are followed
+-- depth-first, ahead of the remaining weaker candidates, which are searched
+-- breadth-first. A hub like @GHC.Internal.Base@ re-exports through @module M@
+-- entries that never name the symbol, so strength must be inherited for its
+-- leads to be reached before the budget is spent. It is bounded in depth and in
+-- the number of modules read, so a symbol that is genuinely absent costs only a
+-- handful of lookups. It stops early at a module that declares the symbol as a
+-- member of a local type or class (see 'declaresMember'): that module defines
+-- it, so following other imports could only find an unrelated namesake.
+followReexports
+    :: ( FileSystem :> es
+       , GhcPkg :> es
+       , Hackage :> es
+       , Input Repl :> es
+       , Log :> es
+       , PackageStore :> es
+       )
+    => ProjectSourceDirs
+    -> ReexportLimits
+    -> SourceQuery
+    -> Text
+    -> Origin
+    -> Eff es (Maybe ModuleSourceResult)
+followReexports dirs limits query symbol origin
+    | limits.maxDepth <= 0 = pure Nothing
+    | otherwise =
+        candidatesIn origin query.moduleName >>= \case
+            Nothing -> pure Nothing
+            -- The queried module's candidates are one hop away.
+            Just (_, roots) ->
+                go
+                    (Set.singleton query.moduleName)
+                    limits.maxModules
+                    [(m, mo, 1, False) | (m, mo) <- roots]
+  where
+    go _ _ [] = pure Nothing
+    go seen budget ((m, parent, depth, onLead) : queue)
+        | budget <= 0 = pure Nothing
+        | m `Set.member` seen = go seen budget queue
+        | otherwise = do
+            Log.debug $ "Source: following re-export of " <> symbol <> " to " <> unModuleName m
+            (mOrigin, result) <- lookupLocal dirs parent SourceQuery {moduleName = m, function = Just symbol}
+            let seen' = Set.insert m seen
+            case (result, mOrigin) of
+                (SourceFound _ src, _) -> pure (Just (SourceReexported query m src))
+                (FunctionNotFound _, Just mo)
+                    | depth < limits.maxDepth ->
+                        candidatesIn mo m >>= \case
+                            Nothing -> pure Nothing
+                            Just (namesIt, next) ->
+                                -- A lead stays strong all the way down its chain.
+                                let strong = onLead || namesIt
+                                    leads = [(n, no, depth + 1, strong) | (n, no) <- next]
+                                in  go seen' (budget - 1) (if strong then leads <> queue else queue <> leads)
+                _ -> go seen' (budget - 1) queue
+
+    -- The modules that may re-export the symbol to module @m@ (reached from
+    -- @mo@), each paired with where @m@ was found as the parent to resolve it
+    -- from, and whether @m@ names the symbol in its exports. 'Nothing' when @m@
+    -- itself declares the symbol as a member of a local type or class.
+    candidatesIn mo m =
+        lookupLocal dirs mo SourceQuery {moduleName = m, function = Nothing} <&> \case
+            (Just mo', SourceFound _ src)
+                | declaresMember symbol src -> Nothing
+                | otherwise -> Just (exportsByName symbol src, map (,mo') (reexportCandidates symbol src))
+            _ -> Just (False, [])
+
+
 -- | Resolve a module to its package, consulting the module -> package cache first.
 resolvePackage
-    :: ( Cache ModuleName PackageId :> es
-       , GhcPkg :> es
+    :: ( GhcPkg :> es
        , Input Repl :> es
        , Log :> es
        )
     => ModuleName
     -> Eff es (Maybe PackageId)
 resolvePackage modName = do
-    mCachedPkg <- cacheLookup @ModuleName @PackageId modName
-    case mCachedPkg of
-        Just p -> do
-            Log.debug $ "Source: " <> unModuleName modName <> " → " <> unPackageId p <> " (cached)"
-            pure (Just p)
-        Nothing -> do
-            repl <- input
-            result <- GhcPkg.findModule repl modName
-            Log.debug $ "Source: find-module " <> unModuleName modName <> " → " <> show result
-            whenJust result (cacheInsert @ModuleName @PackageId modName)
-            pure result
+    repl <- input
+    result <- GhcPkg.findModule repl modName
+    Log.debug $ "Source: find-module " <> unModuleName modName <> " → " <> show result
+    pure result
 
 
 -- | Locate (or fetch) the package's tarball, read the module member, and slice
--- the requested symbol if any. Caches and returns the result.
+-- the requested symbol if any.
 serveFromTarball
-    :: ( Cache (PackageId, SourceQuery) ModuleSourceResult :> es
-       , FileSystem :> es
+    :: ( FileSystem :> es
        , Hackage :> es
        , Log :> es
        , PackageStore :> es
@@ -116,25 +300,8 @@
     -> Eff es ModuleSourceResult
 serveFromTarball query p =
     obtainTarball p >>= \case
-        -- A failed `cabal fetch` is transient (offline, stale index), so return
-        -- unavailable WITHOUT caching: a later lookup retries once the network or
-        -- index recovers, rather than serving the negative for the whole window.
-        TarballFetchFailed -> pure (SourceUnavailable query p)
-        -- The package is genuinely absent — a deterministic negative, safe to
-        -- cache alongside the read/slice outcomes below.
-        TarballAbsent -> cacheResult (SourceUnavailable query p)
+        TarballFetchFailed -> pure $ SourceUnavailable query p
+        TarballAbsent -> pure $ SourceUnavailable query p
         TarballAt tarball -> do
             mModuleSrc <- readModuleMember tarball query.moduleName
-            cacheResult $ case mModuleSrc of
-                Nothing -> SourceUnavailable query p
-                Just moduleSrc -> case query.function of
-                    Nothing -> SourceFound query moduleSrc
-                    Just symbol -> case sliceSymbol symbol moduleSrc of
-                        Just slice -> SourceFound query slice
-                        Nothing -> FunctionNotFound query
-  where
-    -- Cache a deterministic outcome so the fetch + read/slice cost is paid at
-    -- most once per (package, query) within the cache window.
-    cacheResult result = do
-        cacheInsert @(PackageId, SourceQuery) @ModuleSourceResult (p, query) result
-        pure result
+            pure $ maybe (SourceUnavailable query p) (fromModuleSource query) mModuleSrc
diff --git a/src/Tricorder/SourceLookup/Reexport.hs b/src/Tricorder/SourceLookup/Reexport.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/SourceLookup/Reexport.hs
@@ -0,0 +1,343 @@
+module Tricorder.SourceLookup.Reexport
+    ( reexportCandidates
+    , exportsByName
+    , declaresMember
+    )
+where
+
+import Data.Char (isAlphaNum, isSpace, isUpper)
+import Data.List (partition)
+import Tricorder.SourceLookup.SourceQuery (ModuleName (..))
+
+import Data.Text qualified as T
+
+import Tricorder.SourceLookup.Slice (sliceSymbol)
+
+
+reexportCandidates :: Text -> Text -> [ModuleName]
+reexportCandidates symbol source =
+    case moduleHeader stripped of
+        Nothing -> []
+        Just (self, exports) ->
+            let imports = filter ((/= self) . (.modName)) (moduleImports stripped)
+                routes = mapMaybe (exportRoute symbol definedLocally) exports
+                matching = filter (\i -> any (routeMatches symbol i) routes) imports
+                (strong, weak) = partition (namesSymbol symbol) matching
+            in  ordNub (map (.modName) (strong <> weak))
+  where
+    stripped = stripComments source
+    definedLocally n = isJust (sliceSymbol n source)
+
+
+exportsByName :: Text -> Text -> Bool
+exportsByName symbol source =
+    case moduleHeader (stripComments source) of
+        Nothing -> False
+        Just (_, exports) -> any names exports
+  where
+    names = \case
+        NameItem _ n subs -> n == symbol || symbol `elem` subs
+        ModuleItem _ -> False
+
+
+declaresMember :: Text -> Text -> Bool
+declaresMember symbol source =
+    case moduleHeader (stripComments source) of
+        Nothing -> False
+        Just (_, exports) -> any isLocalMember exports
+  where
+    isLocalMember = \case
+        NameItem Nothing n subs
+            | n /= symbol
+            , symbol `elem` subs || ".." `elem` subs
+            , Just decl <- sliceSymbol n source ->
+                mentions decl
+        _ -> False
+    -- The symbol leads a line of the declaration (a method or field signature,
+    -- a default method, a GADT constructor, a further @|@ alternative), heads a
+    -- constructor alternative of the @data@ line, or is one of several names
+    -- sharing a signature (@foo, bar :: …@). A mere use in a body does not count.
+    mentions decl = any declares (T.lines decl)
+    declares line =
+        let sig = fst (T.breakOn "::" line)
+            alternatives = drop 1 (T.split (`elem` ("=|" :: String)) (fst (T.breakOn "where" line)))
+        in  leading line == symbol
+                || ("::" `T.isInfixOf` line && symbol `elem` map leading (T.splitOn "," sig))
+                || (isDataHead line && symbol `elem` map leading alternatives)
+    isDataHead line = any (`T.isPrefixOf` line) ["data ", "newtype "]
+    leading t =
+        let s = T.dropWhile (\c -> isSpace c || c `elem` ("{,|" :: String)) t
+        in  case T.stripPrefix "(" s of
+                Just op -> T.strip (T.takeWhile (/= ')') op)
+                Nothing -> T.takeWhile isIdentChar s
+
+
+-- | One entry of an export or import list.
+data Item
+    = -- | A name, its optional qualifier, and its sub-list (@..@ for a wildcard).
+      NameItem (Maybe Text) Text [Text]
+    | -- | A @module M@ entry.
+      ModuleItem Text
+
+
+-- | How an export entry could carry the symbol out of the module.
+data Route
+    = -- | Via the named entity (under an optional qualifier).
+      ViaName (Maybe Text) Text
+    | -- | Via a @module M@ export.
+      ViaModule Text
+
+
+exportRoute :: Text -> (Text -> Bool) -> Item -> Maybe Route
+exportRoute symbol definedLocally = \case
+    NameItem q n subs
+        | n == symbol -> Just (ViaName q n)
+        | symbol `elem` subs || ".." `elem` subs, not (definedLocally n) -> Just (ViaName q n)
+        | otherwise -> Nothing
+    ModuleItem m -> Just (ViaModule m)
+
+
+-- | Whether an import could bring @symbol@ into scope along a route.
+routeMatches :: Text -> Import -> Route -> Bool
+routeMatches symbol i = \case
+    ViaName Nothing n -> not i.qualified && carries n
+    ViaName (Just q) n -> q == scopeName i && carries n
+    ViaModule m ->
+        not i.qualified
+            && (m == scopeName i || m == unModuleName i.modName)
+            && (provides symbol i || hasWildcard i)
+  where
+    -- The import brings in the exported entity @n@ (or the symbol directly),
+    -- and does not hide the symbol itself.
+    carries n = (provides n i || provides symbol i) && not (i.hiding && namedIn i)
+    namedIn imp = symbol `elem` concatMap itemNames (fromMaybe [] imp.items)
+    hasWildcard imp = case imp.items of
+        Just its | not imp.hiding -> any isWildcard its
+        _ -> False
+    isWildcard = \case
+        NameItem _ _ subs -> ".." `elem` subs
+        ModuleItem _ -> False
+
+
+data Import = Import
+    { modName :: ModuleName
+    , qualified :: Bool
+    , alias :: Maybe Text
+    , hiding :: Bool
+    , items :: Maybe [Item]
+    }
+
+
+scopeName :: Import -> Text
+scopeName i = fromMaybe (unModuleName i.modName) i.alias
+
+
+provides :: Text -> Import -> Bool
+provides name i = case i.items of
+    Nothing -> True
+    Just its
+        | i.hiding -> name `notElem` concatMap itemNames its
+        | otherwise -> name `elem` concatMap itemNames its
+
+
+namesSymbol :: Text -> Import -> Bool
+namesSymbol symbol i = case i.items of
+    Just its | not i.hiding -> symbol `elem` concatMap itemNames its
+    _ -> False
+
+
+itemNames :: Item -> [Text]
+itemNames = \case
+    NameItem _ n subs -> n : filter (/= "..") subs
+    ModuleItem _ -> []
+
+
+moduleImports :: Text -> [Import]
+moduleImports = mapMaybe parseImport . groupImports . T.lines
+  where
+    groupImports [] = []
+    groupImports (l : ls)
+        | isImportLine l =
+            let (cont, rest) = span isContinuation ls
+            in  T.unwords (l : cont) : groupImports rest
+        | otherwise = groupImports ls
+    isImportLine l = case T.stripPrefix "import" l of
+        Just rest -> maybe True (isSpace . fst) (T.uncons rest)
+        Nothing -> False
+    isContinuation l = case T.uncons l of
+        Nothing -> True
+        Just (c, _) -> isSpace c
+
+
+parseImport :: Text -> Maybe Import
+parseImport decl = do
+    afterImport <- T.stripPrefix "import" decl
+    let (preQualified, afterPre) = skipPrefixes False (T.strip afterImport)
+        (name, afterName) = T.span isModuleChar afterPre
+    guard (not (T.null name))
+    pure
+        $ parseSuffix
+            Import
+                { modName = ModuleName name
+                , qualified = preQualified
+                , alias = Nothing
+                , hiding = False
+                , items = Nothing
+                }
+            (T.strip afterName)
+  where
+    -- @safe@, @qualified@, @{-# SOURCE #-}@ (already stripped), and a
+    -- package-import string may precede the module name.
+    skipPrefixes q t
+        | Just rest <- T.stripPrefix "\"" t =
+            skipPrefixes q (T.strip (T.drop 1 (T.dropWhile (/= '"') rest)))
+        | otherwise = case T.breakOn " " t of
+            ("safe", rest) -> skipPrefixes q (T.strip rest)
+            ("qualified", rest) -> skipPrefixes True (T.strip rest)
+            _ -> (q, t)
+    parseSuffix i t
+        | T.null t = i
+        | "(" `T.isPrefixOf` t = case balanced t of
+            Just (inner, rest) ->
+                parseSuffix i {items = Just (mapMaybe parseItem (splitTopCommas inner))} (T.strip rest)
+            Nothing -> i
+        | otherwise =
+            let (w, rest) = T.break (\c -> isSpace c || c == '(') t
+            in  case w of
+                    "qualified" -> parseSuffix i {qualified = True} (T.strip rest)
+                    "hiding" -> parseSuffix i {hiding = True} (T.strip rest)
+                    "as" ->
+                        let (a, rest') = T.span isModuleChar (T.strip rest)
+                        in  parseSuffix i {alias = Just a} (T.strip rest')
+                    _ -> i
+
+
+moduleHeader :: Text -> Maybe (ModuleName, [Item])
+moduleHeader src = do
+    afterModule <- viaNonEmpty head (mapMaybe startsModule (lineStarts src))
+    let (name, rest) = T.span isModuleChar (T.strip afterModule)
+    guard (not (T.null name))
+    (inner, _) <- balanced (T.strip rest)
+    pure (ModuleName name, mapMaybe parseItem (splitTopCommas inner))
+  where
+    -- The text after a column-0 @module@ keyword.
+    startsModule l = case T.stripPrefix "module" l of
+        Just rest | maybe False (isSpace . fst) (T.uncons rest) -> Just rest
+        _ -> Nothing
+
+
+lineStarts :: Text -> [Text]
+lineStarts t = t : go t
+  where
+    go s = case T.breakOn "\n" s of
+        (_, rest) | T.null rest -> []
+        (_, rest) -> let s' = T.drop 1 rest in s' : go s'
+
+
+parseItem :: Text -> Maybe Item
+parseItem raw
+    | T.null t = Nothing
+    | Just rest <- T.stripPrefix "module " t =
+        Just (ModuleItem (T.takeWhile isModuleChar (T.strip rest)))
+    | otherwise = do
+        let t' = dropNamespace t
+        (name, rest) <-
+            if "(" `T.isPrefixOf` t'
+                then first T.strip <$> balanced t'
+                else Just (T.break (\c -> isSpace c || c == '(') t')
+        guard (not (T.null name))
+        let subs = case balanced (T.strip rest) of
+                Just (inner, _) -> map (unparen . T.strip) (splitTopCommas inner)
+                Nothing -> []
+            (q, n) = splitQualified name
+        pure (NameItem q n subs)
+  where
+    t = T.strip raw
+    dropNamespace s = fromMaybe s (asum [T.strip <$> T.stripPrefix p s | p <- ["type ", "pattern "]])
+    unparen s = maybe s (T.strip . fst) (balanced s)
+
+
+-- | Split a qualified name such as @T.pack@ or @Data.Text.Text@ into its
+-- qualifier and its base name.
+splitQualified :: Text -> (Maybe Text, Text)
+splitQualified name =
+    case T.splitOn "." name of
+        segs@(_ : _ : _)
+            | Just (quals, base) <- unsnoc segs
+            , not (T.null base)
+            , all isConid quals ->
+                (Just (T.intercalate "." quals), base)
+        _ -> (Nothing, name)
+  where
+    isConid s = maybe False (\(c, rest) -> isUpper c && T.all isIdentChar rest) (T.uncons s)
+    unsnoc xs = (,) <$> viaNonEmpty init xs <*> viaNonEmpty last xs
+
+
+-- | Given text starting with @(@, the contents up to the matching @)@ and the
+-- text after it.
+balanced :: Text -> Maybe (Text, Text)
+balanced t = do
+    rest <- T.stripPrefix "(" t
+    let go :: Int -> Int -> String -> Maybe Int
+        go _ _ [] = Nothing
+        go depth n (c : cs)
+            | c == ')' && depth == 0 = Just n
+            | c == ')' = go (depth - 1) (n + 1) cs
+            | c == '(' = go (depth + 1) (n + 1) cs
+            | otherwise = go depth (n + 1) cs
+    n <- go 0 0 (toString rest)
+    pure (T.take n rest, T.drop (n + 1) rest)
+
+
+splitTopCommas :: Text -> [Text]
+splitTopCommas = map toText . go (0 :: Int) [] . toString
+  where
+    go _ acc [] = [reverse acc]
+    go depth acc (c : cs)
+        | c == ',' && depth == 0 = reverse acc : go depth [] cs
+        | c == '(' = go (depth + 1) (c : acc) cs
+        | c == ')' = go (depth - 1) (c : acc) cs
+        | otherwise = go depth (c : acc) cs
+
+
+stripComments :: Text -> Text
+stripComments = toText . code . toString
+  where
+    code [] = []
+    code ('{' : '-' : cs) = ' ' : block (1 :: Int) cs
+    code ('"' : cs) = '"' : str cs
+    code ('\'' : '\\' : cs) = let (lit, rest) = break (== '\'') cs in '\'' : '\\' : lit <> code rest
+    code ('\'' : c : '\'' : cs) = '\'' : c : '\'' : code cs
+    code s@(c : _)
+        | isIdentChar c = let (ident, rest) = span isIdentChar s in ident <> code rest
+        | isSymbolChar c =
+            let (op, rest) = span isSymbolChar s
+            in  if length op >= 2 && all (== '-') op
+                    then code (dropWhile (/= '\n') rest)
+                    else op <> code rest
+    code (c : cs) = c : code cs
+
+    block _ [] = []
+    block depth ('-' : '}' : cs)
+        | depth == 1 = code cs
+        | otherwise = block (depth - 1) cs
+    block depth ('{' : '-' : cs) = block (depth + 1) cs
+    block depth ('\n' : cs) = '\n' : block depth cs
+    block depth (_ : cs) = block depth cs
+
+    str [] = []
+    str ('\\' : c : cs) = '\\' : c : str cs
+    str ('"' : cs) = '"' : code cs
+    str (c : cs) = c : str cs
+
+
+isIdentChar :: Char -> Bool
+isIdentChar c = isAlphaNum c || c == '_' || c == '\''
+
+
+isModuleChar :: Char -> Bool
+isModuleChar c = isAlphaNum c || c == '_' || c == '\'' || c == '.'
+
+
+isSymbolChar :: Char -> Bool
+isSymbolChar c = c `elem` ("!#$%&*+./<=>?@\\^|-~:" :: String)
diff --git a/src/Tricorder/SourceLookup/Tarball.hs b/src/Tricorder/SourceLookup/Tarball.hs
--- a/src/Tricorder/SourceLookup/Tarball.hs
+++ b/src/Tricorder/SourceLookup/Tarball.hs
@@ -5,6 +5,7 @@
       TarballOutcome (..)
     , obtainTarball
     , readModuleMember
+    , sourceExtensions
 
       -- * Pure helpers (exposed for testing)
     , tarballPath
diff --git a/test/Unit/Tricorder/CLI/App/Test/SuiteViewSpec.hs b/test/Unit/Tricorder/CLI/App/Test/SuiteViewSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Unit/Tricorder/CLI/App/Test/SuiteViewSpec.hs
@@ -0,0 +1,119 @@
+module Unit.Tricorder.CLI.App.Test.SuiteViewSpec (test_SuiteView) where
+
+import Data.Aeson (Value (..), toJSON)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (testCase, (@?=))
+
+import Data.Aeson.Key qualified as Key
+import Data.Aeson.KeyMap qualified as KeyMap
+import Data.Map.Strict qualified as Map
+import Tricorder.CLI.Command.TestFilter qualified as TestFilter
+import Tricorder.FieldMask.Schema qualified as FieldMask (Schema (..))
+import Tricorder.FieldMask.Schema qualified as FieldMask.Schema
+
+import Tricorder.Build.Duration (Duration (..))
+import Tricorder.CLI.App.Test.SuiteView
+    ( CaseView (..)
+    , Outcome (..)
+    , Status (..)
+    , SuiteView (..)
+    , defaultFields
+    , schema
+    , selectSuites
+    )
+import Tricorder.Session.TestTarget (TestTarget)
+
+import Tricorder.Build.Test qualified as Test
+import Tricorder.Session.TestTarget qualified as TestTarget
+
+
+test_SuiteView :: TestTree
+test_SuiteView =
+    testGroup
+        "SuiteView"
+        [ testCase "schema lists every field of the JSON encoding" do
+            schemaKeys schema @?= jsonKeys (toJSON fullView)
+        , testCase "default fields are in the schema" do
+            void (FieldMask.Schema.select schema defaultFields) @?= Right ()
+        , testGroup "selectSuites" testSelectSuites
+        ]
+
+
+testSelectSuites :: [TestTree]
+testSelectSuites =
+    [ testCase "shows all suites when none are requested" do
+        names (selectSuites [] TestFilter.All suites)
+            @?= Right ["test:errored", "test:failing", "test:passing"]
+    , testCase "shows requested suites in the requested order" do
+        names (selectSuites [target "test:passing", target "test:failing"] TestFilter.All suites)
+            @?= Right ["test:passing", "test:failing"]
+    , testCase "fails on suites that do not exist" do
+        names (selectSuites [target "test:passing", target "test:nope"] TestFilter.All suites)
+            @?= Left (target "test:nope" :| [])
+    , testCase "failed-only keeps failed and errored suites" do
+        names (selectSuites [] TestFilter.FailedOnly suites) @?= Right ["test:errored", "test:failing"]
+    , testCase "failed-only keeps only failed test cases" do
+        fmap (fmap (.cases)) (selectSuites [target "test:failing"] TestFilter.FailedOnly suites)
+            @?= Right [Just [CaseView "breaks" CaseFailed (Just "boom")]]
+    , testCase "counts cases before filtering them" do
+        fmap
+            (fmap (\s -> (s.passedCases, s.totalCases)))
+            (selectSuites [target "test:failing"] TestFilter.FailedOnly suites)
+            @?= Right [(Just 1, Just 2)]
+    , testCase "errored suites carry their error" do
+        fmap (fmap (\s -> (s.status, s.error))) (selectSuites [target "test:errored"] TestFilter.All suites)
+            @?= Right [(Errored, Just "did not compile")]
+    ]
+  where
+    names = fmap (fmap (.name))
+    suites =
+        Test.Suites
+            $ Map.fromList
+                [ (target "test:passing", completed True [Test.Case "works" Test.Passed])
+                ,
+                    ( target "test:failing"
+                    , completed False [Test.Case "works" Test.Passed, Test.Case "breaks" (Test.Failed "boom")]
+                    )
+                , (target "test:errored", Test.SuiteErrored $ Test.SuiteError "did not compile")
+                ]
+    completed passed testCases =
+        Test.SuiteCompleted
+            $ Test.SuiteCompletion {passed, output = "", testCases, duration = Nothing}
+
+
+target :: Text -> TestTarget
+target name = fromMaybe (Prelude.error $ "invalid test target: " <> name) $ TestTarget.parse name
+
+
+-- | A view with every field set, so every field shows up in its JSON.
+fullView :: SuiteView
+fullView =
+    SuiteView
+        { name = "test:suite"
+        , status = Failed
+        , error = Just "error"
+        , passedCases = Just 0
+        , totalCases = Just 1
+        , durationMs = Just $ Duration 5
+        , output = Just "output"
+        , cases = Just [CaseView "case" CaseFailed (Just "failure")]
+        }
+
+
+-- | Keys of a JSON value, nested keys of arrays taken from their first element.
+data Keys = Keys [(Text, Maybe Keys)]
+    deriving stock (Eq, Show)
+
+
+schemaKeys :: FieldMask.Schema -> Keys
+schemaKeys (FieldMask.Schema fields) = Keys $ sortOn fst $ second (fmap schemaKeys) <$> fields
+
+
+jsonKeys :: Value -> Keys
+jsonKeys = \case
+    Object obj -> Keys $ sortOn fst [(Key.toText key, nestedKeys value) | (key, value) <- KeyMap.toList obj]
+    _ -> Keys []
+  where
+    nestedKeys = \case
+        Array values | Just element <- viaNonEmpty head (toList values) -> Just $ jsonKeys element
+        _ -> Nothing
diff --git a/test/Unit/Tricorder/Daemon/BuilderSpec.hs b/test/Unit/Tricorder/Daemon/BuilderSpec.hs
--- a/test/Unit/Tricorder/Daemon/BuilderSpec.hs
+++ b/test/Unit/Tricorder/Daemon/BuilderSpec.hs
@@ -10,12 +10,7 @@
 import Tricorder.Build (BuildResult (..), Diagnostic (..), Severity (..))
 import Tricorder.Build.Duration (Duration (..))
 import Tricorder.Daemon.Builder (NewLoadResult (..), compileBuildResults)
-import Tricorder.Daemon.GhciSession.GhciParser
-    ( LoadResult (..)
-    , LoadedModule (..)
-    , extractTitle
-    , resolveKnownTargets
-    )
+import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..))
 import Tricorder.Runtime (ProjectRoot (..))
 import Tricorder.Session.WatchDirs (WatchDirs (..))
 
@@ -24,9 +19,7 @@
 test_Builder =
     testGroup
         "Builder"
-        [ testGroup "extractTitle" testExtractTitle
-        , testGroup "compileBuildResults" testCompileBuildResults
-        , testGroup "resolveKnownTargets" testResolveKnownTargets
+        [ testGroup "compileBuildResults" testCompileBuildResults
         ]
 
 
@@ -102,148 +95,6 @@
   where
     root = ProjectRoot "/"
     watchDirs = WatchDirs ["/src"]
-
-
---------------------------------------------------------------------------------
--- resolveKnownTargets tests
---------------------------------------------------------------------------------
-
-testResolveKnownTargets :: [TestTree]
-testResolveKnownTargets =
-    [ testCase "uses :show modules as the primary source for path↔name mapping" do
-        let result =
-                emptyLr
-                    { loadedModules =
-                        Map.fromList
-                            [
-                                ( "/abs/src/Foo.hs"
-                                , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}
-                                )
-                            ]
-                    , targetNames = ["Foo"]
-                    }
-        resolveKnownTargets Map.empty result
-            @?= Map.fromList
-                [
-                    ( "/abs/src/Foo.hs"
-                    , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}
-                    )
-                ]
-    , -- Regression test for the stale-results bug. After a failed compile, the
-      -- module disappears from :show modules but stays in :show targets. The
-      -- prior state's entry must be carried over so the dispatcher continues to
-      -- see the file as "known" and issues :reload (not :add) when the user
-      -- fixes the error.
-      testCase "carries over prior state for targets that are no longer in :show modules" do
-        let prev =
-                Map.fromList
-                    [
-                        ( "/abs/src/Foo.hs"
-                        , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}
-                        )
-                    ]
-            result =
-                emptyLr
-                    { loadedModules = Map.empty -- Foo failed to compile
-                    , targetNames = ["Foo"] -- but is still a target
-                    }
-        resolveKnownTargets prev result @?= prev
-    , testCase "drops targets that are no longer in :show targets" do
-        let prev =
-                Map.fromList
-                    [
-                        ( "/abs/src/Foo.hs"
-                        , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}
-                        )
-                    ]
-            result = emptyLr {loadedModules = Map.empty, targetNames = []}
-        resolveKnownTargets prev result @?= Map.empty
-    , -- Dropped from the path-keyed map because we have no path↔name entry;
-      -- the dispatcher still handles them via 'KnownTargetNames'.
-      testCase "drops targets that have neither a current :show modules entry nor prior state" do
-        let result = emptyLr {loadedModules = Map.empty, targetNames = ["BrandNew"]}
-        resolveKnownTargets Map.empty result @?= Map.empty
-    ]
-  where
-    emptyLr =
-        LoadResult
-            { moduleCount = 0
-            , compiledFiles = Set.empty
-            , loadedModules = Map.empty
-            , targetNames = []
-            , diagnostics = []
-            }
-
-
---------------------------------------------------------------------------------
--- extractTitle tests
---------------------------------------------------------------------------------
-
-testExtractTitle :: [TestTree]
-testExtractTitle =
-    [ testCase "returns empty string for empty message" do
-        extractTitle [] @?= ""
-    , -- New GHC style: header ends with [GHC-XXXXX], content on body lines.
-      -- Captured from GHC 9.10.2 with -Weverything.
-      testCase "extracts first body line for error with [GHC-XXXXX] code" do
-        extractTitle
-            [ "src/Tricorder/Config.hs:39:20: error: [GHC-83865]"
-            , "    \8226 Couldn't match expected type 'Int' with actual type 'Bool'"
-            , "    \8226 In the expression: True"
-            , "      In an equation for '_deliberateError': _deliberateError = True"
-            , "   |"
-            , "39 | _deliberateError = True"
-            , "   |                    ^^^^"
-            ]
-            @?= "\8226 Couldn't match expected type 'Int' with actual type 'Bool'"
-    , testCase "extracts first body line for warning with [GHC-XXXXX] [-Wfoo] codes" do
-        extractTitle
-            [ "src/Tricorder/Config.hs:38:26: warning: [GHC-55631] [-Wmissing-deriving-strategies]"
-            , "    No deriving strategy specified. Did you want stock, newtype, or anyclass?"
-            , "   |"
-            , "38 | data TestWarn = TestWarn deriving (Eq)"
-            , "   |                          ^^^^^^^^^^^^^"
-            ]
-            @?= "No deriving strategy specified. Did you want stock, newtype, or anyclass?"
-    , -- Old GHC style: message text is inline on the header line.
-      testCase "extracts inline content for old-style single-line error" do
-        extractTitle ["GHCi.hs:70:1: error: Parse error: naked expression at top level"]
-            @?= "Parse error: naked expression at top level"
-    , testCase "extracts inline content for old-style Warning (capital W)" do
-        extractTitle ["GHCi.hs:81:1: Warning: Defined but not used: \8216foo\8217"]
-            @?= "Defined but not used: \8216foo\8217"
-    , -- Multi-line without any inline message: position-only or "Warning:" header.
-      testCase "extracts first body line when header has position only" do
-        extractTitle
-            [ "GHCi.hs:72:13:"
-            , "    No instance for (Num ([String] -> [String]))"
-            , "      arising from the literal '1'"
-            ]
-            @?= "No instance for (Num ([String] -> [String]))"
-    , testCase "extracts first body line when header ends with 'Warning:'" do
-        extractTitle
-            [ "/src/TrieSpec.hs:(192,7)-(193,76): Warning:"
-            , "    A do-notation statement discarded a result of type '[()]'"
-            ]
-            @?= "A do-notation statement discarded a result of type '[()]'"
-    , -- Source display lines (pipe/caret) must be skipped.
-      testCase "skips source display lines when scanning body" do
-        extractTitle
-            [ "file.hs:1:1: error: [GHC-12345]"
-            , "   |"
-            , "1 | foo bar"
-            , "   |     ^^^"
-            , "    actual content here"
-            ]
-            @?= "actual content here"
-    , -- ANSI-escaped header (colour output): strip escapes before searching.
-      testCase "handles ANSI-escaped headers" do
-        extractTitle
-            [ "\ESC[;1msrc/Types.hs:11:1: \ESC[35mwarning:\ESC[0m \ESC[35m[-Wunused-imports]\ESC[0m"
-            , "    The import of 'Data.Data' is redundant"
-            ]
-            @?= "The import of 'Data.Data' is redundant"
-    ]
 
 
 --------------------------------------------------------------------------------
diff --git a/test/Unit/Tricorder/Daemon/DispatchSpec.hs b/test/Unit/Tricorder/Daemon/DispatchSpec.hs
--- a/test/Unit/Tricorder/Daemon/DispatchSpec.hs
+++ b/test/Unit/Tricorder/Daemon/DispatchSpec.hs
@@ -14,7 +14,8 @@
     , mergeDiagnostics
     , preserveFailureVisibility
     )
-import Tricorder.Daemon.GhciSession.GhciParser (LoadResult (..), collectResult)
+import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..))
+import Tricorder.Daemon.Ghci.Result (collectResult)
 import Tricorder.Session.WatchDirs (WatchDirs (..))
 
 
diff --git a/test/Unit/Tricorder/Daemon/Ghci/LoadResultSpec.hs b/test/Unit/Tricorder/Daemon/Ghci/LoadResultSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Unit/Tricorder/Daemon/Ghci/LoadResultSpec.hs
@@ -0,0 +1,87 @@
+module Unit.Tricorder.Daemon.Ghci.LoadResultSpec (test_LoadResult) where
+
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (testCase, (@?=))
+
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+
+import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..))
+import Tricorder.Daemon.Ghci.LoadedModule (LoadedModule (..))
+
+import Tricorder.Daemon.Ghci.LoadResult qualified as LoadResult
+
+
+test_LoadResult :: TestTree
+test_LoadResult =
+    testGroup
+        "LoadResult"
+        [ testGroup "resolveKnownTargets" testResolveKnownTargets
+        ]
+
+
+testResolveKnownTargets :: [TestTree]
+testResolveKnownTargets =
+    [ testCase "uses :show modules as the primary source for path↔name mapping" do
+        let result =
+                emptyLr
+                    { loadedModules =
+                        Map.fromList
+                            [
+                                ( "/abs/src/Foo.hs"
+                                , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}
+                                )
+                            ]
+                    , targetNames = ["Foo"]
+                    }
+        LoadResult.resolveKnownTargets Map.empty result
+            @?= Map.fromList
+                [
+                    ( "/abs/src/Foo.hs"
+                    , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}
+                    )
+                ]
+    , -- Regression test for the stale-results bug. After a failed compile, the
+      -- module disappears from :show modules but stays in :show targets. The
+      -- prior state's entry must be carried over so the dispatcher continues to
+      -- see the file as "known" and issues :reload (not :add) when the user
+      -- fixes the error.
+      testCase "carries over prior state for targets that are no longer in :show modules" do
+        let prev =
+                Map.fromList
+                    [
+                        ( "/abs/src/Foo.hs"
+                        , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}
+                        )
+                    ]
+            result =
+                emptyLr
+                    { loadedModules = Map.empty -- Foo failed to compile
+                    , targetNames = ["Foo"] -- but is still a target
+                    }
+        LoadResult.resolveKnownTargets prev result @?= prev
+    , testCase "drops targets that are no longer in :show targets" do
+        let prev =
+                Map.fromList
+                    [
+                        ( "/abs/src/Foo.hs"
+                        , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}
+                        )
+                    ]
+            result = emptyLr {loadedModules = Map.empty, targetNames = []}
+        LoadResult.resolveKnownTargets prev result @?= Map.empty
+    , -- Dropped from the path-keyed map because we have no path↔name entry;
+      -- the dispatcher still handles them via 'KnownTargetNames'.
+      testCase "drops targets that have neither a current :show modules entry nor prior state" do
+        let result = emptyLr {loadedModules = Map.empty, targetNames = ["BrandNew"]}
+        LoadResult.resolveKnownTargets Map.empty result @?= Map.empty
+    ]
+  where
+    emptyLr =
+        LoadResult
+            { moduleCount = 0
+            , compiledFiles = Set.empty
+            , loadedModules = Map.empty
+            , targetNames = []
+            , diagnostics = []
+            }
diff --git a/test/Unit/Tricorder/Daemon/Ghci/OutputSpec.hs b/test/Unit/Tricorder/Daemon/Ghci/OutputSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Unit/Tricorder/Daemon/Ghci/OutputSpec.hs
@@ -0,0 +1,78 @@
+module Unit.Tricorder.Daemon.Ghci.OutputSpec (test_Output) where
+
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (testCase, (@?=))
+
+import Tricorder.Daemon.Ghci.Output (parseShowModules, parseShowTargets)
+
+
+test_Output :: TestTree
+test_Output =
+    testGroup
+        "Output"
+        [ testGroup "parseShowModules" testParseShowModules
+        , testGroup "parseShowTargets" testParseShowTargets
+        ]
+
+
+testParseShowModules :: [TestTree]
+testParseShowModules =
+    [ testGroup "typical output" testShowModules
+    , testGroup "empty / blank input" testShowModulesEmpty
+    ]
+
+
+testShowModules :: [TestTree]
+testShowModules =
+    [ testCase "parses typical :show modules output" do
+        let input =
+                [ "Tricorder.Build     ( src/Tricorder.Build.hs, interpreted )"
+                , "Tricorder.Session        ( src/Tricorder/Session.hs, interpreted )"
+                , "Main                     ( app/Main.hs, interpreted )"
+                ]
+        parseShowModules input
+            @?= [ ("Tricorder.Build", "src/Tricorder.Build.hs")
+                , ("Tricorder.Session", "src/Tricorder/Session.hs")
+                , ("Main", "app/Main.hs")
+                ]
+    , testCase "parses absolute paths" do
+        parseShowModules ["Lib ( /home/user/project/src/Lib.hs, interpreted )"]
+            @?= [("Lib", "/home/user/project/src/Lib.hs")]
+    , testCase "strips ANSI codes before parsing" do
+        parseShowModules ["\ESC[1mMain\ESC[0m                     ( app/Main.hs, interpreted )"]
+            @?= [("Main", "app/Main.hs")]
+    ]
+
+
+testShowModulesEmpty :: [TestTree]
+testShowModulesEmpty =
+    [ testCase "returns empty list for empty input" do
+        parseShowModules [] @?= []
+    , testCase "returns empty list for blank lines" do
+        parseShowModules ["", "   ", "\t"] @?= []
+    , testCase "skips lines without '( '" do
+        parseShowModules ["just some random text"] @?= []
+    ]
+
+
+testParseShowTargets :: [TestTree]
+testParseShowTargets =
+    [ testCase "parses module names emitted by cabal repl --enable-multi-repl" do
+        parseShowTargets
+            [ "Atelier.Effects.Cache"
+            , "Atelier.Effects.Chan"
+            , "Paths_tricorder"
+            ]
+            @?= ["Atelier.Effects.Cache", "Atelier.Effects.Chan", "Paths_tricorder"]
+    , testCase "parses file-path targets emitted by plain ghci" do
+        parseShowTargets ["src/Foo.hs", "test/Bar.hs"]
+            @?= ["src/Foo.hs", "test/Bar.hs"]
+    , testCase "strips the leading '*' marker for the active interactive target" do
+        parseShowTargets ["*Main", "Foo.Bar"] @?= ["Main", "Foo.Bar"]
+    , testCase "strips ANSI escape sequences" do
+        parseShowTargets ["\ESC[1mFoo.Bar\ESC[0m"] @?= ["Foo.Bar"]
+    , testCase "skips blank and whitespace-only lines" do
+        parseShowTargets ["", "   ", "\t", "Real.Target"] @?= ["Real.Target"]
+    , testCase "returns empty list for empty input" do
+        parseShowTargets [] @?= []
+    ]
diff --git a/test/Unit/Tricorder/Daemon/Ghci/ProcessSpec.hs b/test/Unit/Tricorder/Daemon/Ghci/ProcessSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Unit/Tricorder/Daemon/Ghci/ProcessSpec.hs
@@ -0,0 +1,564 @@
+module Unit.Tricorder.Daemon.Ghci.ProcessSpec (test_Process) where
+
+import Atelier.Effects.Conc (runConc)
+import Atelier.Effects.Delay (runDelay)
+import Atelier.Effects.File (runFile)
+import Atelier.Effects.Log (Message (..), Severity (..), runLogNoOp, runLogWriter)
+import Atelier.Effects.Process (runProcessIO, terminateProcessGroup, withProcessGroup)
+import Atelier.Effects.Process.Internal (RunningProcess (..))
+import Atelier.Effects.Timeout (runTimeout)
+import Atelier.Time (Millisecond)
+import Control.Concurrent (threadDelay)
+import Control.Concurrent.STM (newTVarIO)
+import Control.Exception (IOException, catch)
+import Data.Char (isDigit)
+import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
+import Data.Time.Units (Second)
+import Effectful (runEff)
+import Effectful.Concurrent (runConcurrent)
+import Effectful.Exception (trySync)
+import Effectful.Writer.Static.Shared (runWriter)
+import System.IO (hGetLine)
+import System.Posix.Signals (nullSignal, sigKILL, signalProcess)
+import System.Process.Typed
+    ( createPipe
+    , getStderr
+    , getStdin
+    , getStdout
+    , setCreateGroup
+    , setStderr
+    , setStdin
+    , setStdout
+    , shell
+    , startProcess
+    , stopProcess
+    , waitExitCode
+    )
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertFailure, testCase, (@?), (@?=))
+
+import Atelier.Effects.Conc qualified as Conc
+import Atelier.Effects.Delay qualified as Delay
+import Atelier.Effects.File qualified as File
+import Atelier.Effects.Process qualified as AProc
+import Data.Text qualified as T
+import System.Process qualified as Process
+import System.Timeout qualified
+
+import Tricorder.Daemon.Ghci.InterruptDecision (InterruptDecision (..))
+import Tricorder.Daemon.Ghci.Process (Error (..), Process (..), UnexpectedExit (..))
+import Tricorder.Daemon.Ghci.Session.State (State (..))
+
+import Tricorder.Daemon.Ghci.InterruptDecision qualified as InterruptDecision
+import Tricorder.Daemon.Ghci.Process qualified as Process
+
+
+test_Process :: TestTree
+test_Process =
+    testGroup
+        "Process"
+        [ testGroup "decideInterrupt" testDecideInterrupt
+        , testGroup "drainUntil" testDrainUntil
+        , testGroup "execGhci" testExecGhciScope
+        , testGroup "execGhci (stale marker desync)" testExecGhciStaleMarker
+        , testGroup "execGhci (sync marker scope independence)" testSyncMarkerScopeIndependent
+        , testGroup "waitForBannerOrFail" testWaitForBannerOrFail
+        , testGroup "withProcessGroup (process group)" testWithProcessGroupCleanup
+        , testGroup "terminateProcessGroup (process group)" testTerminateProcessGroup
+        ]
+
+
+-- | Mirrors the private 'markerFor' helper (not exported), using the same
+-- reconstruction as 'testExecGhciStaleMarker' below.
+finishMarker :: Int -> Text
+finishMarker n = "#~TRI-FINISH-" <> show n <> "~#"
+
+
+testDrainUntil :: [TestTree]
+testDrainUntil =
+    [ testCase "returns accumulated non-marker lines in order and stops at the marker" do
+        (r, w) <- Process.createPipe
+        (result, _msgs) <-
+            runEff
+                . runWriter @[Message]
+                . runLogWriter
+                . runFile
+                $ do
+                    for_ ["line1", "line2", finishMarker 1, "line3"] (File.hPutTextLn w)
+                    File.hClose w
+                    Process.drainUntil r (finishMarker 1) (\_ -> pure ())
+        result @?= ["line1", "line2"]
+    , testCase "streams each non-marker line to onLine, in order, before returning" do
+        (r, w) <- Process.createPipe
+        seenRef <- newIORef []
+        (result, _msgs) <-
+            runEff
+                . runWriter @[Message]
+                . runLogWriter
+                . runFile
+                $ do
+                    for_ ["a", "b", "c", finishMarker 2] (File.hPutTextLn w)
+                    File.hClose w
+                    Process.drainUntil r (finishMarker 2) (\l -> liftIO $ modifyIORef' seenRef (l :))
+        seen <- reverse <$> readIORef seenRef
+        seen @?= ["a", "b", "c"]
+        result @?= seen
+    , testCase "skips a stale marker with a different suffix and keeps draining" do
+        (r, w) <- Process.createPipe
+        (result, _msgs) <-
+            runEff
+                . runWriter @[Message]
+                . runLogWriter
+                . runFile
+                $ do
+                    -- 'finishMarker 5' is a leftover from a prior, interrupted
+                    -- command; the drain waiting for 'finishMarker 9' must
+                    -- skip it rather than stopping.
+                    for_ ["before", finishMarker 5, "after", finishMarker 9] (File.hPutTextLn w)
+                    File.hClose w
+                    Process.drainUntil r (finishMarker 9) (\_ -> pure ())
+        result @?= ["before", "after"]
+    , testCase
+        "throws UnexpectedExit with ALL accumulated lines (in order) on EOF, not just the last one"
+        do
+            (r, w) <- Process.createPipe
+            (outcome, _msgs) <-
+                runEff
+                    . runWriter @[Message]
+                    . runLogWriter
+                    . runFile
+                    $ do
+                        for_ ["first", "second", "third"] (File.hPutTextLn w)
+                        File.hClose w -- EOF before the marker ever arrives
+                        trySync (Process.drainUntil r (finishMarker 1) (\_ -> pure ()))
+            case outcome of
+                Right ls -> assertFailure ("expected UnexpectedExit, got: " <> show ls)
+                Left ex -> case fromException ex of
+                    Just (UnexpectedExit (MkUnexpectedExit m ls)) -> do
+                        m @?= finishMarker 1
+                        ls
+                            @?= "Reached EOF before reading marker from GHCi.\nWas looking for marker '#~TRI-FINISH-1~#', but no such marker was found.\nAccumulated output from GHCi so far:\nfirst\nsecond\nthird"
+                    other -> assertFailure ("expected UnexpectedExit, got: " <> show other)
+    , testCase "throws UnexpectedExit with no lines when EOF is reached immediately" do
+        (r, w) <- Process.createPipe
+        (outcome, _msgs) <-
+            runEff
+                . runWriter @[Message]
+                . runLogWriter
+                . runFile
+                $ do
+                    File.hClose w
+                    trySync (Process.drainUntil r (finishMarker 1) (\_ -> pure ()))
+        case outcome of
+            Right ls -> assertFailure ("expected UnexpectedExit, got: " <> show ls)
+            Left ex -> case fromException ex of
+                Just (UnexpectedExit (MkUnexpectedExit m ls)) -> do
+                    m @?= finishMarker 1
+                    let expected =
+                            "Reached EOF before reading marker from GHCi.\nWas looking for marker '#~TRI-FINISH-1~#', but no such marker was found.\nGHCi returned no output before we reached what we believe is EOF.\nGot the following exception when attempting to read from GHCi:\n<file descriptor:"
+                    (expected `T.isPrefixOf` ls)
+                        @? ( "error message does not start with expected prefix\nExpected:\n"
+                                <> show expected
+                                <> "\nActual:\n"
+                                <> show ls
+                           )
+                other -> assertFailure ("expected UnexpectedExit, got: " <> show other)
+    , testCase
+        "logs an ERROR mentioning the missing marker and the exception when EOF is reached with no output"
+        do
+            (r, w) <- Process.createPipe
+            (_outcome, msgs) <-
+                runEff
+                    . runWriter @[Message]
+                    . runLogWriter
+                    . runFile
+                    $ do
+                        File.hClose w
+                        trySync (Process.drainUntil r (finishMarker 3) (\_ -> pure ()))
+            case filter (\m -> m.severity == ERROR) msgs of
+                [] -> assertFailure "expected an ERROR log message"
+                (logMsg : _) -> do
+                    (finishMarker 3 `T.isInfixOf` logMsg.text) @?= True
+                    ("GHCi returned no output" `T.isInfixOf` logMsg.text) @?= True
+    , testCase "logs an ERROR including the accumulated output when EOF is reached mid-output" do
+        (r, w) <- Process.createPipe
+        (_outcome, msgs) <-
+            runEff
+                . runWriter @[Message]
+                . runLogWriter
+                . runFile
+                $ do
+                    for_ ["oops-line-1", "oops-line-2"] (File.hPutTextLn w)
+                    File.hClose w
+                    trySync (Process.drainUntil r (finishMarker 4) (\_ -> pure ()))
+        case filter (\m -> m.severity == ERROR) msgs of
+            [] -> assertFailure "expected an ERROR log message"
+            (logMsg : _) -> do
+                (finishMarker 4 `T.isInfixOf` logMsg.text) @?= True
+                ("oops-line-1" `T.isInfixOf` logMsg.text) @?= True
+                ("oops-line-2" `T.isInfixOf` logMsg.text) @?= True
+    ]
+
+
+-- | Regression for the touch-during-reload desync. Interrupting a *Busy* GHCi
+-- (a reload in flight) leaves a stale sync marker in the stdout/stderr buffers
+-- ahead of the next command's real output. Because 'drainUntil' used to stop on
+-- ANY marker-prefix line, the next 'execGhci' matched that stale marker and
+-- returned *before its command ran* — surfacing as @All good. (0 modules)@ (or,
+-- on the other timing, a hang). 'execGhci' must skip markers that aren't its
+-- own and stop only on the marker it is waiting for.
+testExecGhciStaleMarker :: [TestTree]
+testExecGhciStaleMarker =
+    [ testCase "skips a stale leftover marker and returns the command's real output" do
+        (stdinR, stdinW) <- Process.createPipe
+        (stdoutR, stdoutW) <- Process.createPipe
+        (stderrR, stderrW) <- Process.createPipe
+        -- A dummy process handle to fill the record; 'execGhci' never uses it.
+        p <-
+            startProcess
+                $ setStdin createPipe
+                $ setStdout createPipe
+                $ setStderr createPipe
+                $ shell "true"
+        _ <- waitExitCode p
+        stateVar <- newTVarIO (Idle 9)
+        let gp =
+                Process
+                    { stdin = stdinW
+                    , stdout = stdoutR
+                    , stderr = stderrR
+                    , handle = RunningProcess p
+                    , stateVar
+                    }
+            -- Mirrors 'markerFor': "#~TRI-FINISH-<n>~#".
+            marker n = "#~TRI-FINISH-" <> show (n :: Int) <> "~#" :: Text
+        result <-
+            runEff
+                . runConcurrent
+                . runTimeout
+                . runDelay
+                . runFile
+                . runLogNoOp
+                . runConc
+                $ do
+                    -- A stale 'marker 5' (left by a prior interrupted reload)
+                    -- precedes the fresh command's real output + its 'marker 9'
+                    -- (state is 'Idle 9', so 'execGhci' waits for marker 9).
+                    for_ [marker 5, "out-line", marker 9] (File.hPutTextLn stdoutW)
+                    for_ [marker 5, "err-line", marker 9] (File.hPutTextLn stderrW)
+                    File.hClose stdoutW
+                    File.hClose stderrW
+                    -- Keep the stdin read-end alive across the write inside
+                    -- 'execGhci' (otherwise it is GC-finalised → broken pipe).
+                    r <- Process.exec gp "reload" (\_ -> pure ())
+                    File.hClose stdinR
+                    pure r
+        _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ())
+        result @?= ["out-line", "err-line"]
+    ]
+
+
+-- | Root-cause regression for the "stuck Building…" stall. A SIGINT-interrupted
+-- ':reload' empties GHCi's interactive scope — it drops the implicit
+-- @import Prelude@ (verified against ghci 9.10). A sync marker built from bare
+-- Prelude names then fails to run instead of printing: @putStrLn@ is no longer
+-- in scope, and neither is the @>>@ operator. The marker never appears, so
+-- 'drainUntil' blocks until the watchdog fires. The marker must therefore use
+-- only fully-qualified 'System.IO.hPutStrLn' statements (which survive the
+-- emptied scope), one per stream, with no bare names or operators.
+--
+-- We assert on exactly what 'execGhci' writes to GHCi's stdin.
+testSyncMarkerScopeIndependent :: [TestTree]
+testSyncMarkerScopeIndependent =
+    [ testCase "writes the finish marker using only fully-qualified names (no bare putStrLn / >>)" do
+        (stdinR, stdinW) <- Process.createPipe
+        (stdoutR, stdoutW) <- Process.createPipe
+        (stderrR, stderrW) <- Process.createPipe
+        p <-
+            startProcess
+                $ setStdin createPipe
+                $ setStdout createPipe
+                $ setStderr createPipe
+                $ shell "true"
+        _ <- waitExitCode p
+        stateVar <- newTVarIO (Idle 9)
+        let gp =
+                Process
+                    { stdin = stdinW
+                    , stdout = stdoutR
+                    , stderr = stderrR
+                    , handle = RunningProcess p
+                    , stateVar
+                    }
+            marker = "#~TRI-FINISH-9~#" :: Text
+        written <-
+            runEff
+                . runConcurrent
+                . runTimeout
+                . runDelay
+                . runFile
+                . runLogNoOp
+                . runConc
+                $ do
+                    -- Pre-seed the marker on both streams so the drain returns
+                    -- immediately; we only care about what was written to stdin.
+                    File.hPutTextLn stdoutW marker
+                    File.hPutTextLn stderrW marker
+                    File.hClose stdoutW
+                    File.hClose stderrW
+                    _ <- Process.exec gp ":reload" (\_ -> pure ())
+                    File.hClose stdinW
+                    let readAll acc =
+                            trySync (File.hGetLine stdinR) >>= \case
+                                Left (_ :: SomeException) -> pure (reverse acc)
+                                Right l -> readAll (l : acc)
+                    readAll []
+        _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ())
+        let blob = T.intercalate "\n" written
+        (" >> " `T.isInfixOf` blob) @?= False
+        ("System.IO.hPutStrLn System.IO.stdout" `T.isInfixOf` blob) @?= True
+        ("System.IO.hPutStrLn System.IO.stderr" `T.isInfixOf` blob) @?= True
+    ]
+
+
+-- | Regression: when the build command exits before printing a GHCi banner,
+-- 'waitForBannerOrFail' must surface the *complete* stderr output in the
+-- 'StartupFailed' error. The original implementation snapshotted the captured
+-- lines as soon as the process exited (it waited on 'waitExitCode'), racing
+-- the concurrent stderr drain — so a burst of error lines still buffered in
+-- the pipe was truncated, and the real cabal/build failure was lost.
+testWaitForBannerOrFail :: [TestTree]
+testWaitForBannerOrFail =
+    [ testCase "captures the full stderr output when the command exits before the banner" do
+        let lineCount = 200 :: Int
+            lastLine = "err line " <> show lineCount
+        -- The banner and error streams are pipes we drive ourselves, so the
+        -- timing is deterministic rather than a race against the OS pipe buffer.
+        (bannerOut, bannerOutW) <- Process.createPipe
+        (errR, errW) <- Process.createPipe
+        result <-
+            runEff
+                . runConcurrent
+                . runTimeout
+                . runDelay
+                . runFile
+                . runConc
+                $ do
+                    -- No banner will ever arrive: close the write end so the
+                    -- wait sees EOF at once and takes the "command exited"
+                    -- failure branch.
+                    File.hClose bannerOutW
+                    -- Producer: pause long enough that a snapshot-at-exit reads
+                    -- an empty buffer, THEN stream the whole error log and
+                    -- close so the drain sees EOF. A correct implementation
+                    -- awaits that drain before reading the captured lines.
+                    _ <- Conc.fork do
+                        Delay.wait (30 :: Millisecond)
+                        for_ [1 .. lineCount] \i ->
+                            File.hPutTextLn errW ("err line " <> show i :: Text)
+                        File.hClose errW
+                    trySync (Process.waitForBannerOrFail (5 :: Second) bannerOut errR)
+        case result of
+            Right () -> assertFailure "expected waitForBannerOrFail to throw a startup error"
+            Left ex -> case fromException ex of
+                Just (StartupFailed msg) ->
+                    (lastLine `T.isInfixOf` msg) @?= True
+                other ->
+                    assertFailure ("expected StartupFailed, got: " <> show other)
+    ]
+
+
+-- | Regression for orphaned/zombie build subprocesses on restart and shutdown.
+--
+-- The original leak was on the /graceful/ path: GHCi exits cleanly on @:quit@,
+-- so its leader is reaped, yet the build subprocesses sharing its group linger.
+-- 'withProcessGroup' must still sweep the whole group even after the leader has
+-- gone. We simulate it with a leader that forks a long-lived child sharing its
+-- group and exits on stdin input (mirroring @:quit@); after 'withProcessGroup'
+-- returns, the child must be gone.
+testWithProcessGroupCleanup :: [TestTree]
+testWithProcessGroupCleanup =
+    [ testCase "terminates the whole group on exit, even after the leader has exited" do
+        childPidRef <- newIORef (Nothing :: Maybe Int)
+        let scenario =
+                runEff
+                    . runConcurrent
+                    . runTimeout
+                    . runDelay
+                    . runFile
+                    . runConc
+                    . runProcessIO
+                    $ withProcessGroup procConfig \p -> do
+                        -- First stdout line is the long-lived child's pid.
+                        line <- File.hGetLine (AProc.getStdout p)
+                        liftIO $ writeIORef childPidRef (parsePid (T.unpack line))
+                        -- Let the leader exit and reap it, so the cleanup runs
+                        -- with the leader already gone — the path the bug needed.
+                        File.hPutTextLn (AProc.getStdin p) ""
+                        File.hFlush (AProc.getStdin p)
+                        void $ AProc.waitExitCode p
+        -- Hard wall-clock bound: a regression must surface as a failed
+        -- assertion, never as a hang that stalls the whole suite.
+        outcome <- System.Timeout.timeout (8_000_000) scenario
+        case outcome of
+            Nothing -> assertFailure "test timed out (process did not settle)"
+            Just () ->
+                readIORef childPidRef >>= \case
+                    Nothing -> assertFailure "could not capture the child pid"
+                    Just childPid -> do
+                        died <- waitForProcessDeath childPid
+                        -- Never leak the child if the assertion fails.
+                        ignoring (signalProcess sigKILL (fromIntegral childPid))
+                        died @?= True
+    ]
+  where
+    procConfig =
+        setStdin createPipe
+            $ setStdout createPipe
+            $ setStderr createPipe
+            $ shell "sleep 30 & echo \"$!\"; read _quit"
+
+
+-- | 'terminateProcessGroup' must kill the whole group when called mid-flight
+-- (the leader still alive) — the explicit early-termination path the test
+-- runner uses to abort a one-shot @cabal repl test:…@ from another thread.
+testTerminateProcessGroup :: [TestTree]
+testTerminateProcessGroup =
+    [ testCase "kills the whole group, not just the leader, mid-flight" do
+        outcome <- System.Timeout.timeout (8_000_000) do
+            p <-
+                startProcess
+                    $ setStdin createPipe
+                    $ setStdout createPipe
+                    $ setStderr createPipe
+                    $ setCreateGroup True
+                    $ shell "sleep 30 & echo \"$!\"; read _quit"
+            childLine <- hGetLine (getStdout p)
+            case parsePid childLine of
+                Nothing -> do
+                    ignoring (stopProcess p)
+                    assertFailure ("could not parse child pid from: " <> show childLine)
+                Just childPid -> do
+                    runEff . runProcessIO $ terminateProcessGroup (RunningProcess p)
+                    died <- waitForProcessDeath childPid
+                    -- Never leak the child if the assertion fails.
+                    ignoring (signalProcess sigKILL (fromIntegral childPid))
+                    pure died
+        outcome @?= Just True
+    ]
+
+
+-- | Parse a pid printed on its own line (tolerating surrounding whitespace).
+parsePid :: String -> Maybe Int
+parsePid = readMaybe . takeWhile isDigit . dropWhile (not . isDigit)
+
+
+-- | Swallow any exception from a best-effort cleanup action.
+ignoring :: IO () -> IO ()
+ignoring act = act `catch` \(_ :: SomeException) -> pure ()
+
+
+-- | Poll for up to ~3s for the given pid to disappear from the process table.
+-- @signalProcess nullSignal@ is a liveness probe: it throws once the process is
+-- gone (and reaped by init after being orphaned).
+waitForProcessDeath :: Int -> IO Bool
+waitForProcessDeath pid = go (60 :: Int)
+  where
+    go 0 = not <$> alive
+    go n = do
+        a <- alive
+        if not a then pure True else threadDelay 50_000 >> go (n - 1)
+    alive =
+        (signalProcess nullSignal (fromIntegral pid) >> pure True)
+            `catch` \(_ :: IOException) -> pure False
+
+
+testDecideInterrupt :: [TestTree]
+testDecideInterrupt =
+    -- Regression: an idle GHCi must not be SIGINT'd, since the matching
+    -- sync-marker write would leave a stale marker line in stdout/stderr
+    -- that the next 'execGhci' drain would match instead of the fresh one,
+    -- desyncing the protocol and reporting "0 modules" or hanging.
+    [ testCase "is a no-op when the session is Idle" do
+        InterruptDecision.decide (Idle 7) @?= (Idle 7, NoOpIdle)
+    , testCase "preserves the counter for any Idle state" do
+        InterruptDecision.decide (Idle 0) @?= (Idle 0, NoOpIdle)
+        InterruptDecision.decide (Idle 42) @?= (Idle 42, NoOpIdle)
+    , testCase "advances to Idle (n+1) and emits SendInterruptFor n when Busy" do
+        InterruptDecision.decide (Busy 7) @?= (Idle 8, SendInterruptFor 7)
+    , testCase "advances correctly from Busy 0" do
+        InterruptDecision.decide (Busy 0) @?= (Idle 1, SendInterruptFor 0)
+    ]
+
+
+-- | Pins down the 'Conc.scoped' fix in 'execGhci': when the drain forks
+-- raise 'UnexpectedExit' (because the underlying process exited and EOF'd
+-- the pipes), the exception must be CONTAINED inside 'execGhci' and
+-- surfaced via the caller's 'trySync' — not propagated to the ambient
+-- 'Conc.scoped' that called 'execGhci'.
+--
+-- Without the inner 'Conc.scoped' in 'execGhci', Ki propagates an
+-- exception from a forked thread to its owning scope. If 'execGhci' forks
+-- its drains directly into the ambient scope (the original bug), the
+-- ambient scope is torn down — siblings die, the whole builder cycle
+-- unwinds, and the daemon ends up in the "Restarting builder..." state
+-- the user observed.
+testExecGhciScope :: [TestTree]
+testExecGhciScope =
+    -- Spawn a real subprocess that exits immediately ('true'). Its
+    -- stdout/stderr pipes EOF as soon as the child exits, which makes
+    -- 'drainUntil' inside 'execGhci' throw 'UnexpectedExit' — exactly the
+    -- mid-command termination path the fix exists to handle.
+    [ testCase "contains drain exceptions inside its own scope so siblings survive" do
+        p <-
+            startProcess
+                $ setStdin createPipe
+                $ setStdout createPipe
+                $ setStderr createPipe
+                $ shell "true"
+        -- Wait for the child to actually exit so the pipes are EOF before
+        -- 'execGhci' starts draining (otherwise the drain blocks).
+        _ <- waitExitCode p
+        stateVar <- newTVarIO (Idle 0)
+        let gp =
+                Process
+                    { stdin = getStdin p
+                    , stdout = getStdout p
+                    , stderr = getStderr p
+                    , handle = RunningProcess p
+                    , stateVar
+                    }
+        siblingDoneRef <- newIORef False
+        result <-
+            runEff
+                . runConcurrent
+                . runTimeout
+                . runDelay
+                . runFile
+                . runLogNoOp
+                . runConc
+                $ Conc.scoped do
+                    -- A sibling fork in the SAME ambient scope. If the bug
+                    -- regresses, the drain exception will tear this scope
+                    -- down and the sibling will be cancelled before it can
+                    -- flip the ref.
+                    sibling <- Conc.fork do
+                        Delay.wait (50 :: Millisecond)
+                        liftIO (writeIORef siblingDoneRef True)
+                    -- Drive 'execGhci' on a dead process; the drains should
+                    -- raise 'UnexpectedExit', which 'trySync' must catch
+                    -- here rather than letting Ki tear down the scope.
+                    execResult <- trySync (Process.exec gp "cmd" (\_ -> pure ()))
+                    -- Wait for the sibling to run.
+                    Conc.await sibling
+                    pure execResult
+        -- stopProcess flushes the buffered command+marker to a pipe whose
+        -- read end is already closed, which raises ResourceVanished. The
+        -- subprocess has long since exited; just swallow the cleanup error.
+        _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ())
+        siblingDone <- readIORef siblingDoneRef
+        siblingDone @?= True
+        case result of
+            Right _ -> assertFailure "expected execGhci to raise UnexpectedExit"
+            Left _ -> pure ()
+    ]
diff --git a/test/Unit/Tricorder/Daemon/Ghci/ResultSpec.hs b/test/Unit/Tricorder/Daemon/Ghci/ResultSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Unit/Tricorder/Daemon/Ghci/ResultSpec.hs
@@ -0,0 +1,477 @@
+module Unit.Tricorder.Daemon.Ghci.ResultSpec (test_Result) where
+
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (testCase, (@?), (@?=))
+
+import Tricorder.Build (Severity (..))
+import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..))
+import Tricorder.Daemon.Ghci.Loading (Loading (..))
+import Tricorder.Daemon.Ghci.Message (Message (..))
+import Tricorder.Daemon.Ghci.Position (Position (..))
+import Tricorder.Daemon.Ghci.Result (collectResult, collectResultCustom, extractTitle, parseReload)
+
+import Tricorder.Build qualified as Build.Diagnostic
+import Tricorder.Daemon.Ghci.Load qualified as Load
+import Tricorder.Daemon.Ghci.LoadOutcome qualified as LoadOutcome
+import Tricorder.Daemon.Ghci.Severity qualified as Severity
+
+
+test_Result :: TestTree
+test_Result =
+    testGroup
+        "Result"
+        [ testGroup "extractTitle" testExtractTitle
+        , testGroup "parseReload" testParseReload
+        , testGroup "collectResultCustom" testCollectResultCustom
+        , testGroup "collectResult" testCollectResult
+        ]
+
+
+testExtractTitle :: [TestTree]
+testExtractTitle =
+    [ testCase "returns empty string for empty message" do
+        extractTitle [] @?= ""
+    , -- New GHC style: header ends with [GHC-XXXXX], content on body lines.
+      -- Captured from GHC 9.10.2 with -Weverything.
+      testCase "extracts first body line for error with [GHC-XXXXX] code" do
+        extractTitle
+            [ "src/Tricorder/Config.hs:39:20: error: [GHC-83865]"
+            , "    \8226 Couldn't match expected type 'Int' with actual type 'Bool'"
+            , "    \8226 In the expression: True"
+            , "      In an equation for '_deliberateError': _deliberateError = True"
+            , "   |"
+            , "39 | _deliberateError = True"
+            , "   |                    ^^^^"
+            ]
+            @?= "\8226 Couldn't match expected type 'Int' with actual type 'Bool'"
+    , testCase "extracts first body line for warning with [GHC-XXXXX] [-Wfoo] codes" do
+        extractTitle
+            [ "src/Tricorder/Config.hs:38:26: warning: [GHC-55631] [-Wmissing-deriving-strategies]"
+            , "    No deriving strategy specified. Did you want stock, newtype, or anyclass?"
+            , "   |"
+            , "38 | data TestWarn = TestWarn deriving (Eq)"
+            , "   |                          ^^^^^^^^^^^^^"
+            ]
+            @?= "No deriving strategy specified. Did you want stock, newtype, or anyclass?"
+    , -- Old GHC style: message text is inline on the header line.
+      testCase "extracts inline content for old-style single-line error" do
+        extractTitle ["GHCi.hs:70:1: error: Parse error: naked expression at top level"]
+            @?= "Parse error: naked expression at top level"
+    , testCase "extracts inline content for old-style Warning (capital W)" do
+        extractTitle ["GHCi.hs:81:1: Warning: Defined but not used: \8216foo\8217"]
+            @?= "Defined but not used: \8216foo\8217"
+    , -- Multi-line without any inline message: position-only or "Warning:" header.
+      testCase "extracts first body line when header has position only" do
+        extractTitle
+            [ "GHCi.hs:72:13:"
+            , "    No instance for (Num ([String] -> [String]))"
+            , "      arising from the literal '1'"
+            ]
+            @?= "No instance for (Num ([String] -> [String]))"
+    , testCase "extracts first body line when header ends with 'Warning:'" do
+        extractTitle
+            [ "/src/TrieSpec.hs:(192,7)-(193,76): Warning:"
+            , "    A do-notation statement discarded a result of type '[()]'"
+            ]
+            @?= "A do-notation statement discarded a result of type '[()]'"
+    , -- Source display lines (pipe/caret) must be skipped.
+      testCase "skips source display lines when scanning body" do
+        extractTitle
+            [ "file.hs:1:1: error: [GHC-12345]"
+            , "   |"
+            , "1 | foo bar"
+            , "   |     ^^^"
+            , "    actual content here"
+            ]
+            @?= "actual content here"
+    , -- ANSI-escaped header (colour output): strip escapes before searching.
+      testCase "handles ANSI-escaped headers" do
+        extractTitle
+            [ "\ESC[;1msrc/Types.hs:11:1: \ESC[35mwarning:\ESC[0m \ESC[35m[-Wunused-imports]\ESC[0m"
+            , "    The import of 'Data.Data' is redundant"
+            ]
+            @?= "The import of 'Data.Data' is redundant"
+    ]
+
+
+testParseReload :: [TestTree]
+testParseReload =
+    [ testGroup "clean build" testCleanBuild
+    , testGroup "with errors and warnings" testErrors
+    , testGroup "with -fhide-source-paths (no Loading items)" testHideSourcePaths
+    , testGroup "with <no location info> errors" testNoLocationInfo
+    , testGroup "with Loaded GHCi configuration" testLoadedConfig
+    ]
+
+
+testCleanBuild :: [TestTree]
+testCleanBuild =
+    [ testCase "produces GLoading items for each compiled module" do
+        let input =
+                [ "[1 of 3] Compiling Tricorder.Build ( src/Tricorder.Build.hs, interpreted )"
+                , "[2 of 3] Compiling Tricorder.Session    ( src/Tricorder/Session.hs, interpreted )"
+                , "[3 of 3] Compiling Main                 ( app/Main.hs, interpreted )"
+                , "Ok, 3 modules loaded."
+                ]
+        parseReload input
+            @?= [ Load.Loading
+                    Loading
+                        { index = 1
+                        , total = 3
+                        , moduleName = "Tricorder.Build"
+                        , sourceFile = "src/Tricorder.Build.hs"
+                        }
+                , Load.Loading
+                    Loading
+                        { index = 2
+                        , total = 3
+                        , moduleName = "Tricorder.Session"
+                        , sourceFile = "src/Tricorder/Session.hs"
+                        }
+                , Load.Loading Loading {index = 3, total = 3, moduleName = "Main", sourceFile = "app/Main.hs"}
+                , Load.Summary LoadOutcome.LoadSucceeded
+                ]
+    , testCase "handles padded module index (e.g. [ 1 of 47])" do
+        let input =
+                [ "[ 1 of 47] Compiling Main              ( app/Main.hs, interpreted )"
+                , "Ok, 1 module loaded."
+                ]
+        parseReload input
+            @?= [ Load.Loading Loading {index = 1, total = 47, moduleName = "Main", sourceFile = "app/Main.hs"}
+                , Load.Summary LoadOutcome.LoadSucceeded
+                ]
+    , testGroup
+        "when only summary line"
+        [ testCase "returns the summary outcome" do
+            parseReload ["Ok, 0 modules loaded."] @?= [Load.Summary LoadOutcome.LoadSucceeded]
+        ]
+    ]
+
+
+testErrors :: [TestTree]
+testErrors =
+    [ testCase "parses a single-line error" do
+        let input = ["src/Foo.hs:10:5: error: Variable not in scope: foo"]
+        parseReload input
+            @?= [ Load.Message
+                    Message
+                        { severity = Severity.Error
+                        , file = "src/Foo.hs"
+                        , startPos = Position 10 5
+                        , endPos = Position 10 5
+                        , messageLines = ["src/Foo.hs:10:5: error: Variable not in scope: foo"]
+                        }
+                ]
+    , testCase "parses a warning with continuation lines" do
+        let input =
+                [ "src/Bar.hs:20:3: warning: [-Wunused-imports]"
+                , "    Redundant import: Data.List"
+                , "    Perhaps you want to remove it."
+                ]
+        parseReload input
+            @?= [ Load.Message
+                    Message
+                        { severity = Severity.Warning
+                        , file = "src/Bar.hs"
+                        , startPos = Position 20 3
+                        , endPos = Position 20 3
+                        , messageLines =
+                            [ "src/Bar.hs:20:3: warning: [-Wunused-imports]"
+                            , "    Redundant import: Data.List"
+                            , "    Perhaps you want to remove it."
+                            ]
+                        }
+                ]
+    , testCase "parses a span position (L:C-C2:)" do
+        let input = ["src/Baz.hs:5:1-10: error: Parse error"]
+        parseReload input
+            @?= [ Load.Message
+                    Message
+                        { severity = Severity.Error
+                        , file = "src/Baz.hs"
+                        , startPos = Position 5 1
+                        , endPos = Position 5 10
+                        , messageLines = ["src/Baz.hs:5:1-10: error: Parse error"]
+                        }
+                ]
+    , testCase "parses a span position ((L1,C1)-(L2,C2):)" do
+        let input = ["src/Qux.hs:(3,1)-(5,20): error: Multi-line error"]
+        parseReload input
+            @?= [ Load.Message
+                    Message
+                        { severity = Severity.Error
+                        , file = "src/Qux.hs"
+                        , startPos = Position 3 1
+                        , endPos = Position 5 20
+                        , messageLines = ["src/Qux.hs:(3,1)-(5,20): error: Multi-line error"]
+                        }
+                ]
+    , testCase "parses a span position with double-paren end ((L1,C1)-((L2,C2):)" do
+        let input = ["src/Qux.hs:(3,1)-((5,20): error: Multi-line error"]
+        parseReload input
+            @?= [ Load.Message
+                    Message
+                        { severity = Severity.Error
+                        , file = "src/Qux.hs"
+                        , startPos = Position 3 1
+                        , endPos = Position 5 20
+                        , messageLines = ["src/Qux.hs:(3,1)-((5,20): error: Multi-line error"]
+                        }
+                ]
+    , testCase "parses source-display continuation lines (pipe format)" do
+        let input =
+                [ "src/Foo.hs:10:5: error: Variable not in scope: foo"
+                , "   |"
+                , "10 | foo bar"
+                , "   | ^^^"
+                , "    Suggested fix: import Foo"
+                ]
+        parseReload input
+            @?= [ Load.Message
+                    Message
+                        { severity = Severity.Error
+                        , file = "src/Foo.hs"
+                        , startPos = Position 10 5
+                        , endPos = Position 10 5
+                        , messageLines =
+                            [ "src/Foo.hs:10:5: error: Variable not in scope: foo"
+                            , "   |"
+                            , "10 | foo bar"
+                            , "   | ^^^"
+                            , "    Suggested fix: import Foo"
+                            ]
+                        }
+                ]
+    , testCase "strips ANSI codes from header for matching but stores original in glMessage" do
+        let ansiHeader = "\ESC[1msrc/Foo.hs:10:5:\ESC[0m \ESC[91merror:\ESC[0m Variable not in scope: foo"
+        parseReload [ansiHeader]
+            @?= [ Load.Message
+                    Message
+                        { severity = Severity.Error
+                        , file = "src/Foo.hs"
+                        , startPos = Position 10 5
+                        , endPos = Position 10 5
+                        , messageLines = [ansiHeader]
+                        }
+                ]
+    , testCase "parses a Windows drive-letter path in a diagnostic" do
+        let input = ["C:\\path\\file.hs:10:5: error: Variable not in scope: foo"]
+        parseReload input
+            @?= [ Load.Message
+                    Message
+                        { severity = Severity.Error
+                        , file = "C:\\path\\file.hs"
+                        , startPos = Position 10 5
+                        , endPos = Position 10 5
+                        , messageLines = ["C:\\path\\file.hs:10:5: error: Variable not in scope: foo"]
+                        }
+                ]
+    , testCase "parses mixed Loading, Message, and summary items" do
+        let input =
+                [ "[1 of 2] Compiling Lib ( src/Lib.hs, interpreted )"
+                , "src/Lib.hs:5:1: error: Oops"
+                , "[2 of 2] Compiling Main ( app/Main.hs, interpreted )"
+                , "Failed, 1 module loaded."
+                ]
+        parseReload input
+            @?= [ Load.Loading Loading {index = 1, total = 2, moduleName = "Lib", sourceFile = "src/Lib.hs"}
+                , Load.Message
+                    Message
+                        { severity = Severity.Error
+                        , file = "src/Lib.hs"
+                        , startPos = Position 5 1
+                        , endPos = Position 5 1
+                        , messageLines = ["src/Lib.hs:5:1: error: Oops"]
+                        }
+                , Load.Loading Loading {index = 2, total = 2, moduleName = "Main", sourceFile = "app/Main.hs"}
+                , Load.Summary LoadOutcome.LoadFailed
+                ]
+    ]
+
+
+testHideSourcePaths :: [TestTree]
+testHideSourcePaths =
+    [ testGroup
+        "when source paths are hidden"
+        [ testCase "produces no GLoading items" do
+            let input =
+                    [ "src/Foo.hs:10:5: error: Variable not in scope: foo"
+                    , "    Perhaps you meant: 'bar'"
+                    , "Failed, one module failed to load."
+                    ]
+            parseReload input
+                @?= [ Load.Message
+                        Message
+                            { severity = Severity.Error
+                            , file = "src/Foo.hs"
+                            , startPos = Position 10 5
+                            , endPos = Position 10 5
+                            , messageLines =
+                                [ "src/Foo.hs:10:5: error: Variable not in scope: foo"
+                                , "    Perhaps you meant: 'bar'"
+                                ]
+                            }
+                    , Load.Summary LoadOutcome.LoadFailed
+                    ]
+        ]
+    , testGroup
+        "when all modules are already up to date"
+        [ testCase "returns the summary outcome" do
+            -- GHCi with -fhide-source-paths and nothing to recompile
+            parseReload ["Ok, 5 modules loaded."] @?= [Load.Summary LoadOutcome.LoadSucceeded]
+        ]
+    ]
+
+
+testNoLocationInfo :: [TestTree]
+testNoLocationInfo =
+    [ testCase "handles <no location info>: error: with continuation" do
+        let input =
+                [ "<no location info>: error:"
+                , "    Module `Tricorder.Missing' is not loaded."
+                ]
+        parseReload input
+            @?= [ Load.Message
+                    Message
+                        { severity = Severity.Error
+                        , file = "<no location info>"
+                        , startPos = Position 0 0
+                        , endPos = Position 0 0
+                        , messageLines =
+                            [ "<no location info>: error:"
+                            , "    Module `Tricorder.Missing' is not loaded."
+                            ]
+                        }
+                ]
+    , testCase "handles <no location info>: error: with no continuation" do
+        parseReload ["<no location info>: error: some error"]
+            @?= [ Load.Message
+                    Message
+                        { severity = Severity.Error
+                        , file = "<no location info>"
+                        , startPos = Position 0 0
+                        , endPos = Position 0 0
+                        , messageLines = ["<no location info>: error: some error"]
+                        }
+                ]
+    ]
+
+
+testLoadedConfig :: [TestTree]
+testLoadedConfig =
+    [ testCase "parses a GHCi configuration line" do
+        parseReload ["Loaded GHCi configuration from /home/user/project/.ghci"]
+            @?= [Load.LoadConfig "/home/user/project/.ghci"]
+    , testCase "parses a Windows-style GHCi configuration path" do
+        parseReload ["Loaded GHCi configuration from C:\\Users\\user\\project\\.ghci"]
+            @?= [Load.LoadConfig "C:\\Users\\user\\project\\.ghci"]
+    , testCase "handles config line mixed with other output" do
+        let input =
+                [ "Loaded GHCi configuration from .ghci"
+                , "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"
+                , "Ok, 1 module loaded."
+                ]
+        parseReload input
+            @?= [ Load.LoadConfig ".ghci"
+                , Load.Loading Loading {index = 1, total = 1, moduleName = "Main", sourceFile = "app/Main.hs"}
+                , Load.Summary LoadOutcome.LoadSucceeded
+                ]
+    ]
+
+
+testCollectResultCustom :: [TestTree]
+testCollectResultCustom =
+    [ testGroup "<no location info> plugin load failure" testPluginLoadFailure
+    ]
+
+
+-- | Regression test for the \"All good\" bug with home-unit GHC plugins.
+--
+-- Under @cabal repl --enable-multi-repl@ every unit is interpreted, so a
+-- package used as a GHC plugin in the same project is not available as a
+-- compiled plugin. The unit that depends on it fails to load, GHCi reports the
+-- failure with @\<no location info\>@ (it has no source span), and the load
+-- ends with @Failed, N modules loaded@. This output must still surface as an
+-- error diagnostic — otherwise the build is silently reported as clean.
+testPluginLoadFailure :: [TestTree]
+testPluginLoadFailure =
+    -- Shape of the GHCi output from `cabal repl --enable-multi-repl` when an
+    -- executable loads a home-unit GHC plugin: the plugin package's modules
+    -- compile, then the unit using the plugin fails with a location-less error.
+    [ testCase "surfaces the plugin load failure as an error diagnostic" do
+        Build.Diagnostic.SError `elem` map (.severity) result.diagnostics @? "expected an error diagnostic"
+    , testCase "carries the plugin error message in the diagnostic title" do
+        "Could not load module \8216My.Plugin\8217." `elem` map (.title) result.diagnostics
+            @? "expected the plugin error message in a diagnostic title"
+    ]
+  where
+    reloadOutput =
+        [ "[3 of 5] Compiling My.Plugin       ( src/My/Plugin.hs, interpreted )[plugin-pkg-1.0.0-inplace]"
+        , "<no location info>: error:"
+        , "    Could not load module \8216My.Plugin\8217."
+        , "It is a member of the hidden package \8216plugin-pkg-1.0.0\8217."
+        , "Perhaps you need to add \8216plugin-pkg\8217 to the build-depends in your .cabal file."
+        , "Use -v to see a list of the files searched for."
+        , ""
+        , "[5 of 5] Compiling Main            ( test/Tests.hs, interpreted )[app-pkg-0.1.0.0-inplace-test]"
+        , "Failed, 4 modules loaded."
+        ]
+    result = collectResultCustom "/project" (parseReload reloadOutput) [] []
+
+
+testCollectResult :: [TestTree]
+testCollectResult =
+    [ testGroup "failed load with no located error" testUnattributedFailure
+    ]
+
+
+-- 'collectResult' is the safety net: GHCi can end a load with @Failed, …@
+-- without emitting any error that carries a source span. The build must never
+-- read as clean in that case, so a synthetic error diagnostic is added.
+testUnattributedFailure :: [TestTree]
+testUnattributedFailure =
+    [ testGroup
+        "when the load failed but no error was located"
+        [ testCase "adds a synthetic error" do
+            let reloadOutput =
+                    [ "[1 of 2] Compiling Lib  ( src/Lib.hs, interpreted )"
+                    , "[2 of 2] Compiling Main ( app/Main.hs, interpreted )"
+                    , "Failed, 1 module loaded."
+                    ]
+                result = collectResult "/project" reloadOutput [] []
+            map (.severity) result.diagnostics @?= [SError]
+        ]
+    , testCase "does not duplicate a failure that already produced a located error" do
+        let reloadOutput =
+                [ "[1 of 1] Compiling Lib ( src/Lib.hs, interpreted )"
+                , "src/Lib.hs:5:1: error: Oops"
+                , "Failed, 0 modules loaded."
+                ]
+            result = collectResult "/project" reloadOutput [] []
+        -- Only the real, located diagnostic — no synthetic one appended.
+        map (.file) result.diagnostics @?= ["src/Lib.hs"]
+    , testCase "adds nothing for a successful load" do
+        let reloadOutput =
+                [ "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"
+                , "Ok, 1 module loaded."
+                ]
+            result = collectResult "/project" reloadOutput [] []
+        result.diagnostics @?= []
+    , testGroup
+        "when 'Failed,' appears off the summary line"
+        [ testCase "does not flag a clean build" do
+            -- The load outcome lives on GHCi's single summary line
+            -- ("Ok, …" / "Failed, …"). Output printed *during* the load — e.g. a
+            -- Template Haskell splice or top-level IO run while interpreting — can
+            -- contain a line that happens to begin with "Failed,". That must not be
+            -- mistaken for a failed load: the summary here is "Ok," so no synthetic
+            -- error belongs.
+            let reloadOutput =
+                    [ "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"
+                    , "Failed, retrying with fallback" -- printed by a TH splice
+                    , "Ok, 1 module loaded."
+                    ]
+                result = collectResult "/project" reloadOutput [] []
+            result.diagnostics @?= []
+        ]
+    ]
diff --git a/test/Unit/Tricorder/Daemon/Ghci/SessionSpec.hs b/test/Unit/Tricorder/Daemon/Ghci/SessionSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Unit/Tricorder/Daemon/Ghci/SessionSpec.hs
@@ -0,0 +1,152 @@
+module Unit.Tricorder.Daemon.Ghci.SessionSpec (test_Session) where
+
+import Atelier.Effects.PubSub.Pub (Pub)
+import Control.Exception (ErrorCall (..))
+import Effectful (IOE, runEff)
+import Effectful.Exception (try)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, testCase, (@?=))
+
+import Atelier.Effects.PubSub.Pub qualified as Pub
+import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
+
+import Tricorder.Build (BuildProgress, Diagnostic (..), Severity (..))
+import Tricorder.Daemon.Ghci.Controls (Controls (..))
+import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..))
+import Tricorder.Daemon.Ghci.Session (Session)
+import Tricorder.Runtime (ProjectRoot (..))
+import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))
+import Tricorder.Session.Stage (Stage (..))
+
+import Tricorder.Daemon.Ghci.Session qualified as Session
+
+
+test_Session :: TestTree
+test_Session =
+    testGroup
+        "Session"
+        [ testGroup "runScripted" testScripted
+        ]
+
+
+--------------------------------------------------------------------------------
+-- Scripted interpreter tests
+--------------------------------------------------------------------------------
+
+testScripted :: [TestTree]
+testScripted =
+    [ testGroup
+        "withGhci"
+        [ testGroup
+            "initial load"
+            [ testCase "returns scripted messages" do
+                LoadResult {diagnostics = msgs} <-
+                    runScripted [simpleResult [errMsg]]
+                        $ Session.with Pub.publish cmd (ProjectRoot "/") \initial _ -> pure initial
+                msgs @?= [errMsg]
+            , testCase "returns empty list when scripted result has no messages" do
+                LoadResult {diagnostics = msgs} <-
+                    runScripted [simpleResult []]
+                        $ Session.with Pub.publish cmd (ProjectRoot "/") \initial _ -> pure initial
+                msgs @?= []
+            , testCase "throws when scripted result is Left" do
+                result <-
+                    runScripted [Left (toException boom)]
+                        $ try @ErrorCall
+                        $ Session.with Pub.publish cmd (ProjectRoot "/") \initial _ -> pure initial
+                result @?= Left boom
+            ]
+        , testGroup
+            "reloading"
+            [ testCase "returns scripted messages" do
+                LoadResult {diagnostics = msgs} <-
+                    runScripted [simpleResult [warnMsg], simpleResult [errMsg]]
+                        $ Session.with Pub.publish cmd (ProjectRoot "/") \_ controls -> controls.reload
+                msgs @?= [errMsg]
+            , testCase "throws when scripted result is Left" do
+                result <-
+                    runScripted [Left (toException boom)]
+                        $ try @ErrorCall
+                        $ Session.with Pub.publish cmd (ProjectRoot "/") \_ controls -> controls.reload
+                result @?= Left boom
+            ]
+        ]
+    , testGroup
+        "sequencing"
+        [ testCase "consumes results in order across mixed operations" do
+            (a, b) <- runScripted [simpleResult [errMsg], simpleResult [warnMsg]] do
+                Session.with Pub.publish cmd (ProjectRoot "/") \LoadResult {diagnostics = a} controls -> do
+                    LoadResult {diagnostics = b} <- controls.reload
+                    pure (a, b)
+            a @?= [errMsg]
+            b @?= [warnMsg]
+        , testCase "recover scenario: error then success" do
+            result <- runScripted [Left (toException boom), simpleResult []] do
+                r1 <- try @ErrorCall $ Session.with Pub.publish cmd (ProjectRoot "/") \i _ -> pure i
+                LoadResult {diagnostics = r2} <- Session.with Pub.publish cmd (ProjectRoot "/") \i _ -> pure i
+                pure (r1, r2)
+            assertBool "expected Left" $ isLeft (fst result)
+            snd result @?= []
+        ]
+    ]
+
+
+--------------------------------------------------------------------------------
+-- Helpers
+--------------------------------------------------------------------------------
+
+cmd :: RenderedCommand 'Build
+cmd = RenderedCommand "cabal repl lib:foo"
+
+
+boom :: ErrorCall
+boom = ErrorCall "simulated GHCi crash"
+
+
+errMsg :: Diagnostic
+errMsg =
+    Diagnostic
+        { severity = SError
+        , file = "src/Foo.hs"
+        , line = 1
+        , col = 1
+        , endLine = 1
+        , endCol = 5
+        , title = "Variable not in scope: foo"
+        , text = "Variable not in scope: foo"
+        }
+
+
+warnMsg :: Diagnostic
+warnMsg =
+    Diagnostic
+        { severity = SWarning
+        , file = "src/Bar.hs"
+        , line = 10
+        , col = 3
+        , endLine = 10
+        , endCol = 8
+        , title = "Unused import"
+        , text = "Unused import"
+        }
+
+
+-- | Convenience constructor: a scripted result with no compiled-file info.
+simpleResult :: [Diagnostic] -> Either SomeException LoadResult
+simpleResult msgs =
+    Right
+        LoadResult
+            { moduleCount = 0
+            , compiledFiles = Set.empty
+            , loadedModules = Map.empty
+            , targetNames = []
+            , diagnostics = msgs
+            }
+
+
+runScripted
+    :: [Either SomeException LoadResult]
+    -> Eff '[Session, Pub BuildProgress, IOE] a
+    -> IO a
+runScripted results = runEff . Pub.runNoOp . Session.runScripted results
diff --git a/test/Unit/Tricorder/Daemon/GhciSession/GhciParserSpec.hs b/test/Unit/Tricorder/Daemon/GhciSession/GhciParserSpec.hs
deleted file mode 100644
--- a/test/Unit/Tricorder/Daemon/GhciSession/GhciParserSpec.hs
+++ /dev/null
@@ -1,503 +0,0 @@
-module Unit.Tricorder.Daemon.GhciSession.GhciParserSpec (test_GhciParser) where
-
-import Test.Tasty (TestTree, testGroup)
-import Test.Tasty.HUnit (assertBool, testCase, (@?=))
-
-import Tricorder.Build (Diagnostic (..), Severity (..))
-import Tricorder.Daemon.GhciSession.GhciParser
-    ( GhciLoad (..)
-    , GhciLoading (..)
-    , GhciMessage (..)
-    , GhciSeverity (..)
-    , LoadOutcome (..)
-    , LoadResult (..)
-    , Position (..)
-    , collectResult
-    , collectResultCustom
-    , parseReload
-    , parseShowModules
-    , parseShowTargets
-    )
-
-
-test_GhciParser :: TestTree
-test_GhciParser =
-    testGroup
-        "GhciParser"
-        [ testGroup
-            "parseReload"
-            [ testGroup "clean build" testCleanBuild
-            , testGroup "with errors and warnings" testErrors
-            , testGroup "with -fhide-source-paths (no Loading items)" testHideSourcePaths
-            , testGroup "with <no location info> errors" testNoLocationInfo
-            , testGroup "with Loaded GHCi configuration" testLoadedConfig
-            ]
-        , testGroup
-            "parseShowModules"
-            [ testGroup "typical output" testShowModules
-            , testGroup "empty / blank input" testShowModulesEmpty
-            ]
-        , testGroup "parseShowTargets" testShowTargets
-        , testGroup
-            "collectResultCustom"
-            [ testGroup "<no location info> plugin load failure" testPluginLoadFailure
-            ]
-        , testGroup
-            "collectResult"
-            [ testGroup "failed load with no located error" testUnattributedFailure
-            ]
-        ]
-
-
---------------------------------------------------------------------------------
--- parseReload: clean build
---------------------------------------------------------------------------------
-
-testCleanBuild :: [TestTree]
-testCleanBuild =
-    [ testCase "produces GLoading items for each compiled module" do
-        let input =
-                [ "[1 of 3] Compiling Tricorder.Build ( src/Tricorder.Build.hs, interpreted )"
-                , "[2 of 3] Compiling Tricorder.Session    ( src/Tricorder/Session.hs, interpreted )"
-                , "[3 of 3] Compiling Main                 ( app/Main.hs, interpreted )"
-                , "Ok, 3 modules loaded."
-                ]
-        parseReload input
-            @?= [ GLoading
-                    GhciLoading
-                        { index = 1
-                        , total = 3
-                        , moduleName = "Tricorder.Build"
-                        , sourceFile = "src/Tricorder.Build.hs"
-                        }
-                , GLoading
-                    GhciLoading
-                        { index = 2
-                        , total = 3
-                        , moduleName = "Tricorder.Session"
-                        , sourceFile = "src/Tricorder/Session.hs"
-                        }
-                , GLoading GhciLoading {index = 3, total = 3, moduleName = "Main", sourceFile = "app/Main.hs"}
-                , GSummary LoadSucceeded
-                ]
-    , testCase "handles padded module index (e.g. [ 1 of 47])" do
-        let input =
-                [ "[ 1 of 47] Compiling Main              ( app/Main.hs, interpreted )"
-                , "Ok, 1 module loaded."
-                ]
-        parseReload input
-            @?= [ GLoading GhciLoading {index = 1, total = 47, moduleName = "Main", sourceFile = "app/Main.hs"}
-                , GSummary LoadSucceeded
-                ]
-    , testGroup
-        "when only summary line"
-        [ testCase "returns the summary outcome" do
-            parseReload ["Ok, 0 modules loaded."] @?= [GSummary LoadSucceeded]
-        ]
-    ]
-
-
---------------------------------------------------------------------------------
--- parseReload: errors and warnings
---------------------------------------------------------------------------------
-
-testErrors :: [TestTree]
-testErrors =
-    [ testCase "parses a single-line error" do
-        let input = ["src/Foo.hs:10:5: error: Variable not in scope: foo"]
-        parseReload input
-            @?= [ GMessage
-                    GhciMessage
-                        { severity = GError
-                        , file = "src/Foo.hs"
-                        , startPos = Position 10 5
-                        , endPos = Position 10 5
-                        , messageLines = ["src/Foo.hs:10:5: error: Variable not in scope: foo"]
-                        }
-                ]
-    , testCase "parses a warning with continuation lines" do
-        let input =
-                [ "src/Bar.hs:20:3: warning: [-Wunused-imports]"
-                , "    Redundant import: Data.List"
-                , "    Perhaps you want to remove it."
-                ]
-        parseReload input
-            @?= [ GMessage
-                    GhciMessage
-                        { severity = GWarning
-                        , file = "src/Bar.hs"
-                        , startPos = Position 20 3
-                        , endPos = Position 20 3
-                        , messageLines =
-                            [ "src/Bar.hs:20:3: warning: [-Wunused-imports]"
-                            , "    Redundant import: Data.List"
-                            , "    Perhaps you want to remove it."
-                            ]
-                        }
-                ]
-    , testCase "parses a span position (L:C-C2:)" do
-        let input = ["src/Baz.hs:5:1-10: error: Parse error"]
-        parseReload input
-            @?= [ GMessage
-                    GhciMessage
-                        { severity = GError
-                        , file = "src/Baz.hs"
-                        , startPos = Position 5 1
-                        , endPos = Position 5 10
-                        , messageLines = ["src/Baz.hs:5:1-10: error: Parse error"]
-                        }
-                ]
-    , testCase "parses a span position ((L1,C1)-(L2,C2):)" do
-        let input = ["src/Qux.hs:(3,1)-(5,20): error: Multi-line error"]
-        parseReload input
-            @?= [ GMessage
-                    GhciMessage
-                        { severity = GError
-                        , file = "src/Qux.hs"
-                        , startPos = Position 3 1
-                        , endPos = Position 5 20
-                        , messageLines = ["src/Qux.hs:(3,1)-(5,20): error: Multi-line error"]
-                        }
-                ]
-    , testCase "parses a span position with double-paren end ((L1,C1)-((L2,C2):)" do
-        let input = ["src/Qux.hs:(3,1)-((5,20): error: Multi-line error"]
-        parseReload input
-            @?= [ GMessage
-                    GhciMessage
-                        { severity = GError
-                        , file = "src/Qux.hs"
-                        , startPos = Position 3 1
-                        , endPos = Position 5 20
-                        , messageLines = ["src/Qux.hs:(3,1)-((5,20): error: Multi-line error"]
-                        }
-                ]
-    , testCase "parses source-display continuation lines (pipe format)" do
-        let input =
-                [ "src/Foo.hs:10:5: error: Variable not in scope: foo"
-                , "   |"
-                , "10 | foo bar"
-                , "   | ^^^"
-                , "    Suggested fix: import Foo"
-                ]
-        parseReload input
-            @?= [ GMessage
-                    GhciMessage
-                        { severity = GError
-                        , file = "src/Foo.hs"
-                        , startPos = Position 10 5
-                        , endPos = Position 10 5
-                        , messageLines =
-                            [ "src/Foo.hs:10:5: error: Variable not in scope: foo"
-                            , "   |"
-                            , "10 | foo bar"
-                            , "   | ^^^"
-                            , "    Suggested fix: import Foo"
-                            ]
-                        }
-                ]
-    , testCase "strips ANSI codes from header for matching but stores original in glMessage" do
-        let ansiHeader = "\ESC[1msrc/Foo.hs:10:5:\ESC[0m \ESC[91merror:\ESC[0m Variable not in scope: foo"
-        parseReload [ansiHeader]
-            @?= [ GMessage
-                    GhciMessage
-                        { severity = GError
-                        , file = "src/Foo.hs"
-                        , startPos = Position 10 5
-                        , endPos = Position 10 5
-                        , messageLines = [ansiHeader]
-                        }
-                ]
-    , testCase "parses a Windows drive-letter path in a diagnostic" do
-        let input = ["C:\\path\\file.hs:10:5: error: Variable not in scope: foo"]
-        parseReload input
-            @?= [ GMessage
-                    GhciMessage
-                        { severity = GError
-                        , file = "C:\\path\\file.hs"
-                        , startPos = Position 10 5
-                        , endPos = Position 10 5
-                        , messageLines = ["C:\\path\\file.hs:10:5: error: Variable not in scope: foo"]
-                        }
-                ]
-    , testCase "parses mixed Loading, Message, and summary items" do
-        let input =
-                [ "[1 of 2] Compiling Lib ( src/Lib.hs, interpreted )"
-                , "src/Lib.hs:5:1: error: Oops"
-                , "[2 of 2] Compiling Main ( app/Main.hs, interpreted )"
-                , "Failed, 1 module loaded."
-                ]
-        parseReload input
-            @?= [ GLoading GhciLoading {index = 1, total = 2, moduleName = "Lib", sourceFile = "src/Lib.hs"}
-                , GMessage
-                    GhciMessage
-                        { severity = GError
-                        , file = "src/Lib.hs"
-                        , startPos = Position 5 1
-                        , endPos = Position 5 1
-                        , messageLines = ["src/Lib.hs:5:1: error: Oops"]
-                        }
-                , GLoading GhciLoading {index = 2, total = 2, moduleName = "Main", sourceFile = "app/Main.hs"}
-                , GSummary LoadFailed
-                ]
-    ]
-
-
---------------------------------------------------------------------------------
--- parseReload: -fhide-source-paths output
---------------------------------------------------------------------------------
-
-testHideSourcePaths :: [TestTree]
-testHideSourcePaths =
-    [ testGroup
-        "when source paths are hidden"
-        [ testCase "produces no GLoading items" do
-            let input =
-                    [ "src/Foo.hs:10:5: error: Variable not in scope: foo"
-                    , "    Perhaps you meant: 'bar'"
-                    , "Failed, one module failed to load."
-                    ]
-            parseReload input
-                @?= [ GMessage
-                        GhciMessage
-                            { severity = GError
-                            , file = "src/Foo.hs"
-                            , startPos = Position 10 5
-                            , endPos = Position 10 5
-                            , messageLines =
-                                [ "src/Foo.hs:10:5: error: Variable not in scope: foo"
-                                , "    Perhaps you meant: 'bar'"
-                                ]
-                            }
-                    , GSummary LoadFailed
-                    ]
-        ]
-    , testGroup
-        "when all modules are already up to date"
-        [ testCase "returns the summary outcome" do
-            -- GHCi with -fhide-source-paths and nothing to recompile
-            parseReload ["Ok, 5 modules loaded."] @?= [GSummary LoadSucceeded]
-        ]
-    ]
-
-
---------------------------------------------------------------------------------
--- parseReload: <no location info> errors
---------------------------------------------------------------------------------
-
-testNoLocationInfo :: [TestTree]
-testNoLocationInfo =
-    [ testCase "handles <no location info>: error: with continuation" do
-        let input =
-                [ "<no location info>: error:"
-                , "    Module `Tricorder.Missing' is not loaded."
-                ]
-        parseReload input
-            @?= [ GMessage
-                    GhciMessage
-                        { severity = GError
-                        , file = "<no location info>"
-                        , startPos = Position 0 0
-                        , endPos = Position 0 0
-                        , messageLines =
-                            [ "<no location info>: error:"
-                            , "    Module `Tricorder.Missing' is not loaded."
-                            ]
-                        }
-                ]
-    , testCase "handles <no location info>: error: with no continuation" do
-        parseReload ["<no location info>: error: some error"]
-            @?= [ GMessage
-                    GhciMessage
-                        { severity = GError
-                        , file = "<no location info>"
-                        , startPos = Position 0 0
-                        , endPos = Position 0 0
-                        , messageLines = ["<no location info>: error: some error"]
-                        }
-                ]
-    ]
-
-
---------------------------------------------------------------------------------
--- parseReload: Loaded GHCi configuration
---------------------------------------------------------------------------------
-
-testLoadedConfig :: [TestTree]
-testLoadedConfig =
-    [ testCase "parses a GHCi configuration line" do
-        parseReload ["Loaded GHCi configuration from /home/user/project/.ghci"]
-            @?= [GLoadConfig "/home/user/project/.ghci"]
-    , testCase "parses a Windows-style GHCi configuration path" do
-        parseReload ["Loaded GHCi configuration from C:\\Users\\user\\project\\.ghci"]
-            @?= [GLoadConfig "C:\\Users\\user\\project\\.ghci"]
-    , testCase "handles config line mixed with other output" do
-        let input =
-                [ "Loaded GHCi configuration from .ghci"
-                , "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"
-                , "Ok, 1 module loaded."
-                ]
-        parseReload input
-            @?= [ GLoadConfig ".ghci"
-                , GLoading GhciLoading {index = 1, total = 1, moduleName = "Main", sourceFile = "app/Main.hs"}
-                , GSummary LoadSucceeded
-                ]
-    ]
-
-
---------------------------------------------------------------------------------
--- parseShowModules
---------------------------------------------------------------------------------
-
-testShowModules :: [TestTree]
-testShowModules =
-    [ testCase "parses typical :show modules output" do
-        let input =
-                [ "Tricorder.Build     ( src/Tricorder.Build.hs, interpreted )"
-                , "Tricorder.Session        ( src/Tricorder/Session.hs, interpreted )"
-                , "Main                     ( app/Main.hs, interpreted )"
-                ]
-        parseShowModules input
-            @?= [ ("Tricorder.Build", "src/Tricorder.Build.hs")
-                , ("Tricorder.Session", "src/Tricorder/Session.hs")
-                , ("Main", "app/Main.hs")
-                ]
-    , testCase "parses absolute paths" do
-        parseShowModules ["Lib ( /home/user/project/src/Lib.hs, interpreted )"]
-            @?= [("Lib", "/home/user/project/src/Lib.hs")]
-    , testCase "strips ANSI codes before parsing" do
-        parseShowModules ["\ESC[1mMain\ESC[0m                     ( app/Main.hs, interpreted )"]
-            @?= [("Main", "app/Main.hs")]
-    ]
-
-
-testShowModulesEmpty :: [TestTree]
-testShowModulesEmpty =
-    [ testCase "returns empty list for empty input" do
-        parseShowModules [] @?= []
-    , testCase "returns empty list for blank lines" do
-        parseShowModules ["", "   ", "\t"] @?= []
-    , testCase "skips lines without '( '" do
-        parseShowModules ["just some random text"] @?= []
-    ]
-
-
---------------------------------------------------------------------------------
--- parseShowTargets
---------------------------------------------------------------------------------
-
-testShowTargets :: [TestTree]
-testShowTargets =
-    [ testCase "parses module names emitted by cabal repl --enable-multi-repl" do
-        parseShowTargets
-            [ "Atelier.Effects.Cache"
-            , "Atelier.Effects.Chan"
-            , "Paths_tricorder"
-            ]
-            @?= ["Atelier.Effects.Cache", "Atelier.Effects.Chan", "Paths_tricorder"]
-    , testCase "parses file-path targets emitted by plain ghci" do
-        parseShowTargets ["src/Foo.hs", "test/Bar.hs"]
-            @?= ["src/Foo.hs", "test/Bar.hs"]
-    , testCase "strips the leading '*' marker for the active interactive target" do
-        parseShowTargets ["*Main", "Foo.Bar"] @?= ["Main", "Foo.Bar"]
-    , testCase "strips ANSI escape sequences" do
-        parseShowTargets ["\ESC[1mFoo.Bar\ESC[0m"] @?= ["Foo.Bar"]
-    , testCase "skips blank and whitespace-only lines" do
-        parseShowTargets ["", "   ", "\t", "Real.Target"] @?= ["Real.Target"]
-    , testCase "returns empty list for empty input" do
-        parseShowTargets [] @?= []
-    ]
-
-
---------------------------------------------------------------------------------
--- collectResultCustom: <no location info> plugin load failure
---------------------------------------------------------------------------------
-
--- | Regression test for the \"All good\" bug with home-unit GHC plugins.
---
--- Under @cabal repl --enable-multi-repl@ every unit is interpreted, so a
--- package used as a GHC plugin in the same project is not available as a
--- compiled plugin. The unit that depends on it fails to load, GHCi reports the
--- failure with @\<no location info\>@ (it has no source span), and the load
--- ends with @Failed, N modules loaded@. This output must still surface as an
--- error diagnostic — otherwise the build is silently reported as clean.
-testPluginLoadFailure :: [TestTree]
-testPluginLoadFailure =
-    -- Shape of the GHCi output from `cabal repl --enable-multi-repl` when an
-    -- executable loads a home-unit GHC plugin: the plugin package's modules
-    -- compile, then the unit using the plugin fails with a location-less error.
-    [ testCase "surfaces the plugin load failure as an error diagnostic" do
-        assertBool "expected an error diagnostic"
-            $ SError `elem` map (.severity) result.diagnostics
-    , testCase "carries the plugin error message in the diagnostic title" do
-        assertBool "expected the plugin error message in a diagnostic title"
-            $ "Could not load module \8216My.Plugin\8217." `elem` map (.title) result.diagnostics
-    ]
-  where
-    reloadOutput =
-        [ "[3 of 5] Compiling My.Plugin       ( src/My/Plugin.hs, interpreted )[plugin-pkg-1.0.0-inplace]"
-        , "<no location info>: error:"
-        , "    Could not load module \8216My.Plugin\8217."
-        , "It is a member of the hidden package \8216plugin-pkg-1.0.0\8217."
-        , "Perhaps you need to add \8216plugin-pkg\8217 to the build-depends in your .cabal file."
-        , "Use -v to see a list of the files searched for."
-        , ""
-        , "[5 of 5] Compiling Main            ( test/Tests.hs, interpreted )[app-pkg-0.1.0.0-inplace-test]"
-        , "Failed, 4 modules loaded."
-        ]
-    result = collectResultCustom "/project" (parseReload reloadOutput) [] []
-
-
---------------------------------------------------------------------------------
--- collectResult: failed load with no located error
---------------------------------------------------------------------------------
-
--- 'collectResult' is the safety net: GHCi can end a load with @Failed, …@
--- without emitting any error that carries a source span. The build must never
--- read as clean in that case, so a synthetic error diagnostic is added.
-testUnattributedFailure :: [TestTree]
-testUnattributedFailure =
-    [ testGroup
-        "when the load failed but no error was located"
-        [ testCase "adds a synthetic error" do
-            let reloadOutput =
-                    [ "[1 of 2] Compiling Lib  ( src/Lib.hs, interpreted )"
-                    , "[2 of 2] Compiling Main ( app/Main.hs, interpreted )"
-                    , "Failed, 1 module loaded."
-                    ]
-                result = collectResult "/project" reloadOutput [] []
-            map (.severity) result.diagnostics @?= [SError]
-        ]
-    , testCase "does not duplicate a failure that already produced a located error" do
-        let reloadOutput =
-                [ "[1 of 1] Compiling Lib ( src/Lib.hs, interpreted )"
-                , "src/Lib.hs:5:1: error: Oops"
-                , "Failed, 0 modules loaded."
-                ]
-            result = collectResult "/project" reloadOutput [] []
-        -- Only the real, located diagnostic — no synthetic one appended.
-        map (.file) result.diagnostics @?= ["src/Lib.hs"]
-    , testCase "adds nothing for a successful load" do
-        let reloadOutput =
-                [ "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"
-                , "Ok, 1 module loaded."
-                ]
-            result = collectResult "/project" reloadOutput [] []
-        result.diagnostics @?= []
-    , testGroup
-        "when 'Failed,' appears off the summary line"
-        [ testCase "does not flag a clean build" do
-            -- The load outcome lives on GHCi's single summary line
-            -- ("Ok, …" / "Failed, …"). Output printed *during* the load — e.g. a
-            -- Template Haskell splice or top-level IO run while interpreting — can
-            -- contain a line that happens to begin with "Failed,". That must not be
-            -- mistaken for a failed load: the summary here is "Ok," so no synthetic
-            -- error belongs.
-            let reloadOutput =
-                    [ "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"
-                    , "Failed, retrying with fallback" -- printed by a TH splice
-                    , "Ok, 1 module loaded."
-                    ]
-                result = collectResult "/project" reloadOutput [] []
-            result.diagnostics @?= []
-        ]
-    ]
diff --git a/test/Unit/Tricorder/Daemon/GhciSession/GhciProcessSpec.hs b/test/Unit/Tricorder/Daemon/GhciSession/GhciProcessSpec.hs
deleted file mode 100644
--- a/test/Unit/Tricorder/Daemon/GhciSession/GhciProcessSpec.hs
+++ /dev/null
@@ -1,569 +0,0 @@
-module Unit.Tricorder.Daemon.GhciSession.GhciProcessSpec (test_GhciProcess) where
-
-import Atelier.Effects.Conc (runConc)
-import Atelier.Effects.Delay (runDelay)
-import Atelier.Effects.File (runFile)
-import Atelier.Effects.Log (Message (..), Severity (..), runLogNoOp, runLogWriter)
-import Atelier.Effects.Process (runProcessIO, terminateProcessGroup, withProcessGroup)
-import Atelier.Effects.Process.Internal (RunningProcess (..))
-import Atelier.Effects.Timeout (runTimeout)
-import Atelier.Time (Millisecond)
-import Control.Concurrent (threadDelay)
-import Control.Concurrent.STM (newTVarIO)
-import Control.Exception (IOException, catch)
-import Data.Char (isDigit)
-import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
-import Data.Time.Units (Second)
-import Effectful (runEff)
-import Effectful.Concurrent (runConcurrent)
-import Effectful.Exception (trySync)
-import Effectful.Writer.Static.Shared (runWriter)
-import System.IO (hGetLine)
-import System.Posix.Signals (nullSignal, sigKILL, signalProcess)
-import System.Process.Typed
-    ( createPipe
-    , getStderr
-    , getStdin
-    , getStdout
-    , setCreateGroup
-    , setStderr
-    , setStdin
-    , setStdout
-    , shell
-    , startProcess
-    , stopProcess
-    , waitExitCode
-    )
-import Test.Tasty (TestTree, testGroup)
-import Test.Tasty.HUnit (assertFailure, testCase, (@?), (@?=))
-
-import Atelier.Effects.Conc qualified as Conc
-import Atelier.Effects.Delay qualified as Delay
-import Atelier.Effects.File qualified as File
-import Atelier.Effects.Process qualified as AProc
-import Data.Text qualified as T
-import System.Process qualified as Process
-import System.Timeout qualified
-
-import Tricorder.Daemon.GhciSession.GhciProcess
-    ( GhciProcess (..)
-    , GhciProcessError (..)
-    , InterruptDecision (..)
-    , SessionState (..)
-    , UnexpectedExit (..)
-    , decideInterrupt
-    , drainUntil
-    , execGhci
-    , waitForBannerOrFail
-    )
-
-
-test_GhciProcess :: TestTree
-test_GhciProcess =
-    testGroup
-        "GhciProcess"
-        [ testGroup "decideInterrupt" testDecideInterrupt
-        , testGroup "drainUntil" testDrainUntil
-        , testGroup "execGhci" testExecGhciScope
-        , testGroup "execGhci (stale marker desync)" testExecGhciStaleMarker
-        , testGroup "execGhci (sync marker scope independence)" testSyncMarkerScopeIndependent
-        , testGroup "waitForBannerOrFail" testWaitForBannerOrFail
-        , testGroup "withProcessGroup (process group)" testWithProcessGroupCleanup
-        , testGroup "terminateProcessGroup (process group)" testTerminateProcessGroup
-        ]
-
-
--- | Mirrors the private 'markerFor' helper (not exported), using the same
--- reconstruction as 'testExecGhciStaleMarker' below.
-finishMarker :: Int -> Text
-finishMarker n = "#~TRI-FINISH-" <> show n <> "~#"
-
-
-testDrainUntil :: [TestTree]
-testDrainUntil =
-    [ testCase "returns accumulated non-marker lines in order and stops at the marker" do
-        (r, w) <- Process.createPipe
-        (result, _msgs) <-
-            runEff
-                . runWriter @[Message]
-                . runLogWriter
-                . runFile
-                $ do
-                    for_ ["line1", "line2", finishMarker 1, "line3"] (File.hPutTextLn w)
-                    File.hClose w
-                    drainUntil r (finishMarker 1) (\_ -> pure ())
-        result @?= ["line1", "line2"]
-    , testCase "streams each non-marker line to onLine, in order, before returning" do
-        (r, w) <- Process.createPipe
-        seenRef <- newIORef []
-        (result, _msgs) <-
-            runEff
-                . runWriter @[Message]
-                . runLogWriter
-                . runFile
-                $ do
-                    for_ ["a", "b", "c", finishMarker 2] (File.hPutTextLn w)
-                    File.hClose w
-                    drainUntil r (finishMarker 2) (\l -> liftIO $ modifyIORef' seenRef (l :))
-        seen <- reverse <$> readIORef seenRef
-        seen @?= ["a", "b", "c"]
-        result @?= seen
-    , testCase "skips a stale marker with a different suffix and keeps draining" do
-        (r, w) <- Process.createPipe
-        (result, _msgs) <-
-            runEff
-                . runWriter @[Message]
-                . runLogWriter
-                . runFile
-                $ do
-                    -- 'finishMarker 5' is a leftover from a prior, interrupted
-                    -- command; the drain waiting for 'finishMarker 9' must
-                    -- skip it rather than stopping.
-                    for_ ["before", finishMarker 5, "after", finishMarker 9] (File.hPutTextLn w)
-                    File.hClose w
-                    drainUntil r (finishMarker 9) (\_ -> pure ())
-        result @?= ["before", "after"]
-    , testCase
-        "throws UnexpectedExit with ALL accumulated lines (in order) on EOF, not just the last one"
-        do
-            (r, w) <- Process.createPipe
-            (outcome, _msgs) <-
-                runEff
-                    . runWriter @[Message]
-                    . runLogWriter
-                    . runFile
-                    $ do
-                        for_ ["first", "second", "third"] (File.hPutTextLn w)
-                        File.hClose w -- EOF before the marker ever arrives
-                        trySync (drainUntil r (finishMarker 1) (\_ -> pure ()))
-            case outcome of
-                Right ls -> assertFailure ("expected UnexpectedExit, got: " <> show ls)
-                Left ex -> case fromException ex of
-                    Just (UnexpectedExit (MkUnexpectedExit m ls)) -> do
-                        m @?= finishMarker 1
-                        ls
-                            @?= "Reached EOF before reading marker from GHCi.\nWas looking for marker '#~TRI-FINISH-1~#', but no such marker was found.\nAccumulated output from GHCi so far:\nfirst\nsecond\nthird"
-                    other -> assertFailure ("expected UnexpectedExit, got: " <> show other)
-    , testCase "throws UnexpectedExit with no lines when EOF is reached immediately" do
-        (r, w) <- Process.createPipe
-        (outcome, _msgs) <-
-            runEff
-                . runWriter @[Message]
-                . runLogWriter
-                . runFile
-                $ do
-                    File.hClose w
-                    trySync (drainUntil r (finishMarker 1) (\_ -> pure ()))
-        case outcome of
-            Right ls -> assertFailure ("expected UnexpectedExit, got: " <> show ls)
-            Left ex -> case fromException ex of
-                Just (UnexpectedExit (MkUnexpectedExit m ls)) -> do
-                    m @?= finishMarker 1
-                    let expected =
-                            "Reached EOF before reading marker from GHCi.\nWas looking for marker '#~TRI-FINISH-1~#', but no such marker was found.\nGHCi returned no output before we reached what we believe is EOF.\nGot the following exception when attempting to read from GHCi:\n<file descriptor:"
-                    (expected `T.isPrefixOf` ls)
-                        @? ( "error message does not start with expected prefix\nExpected:\n"
-                                <> show expected
-                                <> "\nActual:\n"
-                                <> show ls
-                           )
-                other -> assertFailure ("expected UnexpectedExit, got: " <> show other)
-    , testCase
-        "logs an ERROR mentioning the missing marker and the exception when EOF is reached with no output"
-        do
-            (r, w) <- Process.createPipe
-            (_outcome, msgs) <-
-                runEff
-                    . runWriter @[Message]
-                    . runLogWriter
-                    . runFile
-                    $ do
-                        File.hClose w
-                        trySync (drainUntil r (finishMarker 3) (\_ -> pure ()))
-            case filter (\m -> m.severity == ERROR) msgs of
-                [] -> assertFailure "expected an ERROR log message"
-                (logMsg : _) -> do
-                    (finishMarker 3 `T.isInfixOf` logMsg.text) @?= True
-                    ("GHCi returned no output" `T.isInfixOf` logMsg.text) @?= True
-    , testCase "logs an ERROR including the accumulated output when EOF is reached mid-output" do
-        (r, w) <- Process.createPipe
-        (_outcome, msgs) <-
-            runEff
-                . runWriter @[Message]
-                . runLogWriter
-                . runFile
-                $ do
-                    for_ ["oops-line-1", "oops-line-2"] (File.hPutTextLn w)
-                    File.hClose w
-                    trySync (drainUntil r (finishMarker 4) (\_ -> pure ()))
-        case filter (\m -> m.severity == ERROR) msgs of
-            [] -> assertFailure "expected an ERROR log message"
-            (logMsg : _) -> do
-                (finishMarker 4 `T.isInfixOf` logMsg.text) @?= True
-                ("oops-line-1" `T.isInfixOf` logMsg.text) @?= True
-                ("oops-line-2" `T.isInfixOf` logMsg.text) @?= True
-    ]
-
-
--- | Regression for the touch-during-reload desync. Interrupting a *Busy* GHCi
--- (a reload in flight) leaves a stale sync marker in the stdout/stderr buffers
--- ahead of the next command's real output. Because 'drainUntil' used to stop on
--- ANY marker-prefix line, the next 'execGhci' matched that stale marker and
--- returned *before its command ran* — surfacing as @All good. (0 modules)@ (or,
--- on the other timing, a hang). 'execGhci' must skip markers that aren't its
--- own and stop only on the marker it is waiting for.
-testExecGhciStaleMarker :: [TestTree]
-testExecGhciStaleMarker =
-    [ testCase "skips a stale leftover marker and returns the command's real output" do
-        (stdinR, stdinW) <- Process.createPipe
-        (stdoutR, stdoutW) <- Process.createPipe
-        (stderrR, stderrW) <- Process.createPipe
-        -- A dummy process handle to fill the record; 'execGhci' never uses it.
-        p <-
-            startProcess
-                $ setStdin createPipe
-                $ setStdout createPipe
-                $ setStderr createPipe
-                $ shell "true"
-        _ <- waitExitCode p
-        stateVar <- newTVarIO (Idle 9)
-        let gp =
-                GhciProcess
-                    { stdin = stdinW
-                    , stdout = stdoutR
-                    , stderr = stderrR
-                    , handle = RunningProcess p
-                    , stateVar
-                    }
-            -- Mirrors 'markerFor': "#~TRI-FINISH-<n>~#".
-            marker n = "#~TRI-FINISH-" <> show (n :: Int) <> "~#" :: Text
-        result <-
-            runEff
-                . runConcurrent
-                . runTimeout
-                . runDelay
-                . runFile
-                . runLogNoOp
-                . runConc
-                $ do
-                    -- A stale 'marker 5' (left by a prior interrupted reload)
-                    -- precedes the fresh command's real output + its 'marker 9'
-                    -- (state is 'Idle 9', so 'execGhci' waits for marker 9).
-                    for_ [marker 5, "out-line", marker 9] (File.hPutTextLn stdoutW)
-                    for_ [marker 5, "err-line", marker 9] (File.hPutTextLn stderrW)
-                    File.hClose stdoutW
-                    File.hClose stderrW
-                    -- Keep the stdin read-end alive across the write inside
-                    -- 'execGhci' (otherwise it is GC-finalised → broken pipe).
-                    r <- execGhci gp "reload" (\_ -> pure ())
-                    File.hClose stdinR
-                    pure r
-        _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ())
-        result @?= ["out-line", "err-line"]
-    ]
-
-
--- | Root-cause regression for the "stuck Building…" stall. A SIGINT-interrupted
--- ':reload' empties GHCi's interactive scope — it drops the implicit
--- @import Prelude@ (verified against ghci 9.10). A sync marker built from bare
--- Prelude names then fails to run instead of printing: @putStrLn@ is no longer
--- in scope, and neither is the @>>@ operator. The marker never appears, so
--- 'drainUntil' blocks until the watchdog fires. The marker must therefore use
--- only fully-qualified 'System.IO.hPutStrLn' statements (which survive the
--- emptied scope), one per stream, with no bare names or operators.
---
--- We assert on exactly what 'execGhci' writes to GHCi's stdin.
-testSyncMarkerScopeIndependent :: [TestTree]
-testSyncMarkerScopeIndependent =
-    [ testCase "writes the finish marker using only fully-qualified names (no bare putStrLn / >>)" do
-        (stdinR, stdinW) <- Process.createPipe
-        (stdoutR, stdoutW) <- Process.createPipe
-        (stderrR, stderrW) <- Process.createPipe
-        p <-
-            startProcess
-                $ setStdin createPipe
-                $ setStdout createPipe
-                $ setStderr createPipe
-                $ shell "true"
-        _ <- waitExitCode p
-        stateVar <- newTVarIO (Idle 9)
-        let gp =
-                GhciProcess
-                    { stdin = stdinW
-                    , stdout = stdoutR
-                    , stderr = stderrR
-                    , handle = RunningProcess p
-                    , stateVar
-                    }
-            marker = "#~TRI-FINISH-9~#" :: Text
-        written <-
-            runEff
-                . runConcurrent
-                . runTimeout
-                . runDelay
-                . runFile
-                . runLogNoOp
-                . runConc
-                $ do
-                    -- Pre-seed the marker on both streams so the drain returns
-                    -- immediately; we only care about what was written to stdin.
-                    File.hPutTextLn stdoutW marker
-                    File.hPutTextLn stderrW marker
-                    File.hClose stdoutW
-                    File.hClose stderrW
-                    _ <- execGhci gp ":reload" (\_ -> pure ())
-                    File.hClose stdinW
-                    let readAll acc =
-                            trySync (File.hGetLine stdinR) >>= \case
-                                Left (_ :: SomeException) -> pure (reverse acc)
-                                Right l -> readAll (l : acc)
-                    readAll []
-        _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ())
-        let blob = T.intercalate "\n" written
-        (" >> " `T.isInfixOf` blob) @?= False
-        ("System.IO.hPutStrLn System.IO.stdout" `T.isInfixOf` blob) @?= True
-        ("System.IO.hPutStrLn System.IO.stderr" `T.isInfixOf` blob) @?= True
-    ]
-
-
--- | Regression: when the build command exits before printing a GHCi banner,
--- 'waitForBannerOrFail' must surface the *complete* stderr output in the
--- 'StartupFailed' error. The original implementation snapshotted the captured
--- lines as soon as the process exited (it waited on 'waitExitCode'), racing
--- the concurrent stderr drain — so a burst of error lines still buffered in
--- the pipe was truncated, and the real cabal/build failure was lost.
-testWaitForBannerOrFail :: [TestTree]
-testWaitForBannerOrFail =
-    [ testCase "captures the full stderr output when the command exits before the banner" do
-        let lineCount = 200 :: Int
-            lastLine = "err line " <> show lineCount
-        -- The banner and error streams are pipes we drive ourselves, so the
-        -- timing is deterministic rather than a race against the OS pipe buffer.
-        (bannerOut, bannerOutW) <- Process.createPipe
-        (errR, errW) <- Process.createPipe
-        result <-
-            runEff
-                . runConcurrent
-                . runTimeout
-                . runDelay
-                . runFile
-                . runConc
-                $ do
-                    -- No banner will ever arrive: close the write end so the
-                    -- wait sees EOF at once and takes the "command exited"
-                    -- failure branch.
-                    File.hClose bannerOutW
-                    -- Producer: pause long enough that a snapshot-at-exit reads
-                    -- an empty buffer, THEN stream the whole error log and
-                    -- close so the drain sees EOF. A correct implementation
-                    -- awaits that drain before reading the captured lines.
-                    _ <- Conc.fork do
-                        Delay.wait (30 :: Millisecond)
-                        for_ [1 .. lineCount] \i ->
-                            File.hPutTextLn errW ("err line " <> show i :: Text)
-                        File.hClose errW
-                    trySync (waitForBannerOrFail (5 :: Second) bannerOut errR)
-        case result of
-            Right () -> assertFailure "expected waitForBannerOrFail to throw a startup error"
-            Left ex -> case fromException ex of
-                Just (StartupFailed msg) ->
-                    (lastLine `T.isInfixOf` msg) @?= True
-                other ->
-                    assertFailure ("expected StartupFailed, got: " <> show other)
-    ]
-
-
--- | Regression for orphaned/zombie build subprocesses on restart and shutdown.
---
--- The original leak was on the /graceful/ path: GHCi exits cleanly on @:quit@,
--- so its leader is reaped, yet the build subprocesses sharing its group linger.
--- 'withProcessGroup' must still sweep the whole group even after the leader has
--- gone. We simulate it with a leader that forks a long-lived child sharing its
--- group and exits on stdin input (mirroring @:quit@); after 'withProcessGroup'
--- returns, the child must be gone.
-testWithProcessGroupCleanup :: [TestTree]
-testWithProcessGroupCleanup =
-    [ testCase "terminates the whole group on exit, even after the leader has exited" do
-        childPidRef <- newIORef (Nothing :: Maybe Int)
-        let scenario =
-                runEff
-                    . runConcurrent
-                    . runTimeout
-                    . runDelay
-                    . runFile
-                    . runConc
-                    . runProcessIO
-                    $ withProcessGroup procConfig \p -> do
-                        -- First stdout line is the long-lived child's pid.
-                        line <- File.hGetLine (AProc.getStdout p)
-                        liftIO $ writeIORef childPidRef (parsePid (T.unpack line))
-                        -- Let the leader exit and reap it, so the cleanup runs
-                        -- with the leader already gone — the path the bug needed.
-                        File.hPutTextLn (AProc.getStdin p) ""
-                        File.hFlush (AProc.getStdin p)
-                        void $ AProc.waitExitCode p
-        -- Hard wall-clock bound: a regression must surface as a failed
-        -- assertion, never as a hang that stalls the whole suite.
-        outcome <- System.Timeout.timeout (8_000_000) scenario
-        case outcome of
-            Nothing -> assertFailure "test timed out (process did not settle)"
-            Just () ->
-                readIORef childPidRef >>= \case
-                    Nothing -> assertFailure "could not capture the child pid"
-                    Just childPid -> do
-                        died <- waitForProcessDeath childPid
-                        -- Never leak the child if the assertion fails.
-                        ignoring (signalProcess sigKILL (fromIntegral childPid))
-                        died @?= True
-    ]
-  where
-    procConfig =
-        setStdin createPipe
-            $ setStdout createPipe
-            $ setStderr createPipe
-            $ shell "sleep 30 & echo \"$!\"; read _quit"
-
-
--- | 'terminateProcessGroup' must kill the whole group when called mid-flight
--- (the leader still alive) — the explicit early-termination path the test
--- runner uses to abort a one-shot @cabal repl test:…@ from another thread.
-testTerminateProcessGroup :: [TestTree]
-testTerminateProcessGroup =
-    [ testCase "kills the whole group, not just the leader, mid-flight" do
-        outcome <- System.Timeout.timeout (8_000_000) do
-            p <-
-                startProcess
-                    $ setStdin createPipe
-                    $ setStdout createPipe
-                    $ setStderr createPipe
-                    $ setCreateGroup True
-                    $ shell "sleep 30 & echo \"$!\"; read _quit"
-            childLine <- hGetLine (getStdout p)
-            case parsePid childLine of
-                Nothing -> do
-                    ignoring (stopProcess p)
-                    assertFailure ("could not parse child pid from: " <> show childLine)
-                Just childPid -> do
-                    runEff . runProcessIO $ terminateProcessGroup (RunningProcess p)
-                    died <- waitForProcessDeath childPid
-                    -- Never leak the child if the assertion fails.
-                    ignoring (signalProcess sigKILL (fromIntegral childPid))
-                    pure died
-        outcome @?= Just True
-    ]
-
-
--- | Parse a pid printed on its own line (tolerating surrounding whitespace).
-parsePid :: String -> Maybe Int
-parsePid = readMaybe . takeWhile isDigit . dropWhile (not . isDigit)
-
-
--- | Swallow any exception from a best-effort cleanup action.
-ignoring :: IO () -> IO ()
-ignoring act = act `catch` \(_ :: SomeException) -> pure ()
-
-
--- | Poll for up to ~3s for the given pid to disappear from the process table.
--- @signalProcess nullSignal@ is a liveness probe: it throws once the process is
--- gone (and reaped by init after being orphaned).
-waitForProcessDeath :: Int -> IO Bool
-waitForProcessDeath pid = go (60 :: Int)
-  where
-    go 0 = not <$> alive
-    go n = do
-        a <- alive
-        if not a then pure True else threadDelay 50_000 >> go (n - 1)
-    alive =
-        (signalProcess nullSignal (fromIntegral pid) >> pure True)
-            `catch` \(_ :: IOException) -> pure False
-
-
-testDecideInterrupt :: [TestTree]
-testDecideInterrupt =
-    -- Regression: an idle GHCi must not be SIGINT'd, since the matching
-    -- sync-marker write would leave a stale marker line in stdout/stderr
-    -- that the next 'execGhci' drain would match instead of the fresh one,
-    -- desyncing the protocol and reporting "0 modules" or hanging.
-    [ testCase "is a no-op when the session is Idle" do
-        decideInterrupt (Idle 7) @?= (Idle 7, NoOpIdle)
-    , testCase "preserves the counter for any Idle state" do
-        decideInterrupt (Idle 0) @?= (Idle 0, NoOpIdle)
-        decideInterrupt (Idle 42) @?= (Idle 42, NoOpIdle)
-    , testCase "advances to Idle (n+1) and emits SendInterruptFor n when Busy" do
-        decideInterrupt (Busy 7) @?= (Idle 8, SendInterruptFor 7)
-    , testCase "advances correctly from Busy 0" do
-        decideInterrupt (Busy 0) @?= (Idle 1, SendInterruptFor 0)
-    ]
-
-
--- | Pins down the 'Conc.scoped' fix in 'execGhci': when the drain forks
--- raise 'UnexpectedExit' (because the underlying process exited and EOF'd
--- the pipes), the exception must be CONTAINED inside 'execGhci' and
--- surfaced via the caller's 'trySync' — not propagated to the ambient
--- 'Conc.scoped' that called 'execGhci'.
---
--- Without the inner 'Conc.scoped' in 'execGhci', Ki propagates an
--- exception from a forked thread to its owning scope. If 'execGhci' forks
--- its drains directly into the ambient scope (the original bug), the
--- ambient scope is torn down — siblings die, the whole builder cycle
--- unwinds, and the daemon ends up in the "Restarting builder..." state
--- the user observed.
-testExecGhciScope :: [TestTree]
-testExecGhciScope =
-    -- Spawn a real subprocess that exits immediately ('true'). Its
-    -- stdout/stderr pipes EOF as soon as the child exits, which makes
-    -- 'drainUntil' inside 'execGhci' throw 'UnexpectedExit' — exactly the
-    -- mid-command termination path the fix exists to handle.
-    [ testCase "contains drain exceptions inside its own scope so siblings survive" do
-        p <-
-            startProcess
-                $ setStdin createPipe
-                $ setStdout createPipe
-                $ setStderr createPipe
-                $ shell "true"
-        -- Wait for the child to actually exit so the pipes are EOF before
-        -- 'execGhci' starts draining (otherwise the drain blocks).
-        _ <- waitExitCode p
-        stateVar <- newTVarIO (Idle 0)
-        let gp =
-                GhciProcess
-                    { stdin = getStdin p
-                    , stdout = getStdout p
-                    , stderr = getStderr p
-                    , handle = RunningProcess p
-                    , stateVar
-                    }
-        siblingDoneRef <- newIORef False
-        result <-
-            runEff
-                . runConcurrent
-                . runTimeout
-                . runDelay
-                . runFile
-                . runLogNoOp
-                . runConc
-                $ Conc.scoped do
-                    -- A sibling fork in the SAME ambient scope. If the bug
-                    -- regresses, the drain exception will tear this scope
-                    -- down and the sibling will be cancelled before it can
-                    -- flip the ref.
-                    sibling <- Conc.fork do
-                        Delay.wait (50 :: Millisecond)
-                        liftIO (writeIORef siblingDoneRef True)
-                    -- Drive 'execGhci' on a dead process; the drains should
-                    -- raise 'UnexpectedExit', which 'trySync' must catch
-                    -- here rather than letting Ki tear down the scope.
-                    execResult <- trySync (execGhci gp "cmd" (\_ -> pure ()))
-                    -- Wait for the sibling to run.
-                    Conc.await sibling
-                    pure execResult
-        -- stopProcess flushes the buffered command+marker to a pipe whose
-        -- read end is already closed, which raises ResourceVanished. The
-        -- subprocess has long since exited; just swallow the cleanup error.
-        _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ())
-        siblingDone <- readIORef siblingDoneRef
-        siblingDone @?= True
-        case result of
-            Right _ -> assertFailure "expected execGhci to raise UnexpectedExit"
-            Left _ -> pure ()
-    ]
diff --git a/test/Unit/Tricorder/Daemon/GhciSessionSpec.hs b/test/Unit/Tricorder/Daemon/GhciSessionSpec.hs
deleted file mode 100644
--- a/test/Unit/Tricorder/Daemon/GhciSessionSpec.hs
+++ /dev/null
@@ -1,154 +0,0 @@
-module Unit.Tricorder.Daemon.GhciSessionSpec (test_GhciSession) where
-
-import Atelier.Effects.Publishing.Pub (Pub)
-import Control.Exception (ErrorCall (..))
-import Effectful (IOE, runEff)
-import Effectful.Exception (try)
-import Test.Tasty (TestTree, testGroup)
-import Test.Tasty.HUnit (assertBool, testCase, (@?=))
-
-import Atelier.Effects.Publishing.Pub qualified as Pub
-import Data.Map.Strict qualified as Map
-import Data.Set qualified as Set
-
-import Tricorder.Build (BuildProgress, Diagnostic (..), Severity (..))
-import Tricorder.Daemon.GhciSession
-    ( Controls (..)
-    , GhciSession
-    , LoadResult (..)
-    , runGhciSessionScripted
-    , withGhci
-    )
-import Tricorder.Runtime (ProjectRoot (..))
-import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))
-import Tricorder.Session.Stage (Stage (..))
-
-
-test_GhciSession :: TestTree
-test_GhciSession =
-    testGroup
-        "GhciSession"
-        [ testGroup "runGhciSessionScripted" testScripted
-        ]
-
-
---------------------------------------------------------------------------------
--- Scripted interpreter tests
---------------------------------------------------------------------------------
-
-testScripted :: [TestTree]
-testScripted =
-    [ testGroup
-        "withGhci"
-        [ testGroup
-            "initial load"
-            [ testCase "returns scripted messages" do
-                LoadResult {diagnostics = msgs} <-
-                    runScripted [simpleResult [errMsg]]
-                        $ withGhci cmd (ProjectRoot "/") \initial _ -> pure initial
-                msgs @?= [errMsg]
-            , testCase "returns empty list when scripted result has no messages" do
-                LoadResult {diagnostics = msgs} <-
-                    runScripted [simpleResult []]
-                        $ withGhci cmd (ProjectRoot "/") \initial _ -> pure initial
-                msgs @?= []
-            , testCase "throws when scripted result is Left" do
-                result <-
-                    runScripted [Left (toException boom)]
-                        $ try @ErrorCall
-                        $ withGhci cmd (ProjectRoot "/") \initial _ -> pure initial
-                result @?= Left boom
-            ]
-        , testGroup
-            "reloading"
-            [ testCase "returns scripted messages" do
-                LoadResult {diagnostics = msgs} <-
-                    runScripted [simpleResult [warnMsg], simpleResult [errMsg]]
-                        $ withGhci cmd (ProjectRoot "/") \_ controls -> controls.reload
-                msgs @?= [errMsg]
-            , testCase "throws when scripted result is Left" do
-                result <-
-                    runScripted [Left (toException boom)]
-                        $ try @ErrorCall
-                        $ withGhci cmd (ProjectRoot "/") \_ controls -> controls.reload
-                result @?= Left boom
-            ]
-        ]
-    , testGroup
-        "sequencing"
-        [ testCase "consumes results in order across mixed operations" do
-            (a, b) <- runScripted [simpleResult [errMsg], simpleResult [warnMsg]] do
-                withGhci cmd (ProjectRoot "/") \LoadResult {diagnostics = a} controls -> do
-                    LoadResult {diagnostics = b} <- controls.reload
-                    pure (a, b)
-            a @?= [errMsg]
-            b @?= [warnMsg]
-        , testCase "recover scenario: error then success" do
-            result <- runScripted [Left (toException boom), simpleResult []] do
-                r1 <- try @ErrorCall $ withGhci cmd (ProjectRoot "/") \i _ -> pure i
-                LoadResult {diagnostics = r2} <- withGhci cmd (ProjectRoot "/") \i _ -> pure i
-                pure (r1, r2)
-            assertBool "expected Left" $ isLeft (fst result)
-            snd result @?= []
-        ]
-    ]
-
-
---------------------------------------------------------------------------------
--- Helpers
---------------------------------------------------------------------------------
-
-cmd :: RenderedCommand 'Build
-cmd = RenderedCommand "cabal repl lib:foo"
-
-
-boom :: ErrorCall
-boom = ErrorCall "simulated GHCi crash"
-
-
-errMsg :: Diagnostic
-errMsg =
-    Diagnostic
-        { severity = SError
-        , file = "src/Foo.hs"
-        , line = 1
-        , col = 1
-        , endLine = 1
-        , endCol = 5
-        , title = "Variable not in scope: foo"
-        , text = "Variable not in scope: foo"
-        }
-
-
-warnMsg :: Diagnostic
-warnMsg =
-    Diagnostic
-        { severity = SWarning
-        , file = "src/Bar.hs"
-        , line = 10
-        , col = 3
-        , endLine = 10
-        , endCol = 8
-        , title = "Unused import"
-        , text = "Unused import"
-        }
-
-
--- | Convenience constructor: a scripted result with no compiled-file info.
-simpleResult :: [Diagnostic] -> Either SomeException LoadResult
-simpleResult msgs =
-    Right
-        LoadResult
-            { moduleCount = 0
-            , compiledFiles = Set.empty
-            , loadedModules = Map.empty
-            , targetNames = []
-            , diagnostics = msgs
-            }
-
-
-runScripted
-    :: [Either SomeException LoadResult]
-    -> Eff '[GhciSession, Pub BuildProgress, IOE] a
-    -> IO a
-runScripted results = runEff . Pub.runNoOp . runGhciSessionScripted results
diff --git a/test/Unit/Tricorder/Daemon/WatchSpec.hs b/test/Unit/Tricorder/Daemon/WatchSpec.hs
--- a/test/Unit/Tricorder/Daemon/WatchSpec.hs
+++ b/test/Unit/Tricorder/Daemon/WatchSpec.hs
@@ -7,7 +7,7 @@
 import Test.Tasty.HUnit (testCase, (@?=))
 import Text.Regex.TDFA.ReadRegex (parseRegex)
 
-import Atelier.Effects.Publishing.Pub qualified as Pub
+import Atelier.Effects.PubSub.Pub qualified as Pub
 
 import Tricorder.Build.Changes
     ( CabalChangeDetected (..)
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
@@ -2,7 +2,6 @@
 
 import Atelier.Effects.Env (runEnvConst)
 import Atelier.Effects.FileSystem (runFileSystemState)
-import Atelier.Effects.Input (runInputConst)
 import Atelier.Effects.Log (runLogNoOp)
 import Effectful (runPureEff)
 import Effectful.Reader.Static (runReader)
@@ -11,6 +10,7 @@
 import Test.Tasty.HUnit (testCase, (@?=))
 
 import Atelier.Effects.FileSystem.Glob qualified as Glob
+import Atelier.Effects.Input qualified as Input
 import Data.Map.Strict qualified as Map
 
 import Tricorder.Runtime (ProjectRoot (..))
@@ -183,7 +183,7 @@
         runPureEff
             . runReader pr
             . runLogNoOp
-            . runInputConst result
+            . Input.withConst result
 
 
 -- | Pins how a package path resolves to a @.cabal@ file: a directory path
diff --git a/test/Unit/Tricorder/Session/TestTargetSpec.hs b/test/Unit/Tricorder/Session/TestTargetSpec.hs
--- a/test/Unit/Tricorder/Session/TestTargetSpec.hs
+++ b/test/Unit/Tricorder/Session/TestTargetSpec.hs
@@ -23,23 +23,23 @@
     [ testCase "infers test: components from targets when testTargets is absent" do
         let cfg = def :: Config
         TestTarget.resolve cfg (mkTargets ["lib:mylib", "test:mylib-test"])
-            @?= TestTarget.parse ["test:mylib-test"]
+            @?= mapMaybe TestTarget.parse ["test:mylib-test"]
     , testCase "returns empty list when no test: components in targets" do
         let cfg = def :: Config
         TestTarget.resolve cfg (mkTargets ["lib:mylib", "exe:myapp"])
-            @?= TestTarget.parse []
+            @?= mapMaybe TestTarget.parse []
     , testCase "uses explicit testTargets list when set" do
         let cfg = def {testTargets = Just ["test:b-test"]} :: Config
         TestTarget.resolve cfg (mkTargets ["lib:a", "test:a-test", "test:b-test"])
-            @?= TestTarget.parse ["test:b-test"]
+            @?= mapMaybe TestTarget.parse ["test:b-test"]
     , testCase "returns empty list when testTargets is explicitly empty" do
         let cfg = def {testTargets = Just []} :: Config
         TestTarget.resolve cfg (mkTargets ["lib:a", "test:a-test"])
-            @?= TestTarget.parse []
+            @?= []
     , testCase "infers multiple test: components" do
         let cfg = def :: Config
         TestTarget.resolve cfg (mkTargets ["lib:a", "test:a-test", "test:b-test"])
-            @?= TestTarget.parse ["test:a-test", "test:b-test"]
+            @?= mapMaybe TestTarget.parse ["test:a-test", "test:b-test"]
     ]
   where
     mkTargets = fmap Target.parse
diff --git a/test/Unit/Tricorder/SessionSpec.hs b/test/Unit/Tricorder/SessionSpec.hs
--- a/test/Unit/Tricorder/SessionSpec.hs
+++ b/test/Unit/Tricorder/SessionSpec.hs
@@ -1,25 +1,30 @@
 module Unit.Tricorder.SessionSpec (test_Session) where
 
-import Atelier.Config (LoadedConfig (..))
 import Atelier.Effects.FileSystem (runFileSystemState)
-import Atelier.Effects.Input (runInputConst)
 import Atelier.Effects.Log (Message (..), Severity (..), runLogNoOp, runLogWriter)
-import Data.Aeson (Value (Null), object, (.=))
+import Data.Aeson (object, (.=))
 import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe)
 import Effectful (runPureEff)
 import Effectful.Reader.Static (runReader)
 import Effectful.State.Static.Shared (evalState)
 import Effectful.Writer.Static.Shared (execWriter)
 import Test.Tasty (TestTree, testGroup)
-import Test.Tasty.HUnit (testCase, (@?=))
+import Test.Tasty.HUnit (assertFailure, testCase, (@?), (@?=))
 
+import Atelier.Effects.Input qualified as Input
+import Data.Aeson qualified as Aeson
+import Data.Text qualified as T
+
+import Tricorder.Config.LoadedConfig (LoadedConfig (..))
 import Tricorder.Runtime (ProjectRoot (..))
-import Tricorder.Session (Session (..), loadSession)
 import Tricorder.Session.CabalFile (CabalFile (..))
 import Tricorder.Session.IdleTimeout (IdleTimeout (..))
 import Unit.Tricorder.Session.Helpers (libWithPreludeCabal, preludeOnlyLibCabal)
 
+import Tricorder.Config qualified
+import Tricorder.Session qualified as Session
 
+
 test_Session :: TestTree
 test_Session =
     testGroup
@@ -33,12 +38,21 @@
 testLoadSession :: TestTree
 testLoadSession =
     testGroup
-        "loadSession"
+        "load"
         [ testGroup
             "when every resolved target exposes a custom Prelude module"
-            [ testCase "emits a WARN" do
+            [ testCase "logs one message" do
                 let msgs = captureSessionLogs [preludeOnlyCF]
-                any (\m -> m.severity == WARN) msgs @?= True
+                length msgs @?= 1
+            , testCase "emits a WARN" do
+                let msgs = (\m -> (m.severity, m.text)) <$> captureSessionLogs [preludeOnlyCF]
+                case msgs of
+                    [(sev, msg)] -> do
+                        sev @?= WARN
+                        "Every resolved target exposes a custom Prelude module" `T.isPrefixOf` msg
+                            @? "does not start with expected string"
+                    xs ->
+                        assertFailure $ "Too many logged messages: " <> show (length xs)
             ]
         , testGroup
             "when not every resolved target exposes a custom Prelude module"
@@ -59,16 +73,19 @@
         CabalFile "/mixed.cabal"
             $ fromMaybe (error "libWithPreludeCabal failed to parse")
             $ parseGenericPackageDescriptionMaybe (libWithPreludeCabal "mixed")
-    captureSessionLogs cabalFiles =
-        runPureEff
+    captureSessionLogs = captureSessionLogs' $ Aeson.Object mempty
+    captureSessionLogs' cfg cabalFiles =
+        filter (\m -> m.severity /= DEBUG)
+            . runPureEff
             . execWriter @[Message]
             . runLogWriter
             . evalState @(Map FilePath ByteString) mempty
             . runFileSystemState
-            . runInputConst cabalFiles
+            . Input.withConst cabalFiles
             . runReader (ProjectRoot "/")
-            . runInputConst (LoadedConfig Null)
-            $ loadSession
+            . Input.withConst (LoadedConfig $ toStrict $ Aeson.encode cfg)
+            . Tricorder.Config.asInput
+            $ Session.load
 
 
 testMissingTargetPlaceholder :: TestTree
@@ -101,17 +118,18 @@
             ]
         ]
   where
-    sessionCfg session = LoadedConfig $ object ["session" .= object session]
+    sessionCfg session = object ["session" .= object session]
     captureLogsFor cfg =
         runPureEff
             . execWriter @[Message]
             . runLogWriter
             . evalState @(Map FilePath ByteString) mempty
             . runFileSystemState
-            . runInputConst ([] :: [CabalFile])
+            . Input.withConst ([] :: [CabalFile])
             . runReader (ProjectRoot "/")
-            . runInputConst cfg
-            $ loadSession
+            . Input.withConst (LoadedConfig $ toStrict $ Aeson.encode cfg)
+            . Tricorder.Config.asInput
+            $ Session.load
 
 
 testIdleTimeout :: TestTree
@@ -119,10 +137,10 @@
     testGroup
         "loadSession idleTimeout"
         [ testCase "defaults to 300 seconds when unset" do
-            (loadSessionWith (LoadedConfig Null)).idleTimeout @?= IdleTimeout 300
+            (.idleTimeout) <$> loadSessionWith (Aeson.Object mempty) @?= Right (IdleTimeout 300)
         , testCase "reads idle_timeout_seconds from the session config" do
-            let cfg = LoadedConfig $ object ["session" .= object ["idle_timeout_seconds" .= (5 :: Int)]]
-            (loadSessionWith cfg).idleTimeout @?= IdleTimeout 5
+            let cfg = object ["session" .= object ["idle_timeout_seconds" .= (5 :: Int)]]
+            (.idleTimeout) <$> (loadSessionWith cfg) @?= Right (IdleTimeout 5)
         ]
   where
     loadSessionWith cfg =
@@ -130,7 +148,8 @@
             . runLogNoOp
             . evalState @(Map FilePath ByteString) mempty
             . runFileSystemState
-            . runInputConst ([] :: [CabalFile])
+            . Input.withConst ([] :: [CabalFile])
             . runReader (ProjectRoot "/")
-            . runInputConst cfg
-            $ loadSession
+            . Input.withConst (LoadedConfig $ toStrict $ Aeson.encode cfg)
+            . Tricorder.Config.asInput
+            $ Session.load
diff --git a/test/Unit/Tricorder/SocketSpec.hs b/test/Unit/Tricorder/SocketSpec.hs
--- a/test/Unit/Tricorder/SocketSpec.hs
+++ b/test/Unit/Tricorder/SocketSpec.hs
@@ -1,24 +1,21 @@
 module Unit.Tricorder.SocketSpec (test_Socket) where
 
 import Atelier.Effects.File (File, runFile)
+import Atelier.Effects.FileSystem (FileSystem)
 import Effectful (IOE, runEff)
+import Effectful.Exception (trySync)
 import System.IO (hClose, hGetLine, openFile, writeFile)
 import Test.Tasty (TestTree, testGroup)
 import Test.Tasty.HUnit (testCase, (@?=))
 
+import Atelier.Effects.FileSystem qualified as FileSystem
+
 import Tricorder.Socket.Client (isDaemonReady)
-import Tricorder.Socket.UnixSocket
-    ( SocketScript (..)
-    , UnixSocket
-    , acceptHandle
-    , bindSocket
-    , removeSocketFile
-    , runUnixSocketIO
-    , runUnixSocketScripted
-    , socketFileExists
-    )
+import Tricorder.Socket.UnixSocket (SocketScript (..), UnixSocket)
 
+import Tricorder.Socket.UnixSocket qualified as UnixSocket
 
+
 test_Socket :: TestTree
 test_Socket =
     testGroup
@@ -35,29 +32,14 @@
 testScripted :: [TestTree]
 testScripted =
     [ testGroup
-        "socketFileExists"
-        [ testCase "returns True when scripted" do
-            result <- runScripted [NextFileCheck True] $ socketFileExists "/"
-            result @?= True
-        , testCase "returns False when scripted" do
-            result <- runScripted [NextFileCheck False] $ socketFileExists "/"
-            result @?= False
-        ]
-    , testGroup
-        "removeSocketFile"
-        [ testCase "is always a no-op" do
-            -- No NextFileCheck/NextAccept needed; just returns ()
-            runScripted [] $ removeSocketFile "/nonexistent/path"
-        ]
-    , testGroup
-        "acceptHandle"
+        "accept"
         [ testCase "returns the scripted handle, readable from a file" do
             let tmpPath = "/tmp/tricorder-socket-accept-test.txt"
             writeFile tmpPath "hello from test\n"
             h <- liftIO $ openFile tmpPath ReadMode
             line <- runScripted [NextAccept h] $ do
-                sock <- bindSocket "/"
-                h' <- acceptHandle sock
+                sock <- UnixSocket.bind "/"
+                h' <- UnixSocket.accept sock
                 liftIO $ hGetLine h'
             liftIO $ hClose h
             line @?= "hello from test"
@@ -79,10 +61,10 @@
     , testCase "returns True once a socket is bound and listening" do
         let path = "/tmp/tricorder-isdaemonready-bound.sock"
         result <- runIO' do
-            removeSocketFile path
-            _ <- bindSocket path
+            void $ trySync $ FileSystem.removeFile path
+            _ <- UnixSocket.bind path
             isDaemonReady path
-        runIO' $ removeSocketFile path
+        runIO' $ void $ trySync $ FileSystem.removeFile path
         result @?= True
     ]
 
@@ -93,9 +75,9 @@
 
 -- | Run scripted socket operations (no Delay needed).
 runScripted :: [SocketScript] -> Eff '[UnixSocket, File, IOE] a -> IO a
-runScripted script = runEff . runFile . runUnixSocketScripted script
+runScripted script = runEff . runFile . UnixSocket.runScripted script
 
 
 -- | Run socket operations against the real IO interpreter.
-runIO' :: Eff '[UnixSocket, File, IOE] a -> IO a
-runIO' = runEff . runFile . runUnixSocketIO
+runIO' :: Eff '[UnixSocket, File, FileSystem, IOE] a -> IO a
+runIO' = runEff . FileSystem.runFileSystemIO . runFile . UnixSocket.runIO
diff --git a/test/Unit/Tricorder/SourceLookup/ReexportSpec.hs b/test/Unit/Tricorder/SourceLookup/ReexportSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Unit/Tricorder/SourceLookup/ReexportSpec.hs
@@ -0,0 +1,181 @@
+module Unit.Tricorder.SourceLookup.ReexportSpec (test_Reexport, test_exportsByName, test_declaresMember) where
+
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (testCase, (@?=))
+
+import Data.Text qualified as T
+
+import Tricorder.SourceLookup.Reexport (declaresMember, exportsByName, reexportCandidates)
+
+
+test_Reexport :: TestTree
+test_Reexport =
+    testGroup
+        "Reexport"
+        [ testGroup
+            "reexportCandidates"
+            [ testCase "follows an import list naming the symbol" do
+                let source =
+                        src
+                            [ "module Data.Text"
+                            , "    ( Text"
+                            , "    , pack -- re-exported"
+                            , "    ) where"
+                            , ""
+                            , "import Data.Text.Array qualified as A"
+                            , "import Data.Text.Show (singleton,"
+                            , "    unpack)"
+                            , "import Data.Text.Internal (Text(..), empty, pack)"
+                            ]
+                reexportCandidates "pack" source @?= ["Data.Text.Internal"]
+            , testCase "ranks imports naming the symbol before unrestricted ones" do
+                let source =
+                        src
+                            [ "module M (foo) where"
+                            , "import A"
+                            , "import B (foo)"
+                            , "import C hiding (foo)"
+                            ]
+                reexportCandidates "foo" source @?= ["B", "A"]
+            , testCase "yields nothing without an export list" do
+                let source = src ["module M where", "import A (foo)"]
+                reexportCandidates "foo" source @?= []
+            , testCase "yields nothing when the export list does not mention the symbol" do
+                let source = src ["module M (bar) where", "import A"]
+                reexportCandidates "foo" source @?= []
+            , testCase "follows a module re-export" do
+                let source =
+                        src
+                            [ "module M (module X, bar) where"
+                            , "import qualified Q"
+                            , "import Data.Foo as X"
+                            , "import Data.Bar"
+                            ]
+                reexportCandidates "foo" source @?= ["Data.Foo"]
+            , testCase "matches a qualified export to its import alias" do
+                let source =
+                        src
+                            [ "module M (T.foo) where"
+                            , "import A"
+                            , "import qualified B as T"
+                            ]
+                reexportCandidates "foo" source @?= ["B"]
+            , testCase "follows a constructor through a wildcard export" do
+                let source =
+                        src
+                            [ "module M (Shape(..)) where"
+                            , "import Shapes (Shape(..))"
+                            , "import Other (thing)"
+                            ]
+                reexportCandidates "Circle" source @?= ["Shapes"]
+            , testCase "skips an import that hides the symbol but brings in its parent" do
+                let source =
+                        src
+                            [ "module Prelude (Foldable(foldr)) where"
+                            , "import GHC.Base hiding (foldr)"
+                            , "import Data.Foldable (Foldable(..))"
+                            ]
+                reexportCandidates "foldr" source @?= ["Data.Foldable"]
+            , testCase "yields nothing for a member of a locally declared parent" do
+                let source =
+                        src
+                            [ "module Data.Foldable (Foldable(..)) where"
+                            , "import GHC.Base"
+                            , ""
+                            , "class Foldable t where"
+                            , "    foldr :: (a -> b -> b) -> b -> t a -> b"
+                            ]
+                reexportCandidates "foldr" source @?= []
+            , testCase "matches operators" do
+                let source = src ["module M ((<+>)) where", "import Ops ((<+>))"]
+                reexportCandidates "<+>" source @?= ["Ops"]
+            , testCase "ignores commented-out exports and imports" do
+                let source =
+                        src
+                            [ "{-# LANGUAGE CPP #-}"
+                            , "-- | module Fake (foo) where"
+                            , "module M ( {- foo, -} bar ) where"
+                            , "-- import A (foo)"
+                            , "import B (bar)"
+                            ]
+                reexportCandidates "foo" source @?= []
+            ]
+        ]
+
+
+src :: [Text] -> Text
+src = T.unlines
+
+
+test_declaresMember :: TestTree
+test_declaresMember =
+    testGroup
+        "declaresMember"
+        [ testCase "holds for a method of a locally declared class" do
+            let source =
+                    src
+                        [ "module Data.Foldable (Foldable(..)) where"
+                        , ""
+                        , "class Foldable t where"
+                        , "    foldr :: (a -> b -> b) -> b -> t a -> b"
+                        ]
+            declaresMember "foldr" source @?= True
+        , testCase "does not hold for a symbol absent from the local parent" do
+            let source =
+                    src
+                        [ "module M (Shape(..), foldr) where"
+                        , "import Data.Foldable (foldr)"
+                        , ""
+                        , "data Shape = Circle | Square"
+                        ]
+            declaresMember "foldr" source @?= False
+        , testCase "does not hold for a symbol merely used in the parent's body" do
+            let source =
+                    src
+                        [ "module M (Functor(..), map) where"
+                        , "import GHC.Base (map)"
+                        , ""
+                        , "class Functor f where"
+                        , "    fmap :: (a -> b) -> f a -> f b"
+                        , "    fmap = map"
+                        ]
+            declaresMember "map" source @?= False
+        , testCase "holds for a constructor of a local data type" do
+            let source =
+                    src
+                        [ "module M (Shape(..)) where"
+                        , ""
+                        , "data Shape = Circle Double | Square Double"
+                        ]
+            declaresMember "Square" source @?= True
+        , testCase "does not hold when the parent is imported" do
+            let source =
+                    src
+                        [ "module Prelude (Foldable(foldr)) where"
+                        , "import Data.Foldable (Foldable(..))"
+                        ]
+            declaresMember "foldr" source @?= False
+        ]
+
+
+test_exportsByName :: TestTree
+test_exportsByName =
+    testGroup
+        "exportsByName"
+        [ testCase "holds for a symbol named directly" do
+            exportsByName "pack" (src ["module Data.Text (Text, pack) where"]) @?= True
+        , testCase "holds for a qualified export" do
+            exportsByName "pack" (src ["module M (T.pack) where", "import qualified Data.Text as T"]) @?= True
+        , testCase "holds for a symbol named in a sub-list" do
+            exportsByName "Just" (src ["module M (Maybe(Nothing, Just)) where"]) @?= True
+        , testCase "holds for a type whose sub-list is a wildcard" do
+            exportsByName "Maybe" (src ["module M (Maybe(..)) where"]) @?= True
+        , testCase "does not hold for a member covered only by a wildcard" do
+            exportsByName "Just" (src ["module M (Maybe(..)) where"]) @?= False
+        , testCase "does not hold for a module re-export" do
+            exportsByName "Maybe" (src ["module M (module GHC.Maybe) where", "import GHC.Maybe"]) @?= False
+        , testCase "does not hold without an export list" do
+            exportsByName "pack" (src ["module M where", "", "pack = undefined"]) @?= False
+        , testCase "ignores a commented-out export" do
+            exportsByName "pack" (src ["module M ( {- pack, -} unpack ) where"]) @?= False
+        ]
diff --git a/test/Unit/Tricorder/SourceLookupSpec.hs b/test/Unit/Tricorder/SourceLookupSpec.hs
--- a/test/Unit/Tricorder/SourceLookupSpec.hs
+++ b/test/Unit/Tricorder/SourceLookupSpec.hs
@@ -1,10 +1,10 @@
 module Unit.Tricorder.SourceLookupSpec (test_SourceLookup) where
 
-import Atelier.Effects.Cache (Cache, runCacheForever)
 import Atelier.Effects.Env (Env, runEnvConst)
 import Atelier.Effects.FileSystem (FileSystem (..))
-import Atelier.Effects.Input (Input, runInputConst)
+import Atelier.Effects.Input (Input)
 import Atelier.Effects.Log (Log, runLogNoOp)
+import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe)
 import Effectful (IOE, runEff)
 import Effectful.Concurrent (Concurrent, runConcurrent)
 import Effectful.Dispatch.Dynamic (interpret_)
@@ -14,23 +14,27 @@
 import Test.Tasty.HUnit (testCase, (@?=))
 import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery (..))
 
+import Atelier.Effects.Input qualified as Input
 import Codec.Archive.Tar qualified as Tar
 import Codec.Archive.Tar.Entry qualified as Tar
 import Codec.Compression.GZip qualified as GZip
 import Data.ByteString.Lazy qualified as BSL
-import Data.IORef qualified as IORef
 import Data.List qualified as List
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 
+import Tricorder.Session.CabalFile (CabalFile (..))
 import Tricorder.Session.Repl (Repl (..))
 import Tricorder.SourceLookup
     ( ModuleSourceResult (..)
+    , ProjectSourceDirs (..)
+    , ReexportLimits (..)
+    , defaultReexportLimits
     , lookupModuleSource
+    , projectSourceDirs
     )
 import Tricorder.SourceLookup.GhcPkg (GhcPkg, GhcPkgScript (..), runGhcPkgScripted)
 import Tricorder.SourceLookup.Hackage (Hackage (..), Result (..))
-import Tricorder.SourceLookup.PackageId (PackageId)
 import Tricorder.SourceLookup.PackageStore (PackageStore)
 
 import Tricorder.SourceLookup.PackageStore qualified as PackageStore
@@ -45,25 +49,28 @@
             [ testCase "reads the whole module from a cached tarball" do
                 result <-
                     runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch
-                        $ lookupModuleSource (wholeModule "Data.Aeson")
+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (wholeModule "Data.Aeson")
                 result @?= SourceFound (wholeModule "Data.Aeson") moduleSource
             , testCase "slices a symbol (with its doc block) from a cached tarball" do
                 result <-
                     runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch
-                        $ lookupModuleSource (symbol "Data.Aeson" "encode")
+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (symbol "Data.Aeson" "encode")
                 result
                     @?= SourceFound
                         (symbol "Data.Aeson" "encode")
                         "-- | Encode a value as JSON.\nencode :: Value -> ByteString\nencode = undefined"
             , testCase "returns FunctionNotFound for a symbol absent from the module" do
                 result <-
-                    runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch
-                        $ lookupModuleSource (symbol "Data.Aeson" "nope")
+                    runTest
+                        (NextFindModule (Just "aeson-2.2.5.0") : cycle [NextFindModule Nothing])
+                        withTarball
+                        noFetch
+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (symbol "Data.Aeson" "nope")
                 result @?= FunctionNotFound (symbol "Data.Aeson" "nope")
             , testCase "returns SourceNotFound when the module is in no package" do
                 result <-
                     runTest [NextFindModule Nothing] Map.empty noFetch
-                        $ lookupModuleSource (wholeModule "Data.Unknown")
+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (wholeModule "Data.Unknown")
                 result @?= SourceNotFound (wholeModule "Data.Unknown")
             , testCase "fetches from Hackage on a cache miss, then reads the now-fetched tarball" do
                 result <-
@@ -71,65 +78,133 @@
                         [NextFindModule (Just "aeson-2.2.5.0")]
                         Map.empty
                         (pure (Success (BSL.toStrict tarballBytes)))
-                        $ lookupModuleSource (wholeModule "Data.Aeson")
+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (wholeModule "Data.Aeson")
                 result @?= SourceFound (wholeModule "Data.Aeson") moduleSource
             , testCase "returns SourceUnavailable when the package is not found on Hackage" do
                 result <-
                     runTest [NextFindModule (Just "aeson-2.2.5.0")] Map.empty noFetch
-                        $ lookupModuleSource (wholeModule "Data.Aeson")
+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (wholeModule "Data.Aeson")
                 result @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"
-            , testCase "caches the result so a second lookup needs no further resolution" do
-                -- Only one NextFindModule is scripted; the second lookup must be served
-                -- entirely from cache (module→package and package→source).
-                (r1, r2) <-
-                    runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch $ do
-                        r1 <- lookupModuleSource (wholeModule "Data.Aeson")
-                        r2 <- lookupModuleSource (wholeModule "Data.Aeson")
-                        pure (r1, r2)
-                r1 @?= SourceFound (wholeModule "Data.Aeson") moduleSource
-                r2 @?= SourceFound (wholeModule "Data.Aeson") moduleSource
-            , testCase "caches an unavailable result and does not re-fetch on a repeat lookup" do
-                -- The tarball is absent and every fetch reports the package as not
-                -- found, so the first lookup is SourceUnavailable. A repeat lookup must
-                -- be served from cache — no second Hackage fetch on the (network)
-                -- request path.
-                fetchCount <- IORef.newIORef (0 :: Int)
-                let countingFetch = do
-                        liftIO (IORef.modifyIORef' fetchCount (+ 1))
+            , testCase "follows a re-export to the defining module" do
+                result <-
+                    runTest
+                        (NextFindModule (Just "reex-1.0") : cycle [NextFindModule (Just "reex-1.0")])
+                        withReexportTarball
                         noFetch
-                (r1, r2) <-
-                    runTest [NextFindModule (Just "aeson-2.2.5.0")] Map.empty countingFetch $ do
-                        r1 <- lookupModuleSource (wholeModule "Data.Aeson")
-                        r2 <- lookupModuleSource (wholeModule "Data.Aeson")
-                        pure (r1, r2)
-                r1 @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"
-                r2 @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"
-                fetches <- IORef.readIORef fetchCount
-                fetches @?= 1
-            , testCase "re-fetches after a failed fetch rather than caching the failure" do
-                -- A failed Hackage fetch (offline, DNS failure, 5xx) is transient, so
-                -- the resulting SourceUnavailable must NOT be cached: a repeat lookup
-                -- has to retry the fetch, or a brief network blip pins unavailability
-                -- for the whole cache window.
-                fetchCount <- IORef.newIORef (0 :: Int)
-                let failingFetch = do
-                        liftIO (IORef.modifyIORef' fetchCount (+ 1))
-                        pure (Failure "network unreachable")
-                (r1, r2) <-
-                    runTest [NextFindModule (Just "aeson-2.2.5.0")] Map.empty failingFetch $ do
-                        r1 <- lookupModuleSource (wholeModule "Data.Aeson")
-                        r2 <- lookupModuleSource (wholeModule "Data.Aeson")
-                        pure (r1, r2)
-                r1 @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"
-                r2 @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"
-                fetches <- IORef.readIORef fetchCount
-                fetches @?= 2
+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (symbol "Data.Reex" "pack")
+                result
+                    @?= SourceReexported
+                        (symbol "Data.Reex" "pack")
+                        "Data.Reex.Internal"
+                        "-- | Pack a string.\npack :: String -> Text\npack = undefined"
+            , testCase "follows a re-export into a module ghc-pkg cannot place" do
+                -- A hidden (other-modules) module is not found by ghc-pkg; it is
+                -- looked up in the re-exporting module's package instead.
+                result <-
+                    runTest
+                        (NextFindModule (Just "reex-1.0") : cycle [NextFindModule Nothing])
+                        withReexportTarball
+                        noFetch
+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (symbol "Data.Reex" "pack")
+                result
+                    @?= SourceReexported
+                        (symbol "Data.Reex" "pack")
+                        "Data.Reex.Internal"
+                        "-- | Pack a string.\npack :: String -> Text\npack = undefined"
+            , testCase "follows a chain of re-exports to the defining module" do
+                -- A re-exports foo from B, which re-exports it from C, which defines it.
+                result <-
+                    runTest
+                        (replicate 3 (NextFindModule (Just "chain-1.0")) <> cycle [NextFindModule Nothing])
+                        withChainTarball
+                        noFetch
+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (symbol "A" "foo")
+                result @?= SourceReexported (symbol "A" "foo") "C" "foo :: Int\nfoo = 42"
+            , testGroup
+                "re-export limits"
+                -- The A -> B -> C chain takes 2 hops and reads 2 modules (B and C).
+                [ testCase "finds the definition when the depth covers the chain" do
+                    result <- lookupChain ReexportLimits {maxDepth = 2, maxModules = 24}
+                    result @?= SourceReexported (symbol "A" "foo") "C" "foo :: Int\nfoo = 42"
+                , testCase "stops short of the definition when the depth is too small" do
+                    result <- lookupChain ReexportLimits {maxDepth = 1, maxModules = 24}
+                    result @?= FunctionNotFound (symbol "A" "foo")
+                , testCase "does not follow re-exports at depth 0" do
+                    result <- lookupChain ReexportLimits {maxDepth = 0, maxModules = 24}
+                    result @?= FunctionNotFound (symbol "A" "foo")
+                , testCase "finds the definition when the module budget covers the chain" do
+                    result <- lookupChain ReexportLimits {maxDepth = 5, maxModules = 2}
+                    result @?= SourceReexported (symbol "A" "foo") "C" "foo :: Int\nfoo = 42"
+                , testCase "stops short of the definition when the module budget runs out" do
+                    result <- lookupChain ReexportLimits {maxDepth = 5, maxModules = 1}
+                    result @?= FunctionNotFound (symbol "A" "foo")
+                ]
+            , testCase "returns FunctionNotFound when no re-export defines the symbol" do
+                result <-
+                    runTest
+                        (NextFindModule (Just "reex-1.0") : cycle [NextFindModule Nothing])
+                        withReexportTarball
+                        noFetch
+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (symbol "Data.Reex" "unpack")
+                result @?= FunctionNotFound (symbol "Data.Reex" "unpack")
             , testCase "finds a tarball in the legacy ~/.cabal cache location" do
                 result <-
                     runTest [NextFindModule (Just "aeson-2.2.5.0")] withLegacyTarball noFetch
-                        $ lookupModuleSource (wholeModule "Data.Aeson")
+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (wholeModule "Data.Aeson")
                 result @?= SourceFound (wholeModule "Data.Aeson") moduleSource
             ]
+        , testGroup
+            "project modules"
+            [ testCase "resolves every component's source dirs against the package dir" do
+                projectDirs @?= ProjectSourceDirs ["/p/src", "/p/test"]
+            , testCase "reads a module from the project's source dirs" do
+                result <-
+                    runTest [] projectFiles noFetch
+                        $ lookupModuleSource projectDirs defaultReexportLimits (wholeModule "Proj.Types")
+                result @?= SourceFound (wholeModule "Proj.Types") projectTypesSource
+            , testCase "reads a module from a non-library component's source dirs" do
+                result <-
+                    runTest [] projectFiles noFetch
+                        $ lookupModuleSource projectDirs defaultReexportLimits (wholeModule "Proj.Helpers")
+                result @?= SourceFound (wholeModule "Proj.Helpers") projectHelpersSource
+            , testCase "slices a symbol from a project module" do
+                result <-
+                    runTest [] projectFiles noFetch
+                        $ lookupModuleSource projectDirs defaultReexportLimits (symbol "Proj.Types" "greeting")
+                result
+                    @?= SourceFound
+                        (symbol "Proj.Types" "greeting")
+                        "-- | A greeting.\ngreeting :: Text\ngreeting = \"hi\""
+            , testCase "follows a re-export to another project module" do
+                result <-
+                    runTest [] projectFiles noFetch
+                        $ lookupModuleSource projectDirs defaultReexportLimits (symbol "Proj" "greeting")
+                result
+                    @?= SourceReexported
+                        (symbol "Proj" "greeting")
+                        "Proj.Types"
+                        "-- | A greeting.\ngreeting :: Text\ngreeting = \"hi\""
+            , testCase "follows a re-export from a project module into a package" do
+                result <-
+                    runTest
+                        [NextFindModule (Just "aeson-2.2.5.0")]
+                        (projectFiles <> withTarball)
+                        noFetch
+                        $ lookupModuleSource projectDirs defaultReexportLimits (symbol "Proj" "encode")
+                result
+                    @?= SourceReexported
+                        (symbol "Proj" "encode")
+                        "Data.Aeson"
+                        "-- | Encode a value as JSON.\nencode :: Value -> ByteString\nencode = undefined"
+            , testCase "falls back to installed packages for a module not in the project" do
+                result <-
+                    runTest
+                        [NextFindModule (Just "aeson-2.2.5.0")]
+                        (projectFiles <> withTarball)
+                        noFetch
+                        $ lookupModuleSource projectDirs defaultReexportLimits (wholeModule "Data.Aeson")
+                result @?= SourceFound (wholeModule "Data.Aeson") moduleSource
+            ]
         ]
 
 
@@ -176,11 +251,127 @@
 withLegacyTarball = Map.singleton legacyTarballPath tarballBytes
 
 
+-- | A project package with a library under @src@ and a test suite under @test@.
+projectCabalFile :: CabalFile
+projectCabalFile =
+    CabalFile "/p/proj.cabal"
+        . fromMaybe (error "projectCabalFile: unparseable")
+        . parseGenericPackageDescriptionMaybe
+        . encodeUtf8
+        $ T.unlines
+            [ "cabal-version: 3.0"
+            , "name: proj"
+            , "version: 0.1"
+            , "library"
+            , "  hs-source-dirs: src"
+            , "  exposed-modules: Proj, Proj.Types"
+            , "test-suite spec"
+            , "  type: exitcode-stdio-1.0"
+            , "  main-is: Main.hs"
+            , "  hs-source-dirs: test"
+            , "  other-modules: Proj.Helpers"
+            ]
+
+
+projectDirs :: ProjectSourceDirs
+projectDirs = projectSourceDirs [projectCabalFile]
+
+
+noProjectDirs :: ProjectSourceDirs
+noProjectDirs = ProjectSourceDirs []
+
+
+projectTypesSource :: Text
+projectTypesSource =
+    T.unlines
+        [ "module Proj.Types where"
+        , ""
+        , "-- | A greeting."
+        , "greeting :: Text"
+        , "greeting = \"hi\""
+        ]
+
+
+projectHelpersSource :: Text
+projectHelpersSource = T.unlines ["module Proj.Helpers where", "", "helper :: Int", "helper = 1"]
+
+
+-- | The source files of 'projectCabalFile': @Proj@ re-exports @greeting@ from
+-- @Proj.Types@ and @encode@ from the installed @Data.Aeson@.
+projectFiles :: Map FilePath LByteString
+projectFiles =
+    Map.fromList
+        [
+            ( "/p/src/Proj.hs"
+            , encodeUtf8 @Text
+                "module Proj (greeting, encode) where\n\nimport Proj.Types (greeting)\nimport Data.Aeson (encode)\n"
+            )
+        , ("/p/src/Proj/Types.hs", encodeUtf8 projectTypesSource)
+        , ("/p/test/Proj/Helpers.hs", encodeUtf8 projectHelpersSource)
+        ]
+
+
+-- | A package in which @A@ re-exports @foo@ from @B@, which re-exports it from
+-- @C@, which defines it.
+withChainTarball :: Map FilePath LByteString
+withChainTarball =
+    Map.singleton
+        ("/h/.cache/cabal/packages" </> "hackage.haskell.org/chain/1.0/chain-1.0.tar.gz")
+        $ mkTarballWith
+            [ ("chain-1.0/src/A.hs", T.unlines ["module A (foo) where", "", "import B (foo)"])
+            , ("chain-1.0/src/B.hs", T.unlines ["module B (foo) where", "", "import C"])
+            , ("chain-1.0/src/C.hs", T.unlines ["module C where", "", "foo :: Int", "foo = 42"])
+            ]
+
+
+-- | Look up @A#foo@ in the 'withChainTarball' package under the given limits.
+lookupChain :: ReexportLimits -> IO ModuleSourceResult
+lookupChain limits =
+    runTest
+        (replicate 3 (NextFindModule (Just "chain-1.0")) <> cycle [NextFindModule Nothing])
+        withChainTarball
+        noFetch
+        $ lookupModuleSource noProjectDirs limits (symbol "A" "foo")
+
+
+-- | A package whose @Data.Reex@ re-exports @pack@ (and claims to re-export
+-- @unpack@) from @Data.Reex.Internal@, which only defines @pack@.
+withReexportTarball :: Map FilePath LByteString
+withReexportTarball =
+    Map.singleton
+        ("/h/.cache/cabal/packages" </> "hackage.haskell.org/reex/1.0/reex-1.0.tar.gz")
+        $ mkTarballWith
+            [
+                ( "reex-1.0/src/Data/Reex.hs"
+                , T.unlines
+                    [ "module Data.Reex (Text, pack, unpack) where"
+                    , ""
+                    , "import Data.Reex.Internal (Text, pack, unpack)"
+                    ]
+                )
+            ,
+                ( "reex-1.0/src/Data/Reex/Internal.hs"
+                , T.unlines
+                    [ "module Data.Reex.Internal where"
+                    , ""
+                    , "-- | Pack a string."
+                    , "pack :: String -> Text"
+                    , "pack = undefined"
+                    ]
+                )
+            ]
+
+
 mkTarball :: FilePath -> Text -> LByteString
-mkTarball entryPath content =
-    GZip.compress (Tar.write [Tar.fileEntry tarPath (BSL.fromStrict (encodeUtf8 content))])
+mkTarball entryPath content = mkTarballWith [(entryPath, content)]
+
+
+mkTarballWith :: [(FilePath, Text)] -> LByteString
+mkTarballWith entries =
+    GZip.compress
+        (Tar.write [Tar.fileEntry (tarPath p) (BSL.fromStrict (encodeUtf8 c)) | (p, c) <- entries])
   where
-    tarPath = either (error . toText) id (Tar.toTarPath False entryPath)
+    tarPath p = either (error . toText) id (Tar.toTarPath False p)
 
 
 wholeModule :: ModuleName -> SourceQuery
@@ -204,49 +395,38 @@
 noFetch = pure NotFound
 
 
+type FetchEffects =
+    '[ PackageStore
+     , Env
+     , FileSystem
+     , State (Map FilePath LByteString)
+     , Input Repl
+     , Log
+     , Concurrent
+     , IOE
+     ]
+
+
+type TestEffects = GhcPkg : Hackage : FetchEffects
+
+
 runTest
     :: [GhcPkgScript]
     -> Map FilePath LByteString
-    -> Eff
-        '[ PackageStore
-         , Env
-         , FileSystem
-         , State (Map FilePath LByteString)
-         , Input Repl
-         , Log
-         , Concurrent
-         , IOE
-         ]
-        Result
-    -> Eff
-        '[ Cache ModuleName PackageId
-         , Cache (PackageId, SourceQuery) ModuleSourceResult
-         , GhcPkg
-         , Hackage
-         , PackageStore
-         , Env
-         , FileSystem
-         , State (Map FilePath LByteString)
-         , Input Repl
-         , Log
-         , Concurrent
-         , IOE
-         ]
-        a
+    -> Eff FetchEffects Result
+    -> Eff TestEffects a
     -> IO a
 runTest pkgScript initialFs onFetch action =
     runEff
         . runConcurrent
         . runLogNoOp
-        . runInputConst Cabal
+        . Input.withConst Cabal
         . evalState initialFs
         . runFileSystemFake
         . runEnvConst [("HOME", "/h")]
         . PackageStore.run
         . runHackageWith onFetch
         . runGhcPkgScripted pkgScript
-        . runCacheForever @(PackageId, SourceQuery) @ModuleSourceResult
-        . runCacheForever @ModuleName @PackageId
         $ action
 
 
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.5.0.0
+version:         0.6.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
@@ -39,9 +39,20 @@
       Tricorder.Build.EvalComment
       Tricorder.Build.Test
       Tricorder.CLI.App
+      Tricorder.CLI.App.CLIResponse
+      Tricorder.CLI.App.Output
+      Tricorder.CLI.App.Test
+      Tricorder.CLI.App.Test.Json
+      Tricorder.CLI.App.Test.Json.Responses
+      Tricorder.CLI.App.Test.SuiteView
+      Tricorder.CLI.App.Test.Text
       Tricorder.CLI.Arguments
       Tricorder.CLI.Arguments.Daemon
+      Tricorder.CLI.Arguments.IfDaemonIsStopped
       Tricorder.CLI.Arguments.OutputFormat
+      Tricorder.CLI.Arguments.Test
+      Tricorder.CLI.Arguments.TestFilter
+      Tricorder.CLI.Arguments.WaitMode
       Tricorder.CLI.Daemon
       Tricorder.CLI.Main
       Tricorder.CLI.Operations
@@ -56,14 +67,33 @@
       Tricorder.CLI.UI.State
       Tricorder.CLI.UI.View
       Tricorder.Config
+      Tricorder.Config.LoadedConfig
       Tricorder.Daemon.Builder
       Tricorder.Daemon.Core
       Tricorder.Daemon.DaemonInfo
       Tricorder.Daemon.Dispatch
       Tricorder.Daemon.EvalCommentRunner
-      Tricorder.Daemon.GhciSession
-      Tricorder.Daemon.GhciSession.GhciParser
-      Tricorder.Daemon.GhciSession.GhciProcess
+      Tricorder.Daemon.Ghci.Config
+      Tricorder.Daemon.Ghci.Controls
+      Tricorder.Daemon.Ghci.Diagnostic
+      Tricorder.Daemon.Ghci.InterruptDecision
+      Tricorder.Daemon.Ghci.LineParser
+      Tricorder.Daemon.Ghci.Load
+      Tricorder.Daemon.Ghci.LoadedModule
+      Tricorder.Daemon.Ghci.Loading
+      Tricorder.Daemon.Ghci.LoadOutcome
+      Tricorder.Daemon.Ghci.LoadResult
+      Tricorder.Daemon.Ghci.Message
+      Tricorder.Daemon.Ghci.Output
+      Tricorder.Daemon.Ghci.Path
+      Tricorder.Daemon.Ghci.Position
+      Tricorder.Daemon.Ghci.Process
+      Tricorder.Daemon.Ghci.Result
+      Tricorder.Daemon.Ghci.Session
+      Tricorder.Daemon.Ghci.Session.State
+      Tricorder.Daemon.Ghci.Severity
+      Tricorder.Daemon.Ghci.TextParser
+      Tricorder.Daemon.HookDispatch
       Tricorder.Daemon.Hpack
       Tricorder.Daemon.Hpack.Effect
       Tricorder.Daemon.IdleTimer
@@ -79,6 +109,8 @@
       Tricorder.Session.CommandTemplate
       Tricorder.Session.Config
       Tricorder.Session.GenerateWithHpack
+      Tricorder.Session.HaskellHooks
+      Tricorder.Session.Hook
       Tricorder.Session.Hooks
       Tricorder.Session.IdleTimeout
       Tricorder.Session.Repl
@@ -92,6 +124,9 @@
       Tricorder.Session.Stage.Test.Command
       Tricorder.Session.Stage.Test.Config
       Tricorder.Session.Stage.Test.Session
+      Tricorder.Session.State
+      Tricorder.Session.State.Invalid
+      Tricorder.Session.State.Result
       Tricorder.Session.Target
       Tricorder.Session.TestTarget
       Tricorder.Session.TestTimeout
@@ -107,6 +142,7 @@
       Tricorder.SourceLookup.Hackage
       Tricorder.SourceLookup.PackageId
       Tricorder.SourceLookup.PackageStore
+      Tricorder.SourceLookup.Reexport
       Tricorder.SourceLookup.Slice
       Tricorder.SourceLookup.Tarball
       Tricorder.TestOutput
@@ -141,7 +177,7 @@
     , Cabal-syntax >=3.12 && <3.17
     , Glob ==0.10.*
     , aeson >=2.2 && <2.4
-    , atelier-core ==0.7.*
+    , atelier-core ==0.8.*
     , atelier-prelude ==0.4.*
     , base >=4.18 && <4.23
     , brick ==3.0.*
@@ -170,7 +206,7 @@
     , text ==2.1.*
     , time >=1.12 && <1.17
     , time-units ==1.0.*
-    , tricorder-types ==0.4.*
+    , tricorder-types ==0.5.*
     , vty >=6.5 && <6.7
     , vty-crossplatform ==0.5.*
     , yaml ==0.11.*
@@ -263,13 +299,16 @@
   other-modules:
       Unit.Tricorder.Build.ByteSizeSpec
       Unit.Tricorder.Build.EvalCommentSpec
+      Unit.Tricorder.CLI.App.Test.SuiteViewSpec
       Unit.Tricorder.CLI.RenderSpec
       Unit.Tricorder.Daemon.BuilderSpec
       Unit.Tricorder.Daemon.BuildStateSpec
       Unit.Tricorder.Daemon.DispatchSpec
-      Unit.Tricorder.Daemon.GhciSession.GhciParserSpec
-      Unit.Tricorder.Daemon.GhciSession.GhciProcessSpec
-      Unit.Tricorder.Daemon.GhciSessionSpec
+      Unit.Tricorder.Daemon.Ghci.LoadResultSpec
+      Unit.Tricorder.Daemon.Ghci.OutputSpec
+      Unit.Tricorder.Daemon.Ghci.ProcessSpec
+      Unit.Tricorder.Daemon.Ghci.ResultSpec
+      Unit.Tricorder.Daemon.Ghci.SessionSpec
       Unit.Tricorder.Daemon.TestRunnerSpec
       Unit.Tricorder.Daemon.WatchSpec
       Unit.Tricorder.Session.CabalFileSpec
@@ -285,6 +324,7 @@
       Unit.Tricorder.SessionSpec
       Unit.Tricorder.SocketSpec
       Unit.Tricorder.SourceLookup.GhcPkgSpec
+      Unit.Tricorder.SourceLookup.ReexportSpec
       Unit.Tricorder.SourceLookup.SliceSpec
       Unit.Tricorder.SourceLookup.TarballSpec
       Unit.Tricorder.SourceLookupSpec
@@ -318,7 +358,7 @@
   build-depends:
       Cabal-syntax >=3.12 && <3.17
     , aeson >=2.2 && <2.4
-    , atelier-core ==0.7.*
+    , atelier-core ==0.8.*
     , atelier-prelude ==0.4.*
     , base >=4.18 && <4.23
     , bytestring >=0.11 && <0.13
@@ -340,7 +380,7 @@
     , time >=1.12 && <1.17
     , time-units ==1.0.*
     , tricorder-internal
-    , tricorder-types ==0.4.*
+    , tricorder-types ==0.5.*
     , typed-process ==0.2.*
     , unagi-chan ==0.4.*
     , unix ==2.8.*
