diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,10 +7,32 @@
 
 ## [Unreleased]
 
-## 0.2.1.0 - 2026-08-17
+## [0.2.2.0] - 2026-09-05
 
 ### Added
 
+- The daemon now shuts itself down after a period of inactivity, configurable
+  with `idle_timeout_seconds` (defaults to 300 seconds; `0` disables it). Any
+  client request resets the countdown, and a `tricorder ui` client's
+  long-lived watch connection counts as continuous activity for as long as
+  it's connected. See [Configuring Tricorder](/docs/configuring-tricorder.md)
+  for more information.
+- Use the `test_memory_limit` configuration value to specify the maximum memory
+  a test suite is allowed to consume. This is enforced through GHC's `-M` RTS
+  option.
+- Hooks for running arbitrary shell scripts at certain points in time in the
+  build process. See [Configuring Tricorder](/docs/configuring-tricorder.md)
+  for more information.
+
+### Fixed
+
+- Unable to correctly parse `packages` lists in `cabal.project` files when the
+  list was formatted as a single-line, comma-separated list.
+
+## [0.2.1.0] - 2026-08-17
+
+### Added
+
 - Tricorder now respects all forms of `cabal.project` files, like
   `cabal.project.local` for example. See
   [Cabal's documentation on project description files](https://cabal.readthedocs.io/en/stable/cabal-project-description-file.html). ([#73](https://github.com/tweag/tricorder/issues/73))
@@ -34,7 +56,7 @@
 - Incorrect repl command used for eval comments. This caused eval comments not
   to be able to use a module's top-level definitions in its expression.
 - Auto-resolved targets are not compatible with `stack ghci` (and its alias
-  `stack ghci`). Targets are now automatically resolved with package name,
+  `stack repl`). Targets are now automatically resolved with package name,
   `pkg:kind:name` for multi-package repos and just `name` for single-package
   repos, instead of just with the component name and kind `kind:name`. `stack
 ghci` is not compatible with the form `kind:name` (but `cabal repl` is), but
diff --git a/LICENSE b/LICENSE
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2025 Tweag
+Copyright (c) 2026 Tweag
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@
 - [`atelier-core`](https://github.com/tweag/tricorder/tree/main/atelier-core) — foundational effects and utilities
 - [`atelier-db`](https://github.com/tweag/tricorder/tree/main/atelier-db) — relational database effect (Hasql/Rel8)
 - [`atelier-testing`](https://github.com/tweag/tricorder/tree/main/atelier-testing) — database-backed test utilities
+- [`atelier-monitoring`](https://github.com/tweag/tricorder/tree/main/atelier-monitoring) - observability and monitoring effects and utilities
 
 ## License
 
diff --git a/src/Tricorder/Build.hs b/src/Tricorder/Build.hs
--- a/src/Tricorder/Build.hs
+++ b/src/Tricorder/Build.hs
@@ -7,13 +7,14 @@
     , PostBuild (..)
     , Diagnostic (..)
     , Severity (..)
-    ) where
+    )
+where
 
 import Atelier.Effects.Clock (UTCTime)
-import Atelier.Time (Millisecond)
 import Data.Aeson (FromJSON (..), ToJSON (..), withText)
 import GHC.Generics (Generically (..))
 
+import Tricorder.Build.Duration (Duration)
 import Tricorder.Daemon.DaemonInfo (DaemonInfo)
 import Tricorder.Session.TestTarget (TestTarget)
 
@@ -55,7 +56,7 @@
 
 data BuildResult = BuildResult
     { completedAt :: UTCTime
-    , duration :: Millisecond
+    , duration :: Duration
     , moduleCount :: Int
     , diagnostics :: [Diagnostic]
     }
diff --git a/src/Tricorder/Build/ByteSize.hs b/src/Tricorder/Build/ByteSize.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Build/ByteSize.hs
@@ -0,0 +1,97 @@
+module Tricorder.Build.ByteSize
+    ( ByteSize (..)
+    , Unit (..)
+    , asBytes
+    , toRTSSize
+    , fromText
+    )
+where
+
+import Text.Megaparsec (Parsec, eof, parseMaybe)
+import Text.Megaparsec.Char (space, string')
+import Text.Megaparsec.Char.Lexer (decimal)
+import Prelude hiding (toText)
+
+import Text.Show qualified as S
+
+
+data ByteSize = ByteSize {amount :: Integer, unit :: Unit}
+    deriving stock (Eq, Generic, Ord)
+
+
+instance Show ByteSize where
+    show ByteSize {amount, unit} = show amount <> show unit
+
+
+data Unit
+    = B
+    | KB
+    | KiB
+    | MB
+    | MiB
+    | GB
+    | GiB
+    | TB
+    | TiB
+    | PB
+    | PiB
+    deriving stock (Eq, Generic, Ord, Show)
+
+
+asBytes :: ByteSize -> Integer
+asBytes bs = bs.amount * multiplier bs.unit
+
+
+multiplier :: Unit -> Integer
+multiplier = \case
+    B -> 1
+    KB -> 1000
+    KiB -> 1024
+    MB -> 1000 `pow` 2
+    MiB -> 1024 `pow` 2
+    GB -> 1000 `pow` 3
+    GiB -> 1024 `pow` 3
+    TB -> 1000 `pow` 4
+    TiB -> 1024 `pow` 4
+    PB -> 1000 `pow` 5
+    PiB -> 1024 `pow` 5
+  where
+    pow :: Integer -> Integer -> Integer
+    pow = (^)
+
+
+toRTSSize :: ByteSize -> Text
+toRTSSize bs = show $ bs.amount * multiplier bs.unit
+
+
+fromText :: Text -> Maybe ByteSize
+fromText = parseMaybe byteSizeP
+
+
+byteSizeP :: Parser ByteSize
+byteSizeP = do
+    amount <- decimal
+    space
+    unit <- unitP
+    pure $ ByteSize {amount, unit}
+
+
+unitP :: Parser Unit
+unitP =
+    asum
+        [ string' "kb" *> pure KB
+        , string' "kib" *> pure KiB
+        , string' "mb" *> pure MB
+        , string' "mib" *> pure MiB
+        , string' "gb" *> pure GB
+        , string' "gib" *> pure GiB
+        , string' "tb" *> pure TB
+        , string' "tib" *> pure TiB
+        , string' "pb" *> pure PB
+        , string' "pib" *> pure PiB
+        , string' "b" *> pure B
+        , eof *> pure B
+        ]
+
+
+type Parser = Parsec Void Text
diff --git a/src/Tricorder/Build/Changes.hs b/src/Tricorder/Build/Changes.hs
--- a/src/Tricorder/Build/Changes.hs
+++ b/src/Tricorder/Build/Changes.hs
@@ -2,7 +2,8 @@
     ( ChangeKind (..)
     , CabalChangeDetected (..)
     , SourceChangeDetected (..)
-    ) where
+    )
+where
 
 import Atelier.Effects.FileWatcher (FileEvent)
 
@@ -15,5 +16,7 @@
 
 data CabalChangeDetected = CabalChangeDetected FilePath FileEvent
     deriving stock (Eq, Show)
+
+
 data SourceChangeDetected = SourceChangeDetected FilePath FileEvent
     deriving stock (Eq, Show)
diff --git a/src/Tricorder/Build/Duration.hs b/src/Tricorder/Build/Duration.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Build/Duration.hs
@@ -0,0 +1,9 @@
+module Tricorder.Build.Duration (Duration (..)) where
+
+import Atelier.Time (AsRawUnit (..), Millisecond)
+import Data.Aeson (FromJSON, ToJSON)
+
+
+newtype Duration = Duration {getDuration :: Millisecond}
+    deriving stock (Eq, Generic, Show)
+    deriving (FromJSON, ToJSON) via AsRawUnit Millisecond
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
@@ -12,7 +12,8 @@
     , blockCommentEvalP
     , State (..)
     , JsonOutput (..)
-    ) where
+    )
+where
 
 import Atelier.Types.QuietSnake (QuietSnake (..))
 import Data.Aeson (FromJSON (..), ToJSON (..), Value (..), withObject, (.:))
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
@@ -12,14 +12,15 @@
     , caseFailed
     , SuiteCompletion (..)
     , SuiteError (..)
-    ) where
+    )
+where
 
-import Atelier.Time (Millisecond)
 import Data.Aeson (FromJSON, ToJSON)
 import GHC.Generics (Generically (..))
 
 import Data.Map.Strict qualified as Map
 
+import Tricorder.Build.Duration (Duration)
 import Tricorder.Session.TestTarget (TestTarget)
 
 
@@ -105,7 +106,7 @@
     { passed :: Bool
     , output :: Text
     , testCases :: [Case]
-    , duration :: Maybe Millisecond
+    , duration :: Maybe Duration
     }
     deriving stock (Eq, Generic, Show)
     deriving (FromJSON, ToJSON) via (Generically 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
@@ -71,15 +71,17 @@
     ask >>= \case
         Start -> do
             running <- isDaemonRunning
-            if running then
-                Console.putStrLn "Daemon already running."
-            else do
-                startDaemon
-                ready <- waitForDaemon
-                if ready then
-                    Console.putStrLn "Daemon started."
-                else
-                    Console.putStrLn "Daemon started, but the socket is not responding yet."
+            if running
+                then
+                    Console.putStrLn "Daemon already running."
+                else do
+                    startDaemon
+                    ready <- waitForDaemon
+                    if ready
+                        then
+                            Console.putStrLn "Daemon started."
+                        else
+                            Console.putStrLn "Daemon started, but the socket is not responding yet."
         Stop force -> do
             running <- isDaemonRunning
             when running
@@ -92,28 +94,31 @@
                         Console.putTextLn result
         Status opts -> do
             running <- isDaemonRunning
-            if not running then
-                Console.putStrLn "Stopped."
-            else
-                showStatus opts
+            if not running
+                then
+                    Console.putStrLn "Stopped."
+                else
+                    showStatus opts
         Test opts -> do
             running <- isDaemonRunning
-            if not running then
-                Console.putStrLn "Stopped."
-            else
-                showTests opts
+            if not running
+                then
+                    Console.putStrLn "Stopped."
+                else
+                    showTests opts
         Log logMode -> do
             running <- isDaemonRunning
             logFile <-
-                if running then do
-                    SocketPath sp <- ask
-                    result <- queryStatus sp
-                    LogPath fallback <- ask
-                    pure $ case result of
-                        Right state -> state.daemonInfo.logFile
-                        Left _ -> fallback
-                else
-                    asks @LogPath (.getLogPath)
+                if running
+                    then do
+                        SocketPath sp <- ask
+                        result <- queryStatus sp
+                        LogPath fallback <- ask
+                        pure $ case result of
+                            Right state -> state.daemonInfo.logFile
+                            Left _ -> fallback
+                    else
+                        asks @LogPath (.getLogPath)
             case logMode of
                 ShowLog followMode -> showLog logFile followMode
                 ShowLogPath -> Console.putTextLn (toText logFile)
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
@@ -10,7 +10,8 @@
     , WaitMode (..)
     , parseArguments
     , runArguments
-    ) where
+    )
+where
 
 import Atelier.Effects.Arguments (Arguments, execParser)
 import Effectful.Reader.Static (Reader, runReader)
@@ -37,77 +38,23 @@
     , progDesc
     , short
     )
-
-import Data.Text qualified as T
-
-import Tricorder.Module (ModuleName (..))
-import Tricorder.Socket.Protocol (Force (..))
-import Tricorder.SourceLookup (SourceQuery (..))
+import Tricorder.CLI.Command
+    ( Command (..)
+    , EvalCommentsOptions (..)
+    , FollowMode (..)
+    , Force (..)
+    , LogMode (..)
+    , OutputFormat (..)
+    , StatusOptions (..)
+    , TestOptions (..)
+    , Verbosity (..)
+    , WaitMode (..)
+    )
+import Tricorder.SourceLookup.SourceQuery (SourceQuery, parseSourceQuery)
 
 import Tricorder.Version qualified as Version
 
 
-data WaitMode
-    = ShowCurrent
-    | WaitForBuild
-    deriving stock (Eq)
-
-
-data OutputFormat
-    = TextOutput
-    | JsonOutput
-    deriving stock (Eq)
-
-
-data Verbosity
-    = Concise
-    | Verbose
-    deriving stock (Eq)
-
-
-data FollowMode
-    = NoFollow
-    | Follow
-    deriving stock (Eq)
-
-
-data LogMode
-    = ShowLog FollowMode
-    | ShowLogPath
-
-
-data StatusOptions = StatusOptions
-    { wait :: WaitMode
-    , format :: OutputFormat
-    , verbosity :: Verbosity
-    , expand :: Maybe Int
-    }
-
-
-data TestOptions = TestOptions
-    { failedOnly :: Bool
-    , wait :: WaitMode
-    }
-
-
-data EvalCommentsOptions = EvalCommentsOptions
-    { wait :: WaitMode
-    , format :: OutputFormat
-    }
-
-
-data Command
-    = Start
-    | Stop Force
-    | Status StatusOptions
-    | Test TestOptions
-    | UI
-    | Log LogMode
-    | Source [SourceQuery]
-    | Restart Force
-    | EvalComments EvalCommentsOptions
-
-
 runArguments :: (Arguments :> es) => Eff (Reader Command : es) a -> Eff es a
 runArguments eff = do
     args <- parseArguments
@@ -135,13 +82,19 @@
     hsubparser
         ( command "start" (info (pure Start) (progDesc "Start the daemon (no-op if already running)"))
             <> command "stop" (info stopParser (progDesc "Stop the daemon"))
-            <> command "status" (info statusParser (progDesc "Print build diagnostics (--json for machine-readable output)"))
+            <> 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 "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"))
+            <> command
+                "source"
+                (info sourceParser (progDesc "Print the Haskell source of one or more installed modules"))
             <> command "restart" (info restartParser (progDesc "Restart the daemon"))
-            <> command "eval-comments" (info evalCommentsParser (progDesc "Show eval comments from the latest build"))
+            <> command
+                "eval-comments"
+                (info evalCommentsParser (progDesc "Show eval comments from the latest build"))
         )
 
 
@@ -206,7 +159,8 @@
 
 sourceParser :: Parser Command
 sourceParser =
-    Source <$> some (argument queryReader (metavar "MODULE[#FUNCTION]" <> help "Module or Module#function"))
+    Source
+        <$> some (argument queryReader (metavar "MODULE[#FUNCTION]" <> help "Module or Module#function"))
 
 
 stopParser :: Parser Command
@@ -253,11 +207,4 @@
 
 
 queryReader :: ReadM SourceQuery
-queryReader = eitherReader $ \s ->
-    let t = toText s
-        (m, rest) = T.break (== '#') t
-    in  Right
-            $ SourceQuery
-                { moduleName = ModuleName m
-                , function = if T.null rest then Nothing else Just (T.tail rest)
-                }
+queryReader = eitherReader $ Right . parseSourceQuery . toText
diff --git a/src/Tricorder/CLI/Daemon.hs b/src/Tricorder/CLI/Daemon.hs
--- a/src/Tricorder/CLI/Daemon.hs
+++ b/src/Tricorder/CLI/Daemon.hs
@@ -3,7 +3,8 @@
     , stopDaemon
     , restartDaemon
     , waitForDaemon
-    ) where
+    )
+where
 
 import Atelier.Effects.Delay (Delay)
 import Atelier.Effects.File (File)
@@ -70,11 +71,12 @@
     requestStop sockPath pidFile = do
         timeout1second (requestShutdown force sockPath) >>= \_ -> do
             didStop <- fmap isJust $ timeout timeoutDelay $ waitForStop pidFile
-            if didStop then
-                pure "Daemon stopped."
-            else do
-                tell ["Daemon did not stop as requested."]
-                emptyEff
+            if didStop
+                then
+                    pure "Daemon stopped."
+                else do
+                    tell ["Daemon did not stop as requested."]
+                    emptyEff
 
     sendKill pidFile = do
         timeout1second (Daemons.forceKillAndWait pidFile) >>= \case
@@ -88,11 +90,12 @@
     waitForStop :: forall es'. (Daemons :> es', Delay :> es') => PidFile -> Eff es' ()
     waitForStop pidFile = fix \rec -> do
         running <- Daemons.isRunning pidFile
-        if running then do
-            Delay.wait (500 :: Millisecond)
-            rec
-        else
-            pure ()
+        if running
+            then do
+                Delay.wait (500 :: Millisecond)
+                rec
+            else
+                pure ()
 
 
 -- | Restart the daemon: stop it (if running) and then start a fresh instance.
@@ -141,8 +144,9 @@
     go _ 0 = pure False
     go sockPath n = do
         ready <- isDaemonReady sockPath
-        if ready then
-            pure True
-        else do
-            Delay.wait (200 :: Millisecond)
-            go sockPath (n - 1)
+        if ready
+            then
+                pure True
+            else do
+                Delay.wait (200 :: Millisecond)
+                go sockPath (n - 1)
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
@@ -4,7 +4,8 @@
     , showStatus
     , showTests
     , showEvalComments
-    ) where
+    )
+where
 
 import Atelier.Effects.Clock (Clock, currentTimeZone)
 import Atelier.Effects.Console (Console)
@@ -16,6 +17,7 @@
 import Data.Time.Format (defaultTimeLocale, formatTime)
 import Data.Time.LocalTime (utcToLocalTime)
 import Effectful.Reader.Static (Reader, ask)
+import Tricorder.SourceLookup.SourceQuery (SourceQuery)
 
 import Atelier.Effects.Console qualified as Console
 import Data.ByteString.Lazy qualified as BSL
@@ -23,6 +25,7 @@
 import Data.Text qualified as T
 
 import Tricorder.Build (BuildState (..), Severity (..))
+import Tricorder.Build.Duration (Duration (..))
 import Tricorder.Build.Test (Suites (..))
 import Tricorder.CLI.Arguments
     ( EvalCommentsOptions (..)
@@ -42,7 +45,6 @@
 import Tricorder.Session.TestTarget (renderTestTarget)
 import Tricorder.Socket.Client (querySource, queryStatus, queryStatusWait)
 import Tricorder.Socket.UnixSocket (UnixSocket)
-import Tricorder.SourceLookup (SourceQuery)
 import Tricorder.TestOutput (stripGhciNoise)
 
 import Tricorder.Build qualified as Build
@@ -145,25 +147,28 @@
         let errs = length $ filter ((== SError) . (.severity)) r.diagnostics
             warns = length $ filter ((== SWarning) . (.severity)) r.diagnostics
             ts = toText $ "— " <> formatTime defaultTimeLocale "%H:%M:%S" (utcToLocalTime tz r.completedAt)
-            stats = toText $ "(" <> show r.moduleCount <> " modules, " <> formatDuration r.duration <> ")"
-        in  if null r.diagnostics then
-                "All good. " <> stats <> " " <> ts
-            else
-                show errs <> " error(s), " <> show warns <> " warning(s) " <> stats <> " " <> ts
+            stats =
+                toText $ "(" <> show r.moduleCount <> " modules, " <> formatDuration r.duration.getDuration <> ")"
+        in  if null r.diagnostics
+                then
+                    "All good. " <> stats <> " " <> ts
+                else
+                    show errs <> " error(s), " <> show warns <> " warning(s) " <> stats <> " " <> ts
 
 
 completionSummary :: Test.SuiteCompletion -> Text
-completionSummary c = statusText <> maybe "" (\d -> " (" <> formatDuration d <> ")") c.duration
+completionSummary c = statusText <> maybe "" (\d -> " (" <> formatDuration d.getDuration <> ")") c.duration
   where
     statusText
         | null c.testCases = if c.passed then "passed" else "failed"
         | otherwise =
             let total = length c.testCases
                 failedCount = length $ filter isFailedCase c.testCases
-            in  if failedCount == 0 then
-                    "passed (" <> show total <> ")"
-                else
-                    show failedCount <> "/" <> show total <> " failed"
+            in  if failedCount == 0
+                    then
+                        "passed (" <> show total <> ")"
+                    else
+                        show failedCount <> "/" <> show total <> " failed"
     isFailedCase (Test.Case _ (Test.Failed _)) = True
     isFailedCase _ = False
 
@@ -176,11 +181,12 @@
     => FilePath -> FollowMode -> Eff es ()
 showLog path followMode = do
     exists <- doesFileExist path
-    if not exists then
-        Console.putTextLn $ "Log file does not exist yet: " <> toText path
-    else case followMode of
-        Follow -> followFile path Console.putStr
-        NoFollow -> readFileLbs path >>= Console.putStr . BSL.toStrict
+    if not exists
+        then
+            Console.putTextLn $ "Log file does not exist yet: " <> toText path
+        else case followMode of
+            Follow -> followFile path Console.putStr
+            NoFollow -> readFileLbs path >>= Console.putStr . BSL.toStrict
 
 
 showTests
@@ -213,10 +219,11 @@
             when (any Test.isFailedRun filteredSuites) exitFailure
       where
         filteredSuites =
-            if opts.failedOnly then
-                Map.filter Test.isFailedRun suites
-            else
-                suites
+            if opts.failedOnly
+                then
+                    Map.filter Test.isFailedRun suites
+                else
+                    suites
 
     printTestOutput tgt tr = case tr of
         Test.SuiteRunning Nothing ->
@@ -227,14 +234,16 @@
             Console.putTextLn $ t <> "error: " <> e.message
         Test.SuiteCompleted c -> do
             Console.putTextLn $ t <> completionSummary c
-            if opts.failedOnly then
-                if null c.testCases then do
-                    Console.putTextLn "  (unrecognised test runner format — showing full output)"
-                    mapM_ (Console.putTextLn . ("  " <>)) (stripGhciNoise (lines c.output))
+            if opts.failedOnly
+                then
+                    if null c.testCases
+                        then do
+                            Console.putTextLn "  (unrecognised test runner format — showing full output)"
+                            mapM_ (Console.putTextLn . ("  " <>)) (stripGhciNoise (lines c.output))
+                        else
+                            mapM_ printFailedCase (filter Test.caseFailed c.testCases)
                 else
-                    mapM_ printFailedCase (filter Test.caseFailed c.testCases)
-            else
-                mapM_ (Console.putTextLn . ("  " <>)) (stripGhciNoise (lines c.output))
+                    mapM_ (Console.putTextLn . ("  " <>)) (stripGhciNoise (lines c.output))
       where
         t = renderTestTarget tgt <> "  "
 
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
@@ -5,25 +5,28 @@
     , diagnosticBlock
     , formatDuration
     , renderSourceResults
-    ) where
+    )
+where
 
 import Atelier.Effects.Console (Console)
 import Atelier.Time (Millisecond, toMicroseconds)
+import Tricorder.SourceLookup.SourceQuery (ModuleName (..), SourceQuery (..))
 
 import Atelier.Effects.Console qualified as Console
 
 import Tricorder.Build (Diagnostic (..), Severity (..))
-import Tricorder.Module (ModuleName (..), PackageId (..))
-import Tricorder.SourceLookup (ModuleSourceResult (..), SourceQuery (..))
+import Tricorder.SourceLookup (ModuleSourceResult (..))
+import Tricorder.SourceLookup.PackageId (PackageId (..))
 
 
 formatDuration :: Millisecond -> Text
 formatDuration d =
     let ms = toMicroseconds d `div` 1000
-    in  if ms < 1000 then
-            show ms <> "ms"
-        else
-            show (ms `div` 1000) <> "." <> show ((ms `mod` 1000) `div` 100) <> "s"
+    in  if ms < 1000
+            then
+                show ms <> "ms"
+            else
+                show (ms `div` 1000) <> "." <> show ((ms `mod` 1000) `div` 100) <> "s"
 
 
 -- | Single-line diagnostic for plain-text / shell output.
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
@@ -1,6 +1,7 @@
 module Tricorder.CLI.UI
     ( viewUi
-    ) where
+    )
+where
 
 import Atelier.Effects.Clock (Clock)
 import Atelier.Effects.Conc (Conc)
diff --git a/src/Tricorder/CLI/UI/Brick.hs b/src/Tricorder/CLI/UI/Brick.hs
--- a/src/Tricorder/CLI/UI/Brick.hs
+++ b/src/Tricorder/CLI/UI/Brick.hs
@@ -3,7 +3,8 @@
       Brick
     , runBrickApp
     , runBrick
-    ) where
+    )
+where
 
 import Brick.Main (App, customMain)
 import Effectful (Effect, IOE)
diff --git a/src/Tricorder/CLI/UI/BrickChan.hs b/src/Tricorder/CLI/UI/BrickChan.hs
--- a/src/Tricorder/CLI/UI/BrickChan.hs
+++ b/src/Tricorder/CLI/UI/BrickChan.hs
@@ -5,7 +5,8 @@
     , writeBChan
     , readBChan
     , runBrickChan
-    ) where
+    )
+where
 
 import Brick.BChan (BChan)
 import Effectful (Effect, IOE)
diff --git a/src/Tricorder/CLI/UI/Event.hs b/src/Tricorder/CLI/UI/Event.hs
--- a/src/Tricorder/CLI/UI/Event.hs
+++ b/src/Tricorder/CLI/UI/Event.hs
@@ -1,7 +1,8 @@
 module Tricorder.CLI.UI.Event
     ( Event (..)
     , handleEvent
-    ) where
+    )
+where
 
 import Brick (BrickEvent (..), EventM, vScrollBy, viewportScroll)
 import Brick.Keybindings (KeyDispatcher, handleKey)
@@ -20,7 +21,10 @@
     | FailedBuild Text
 
 
-handleEvent :: KeyDispatcher KeyEvent (EventM Viewports State) -> BrickEvent Viewports Event -> EventM Viewports State ()
+handleEvent
+    :: KeyDispatcher KeyEvent (EventM Viewports State)
+    -> BrickEvent Viewports Event
+    -> EventM Viewports State ()
 handleEvent _ (AppEvent ev) = handleAppEvent ev
 handleEvent d (VtyEvent (Vty.EvKey key modifiers)) = void $ handleKey d key modifiers
 handleEvent _ (MouseDown vp Vty.BScrollUp _ _) = vScrollBy (viewportScroll vp) (-1)
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
@@ -6,7 +6,8 @@
     , viewKeybindings
     , mkKeyConfig
     , keybindForRoute
-    ) where
+    )
+where
 
 import Atelier.Effects.Console (Console)
 import Brick
@@ -182,30 +183,34 @@
             cfg
             [ onEvent ToggleDaemonInfoView "Toggle daemon info view" do
                 modify \s ->
-                    if currentRoute s == Route.DaemonInfo then
-                        navigate Route.Main s
-                    else
-                        navigate Route.DaemonInfo s
+                    if currentRoute s == Route.DaemonInfo
+                        then
+                            navigate Route.Main s
+                        else
+                            navigate Route.DaemonInfo s
             , onEvent ToggleHelp "Toggle help" do
                 modify \s ->
-                    if currentRoute s == Route.Help then
-                        navigate Route.Main s
-                    else
-                        navigate Route.Help 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}
+                        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
+                    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}
diff --git a/src/Tricorder/CLI/UI/Misc.hs b/src/Tricorder/CLI/UI/Misc.hs
--- a/src/Tricorder/CLI/UI/Misc.hs
+++ b/src/Tricorder/CLI/UI/Misc.hs
@@ -6,7 +6,8 @@
     , subtle
     , hBoxSpaced
     , vBoxSpaced
-    ) where
+    )
+where
 
 import Brick
     ( Padding (..)
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
@@ -1,7 +1,8 @@
 module Tricorder.CLI.UI.Route
     ( Route (..)
     , name
-    ) where
+    )
+where
 
 
 data Route
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
@@ -8,7 +8,8 @@
     , viewToViewport
     , cycleTestFilter
     , navigate
-    ) where
+    )
+where
 
 import Atelier.Effects.Clock (Clock, TimeZone)
 import Prelude hiding (init)
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
@@ -1,7 +1,6 @@
 module Tricorder.CLI.UI.View (mkAttrMap, view) where
 
 import Atelier.Effects.Clock (TimeZone)
-import Atelier.Time (Millisecond, toMicroseconds)
 import Brick
     ( AttrMap
     , AttrName
@@ -35,10 +34,17 @@
 import Graphics.Vty.Attributes.Color qualified as Color
 
 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.Misc (emphasis, err, hBoxSpaced, ok, subtle, vBoxSpaced, warn)
 import Tricorder.CLI.UI.Route (Route)
-import Tricorder.CLI.UI.State (Processed (..), State (..), TestFilter (..), Viewports (..), currentRoute)
+import Tricorder.CLI.UI.State
+    ( Processed (..)
+    , State (..)
+    , TestFilter (..)
+    , Viewports (..)
+    , currentRoute
+    )
 import Tricorder.Daemon.DaemonInfo (DaemonInfo (..))
 import Tricorder.Session.Target (Target, renderTarget)
 import Tricorder.Session.TestTarget (TestTarget, renderTestTarget)
@@ -247,10 +253,11 @@
     hBoxSpaced
         1
         [ emphasis $ txt "Targets:"
-        , if null targets then
-            txt "(all)"
-          else
-            vBox $ (map (txt . renderTarget) targets)
+        , if null targets
+            then
+                txt "(all)"
+            else
+                vBox $ (map (txt . renderTarget) targets)
         ]
 
 
@@ -346,10 +353,11 @@
             errCount = length $ filter (\m -> m.severity == SError) msgs
             warnCount = length $ filter (\m -> m.severity == SWarning) msgs
             header =
-                if errCount > 0 then
-                    err $ txt $ show errCount <> " error(s), " <> show warnCount <> " warning(s)"
-                else
-                    warn $ txt $ show warnCount <> " warning(s)"
+                if errCount > 0
+                    then
+                        err $ txt $ show errCount <> " error(s), " <> show warnCount <> " warning(s)"
+                    else
+                        warn $ txt $ show warnCount <> " warning(s)"
         in  vBoxSpaced
                 1
                 [ hBoxSpaced
@@ -384,7 +392,7 @@
 severityToAttrName SWarning = attrName "warning"
 
 
-viewDuration :: Millisecond -> Widget n
+viewDuration :: Duration -> Widget n
 viewDuration d = txt $ "(" <> formatDuration d <> ")"
 
 
@@ -422,28 +430,30 @@
         | otherwise =
             let total = length c.testCases
                 failed = length $ filter Test.caseFailed c.testCases
-            in  if failed == 0 then
-                    ok $ txt $ "passed (" <> show total <> ")"
-                else
-                    err $ txt $ show failed <> "/" <> show total <> " failed"
+            in  if failed == 0
+                    then
+                        ok $ txt $ "passed (" <> show total <> ")"
+                    else
+                        err $ txt $ show failed <> "/" <> show total <> " failed"
 
 
 viewTimestamp :: TimeZone -> UTCTime -> Widget n
 viewTimestamp tz t = txt $ "— " <> toText (formatTime defaultTimeLocale "%H:%M:%S" $ utcToLocalTime tz t)
 
 
-viewBuildSummary :: Int -> Millisecond -> Widget n
+viewBuildSummary :: Int -> Duration -> Widget n
 viewBuildSummary moduleCount duration =
     txt $ "(" <> show moduleCount <> " modules, " <> formatDuration duration <> ")"
 
 
-formatDuration :: Millisecond -> Text
-formatDuration d =
-    let ms = toMicroseconds d `div` 1000
-    in  if ms < 1000 then
-            show ms <> "ms"
-        else
-            show (ms `div` 1000) <> "." <> show ((ms `mod` 1000) `div` 100) <> "s"
+formatDuration :: Duration -> Text
+formatDuration (Duration d) =
+    let ms = toInteger d
+    in  if ms < 1000
+            then
+                show ms <> "ms"
+            else
+                show (ms `div` 1000) <> "." <> show ((ms `mod` 1000) `div` 100) <> "s"
 
 
 -- | Single-line build status with no scrollable diagnostics list, used as a
@@ -475,10 +485,11 @@
         let errCount = length $ filter (\m -> m.severity == SError) result.diagnostics
             warnCount = length $ filter (\m -> m.severity == SWarning) result.diagnostics
             header =
-                if errCount > 0 then
-                    err $ txt $ show errCount <> " error(s), " <> show warnCount <> " warning(s)"
-                else
-                    warn $ txt $ show warnCount <> " warning(s)"
+                if errCount > 0
+                    then
+                        err $ txt $ show errCount <> " error(s), " <> show warnCount <> " warning(s)"
+                    else
+                        warn $ txt $ show warnCount <> " warning(s)"
         in  hBoxSpaced 1 [header, viewDuration result.duration, viewTimestamp tz result.completedAt]
 
 
diff --git a/src/Tricorder/Config.hs b/src/Tricorder/Config.hs
--- a/src/Tricorder/Config.hs
+++ b/src/Tricorder/Config.hs
@@ -3,7 +3,8 @@
     , runLoadedConfig
     , inputLoadedConfig
     , configFileName
-    ) where
+    )
+where
 
 import Atelier.Config (LoadedConfig (..))
 import Atelier.Effects.FileSystem (FileSystem)
@@ -24,13 +25,14 @@
 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
+    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
 
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
@@ -7,13 +7,14 @@
     , consider
     , with
     , compileBuildResults
-    ) where
+    )
+where
 
 import Atelier.Effects.Clock (Clock, UTCTime)
 import Atelier.Effects.FileWatcher (FileEvent)
 import Atelier.Effects.Log (Log)
 import Atelier.Effects.Publishing.Pub (Pub)
-import Atelier.Time (Millisecond, nominalDiffTime)
+import Atelier.Time (nominalDiffTime)
 import Data.Time (diffUTCTime)
 import Effectful (Effect, inject)
 import Effectful.Dispatch.Dynamic (reinterpretWith_)
@@ -31,6 +32,7 @@
 import Effectful.State.Static.Shared qualified as State
 
 import Tricorder.Build (BuildId (..), BuildProgress, BuildResult (..), Diagnostic (..))
+import Tricorder.Build.Duration (Duration (..))
 import Tricorder.Daemon.Dispatch
     ( BuilderState (..)
     , DiagnosticMap
@@ -178,7 +180,7 @@
     buildResult =
         BuildResult
             { completedAt = endTime
-            , duration = nominalDiffTime (diffUTCTime endTime startTime) :: Millisecond
+            , duration = Duration $ nominalDiffTime (diffUTCTime endTime startTime)
             , moduleCount = loadResult.moduleCount
             , diagnostics = sortOn (\d -> (d.severity, d.file, d.line, d.col)) $ concat $ Map.elems merged
             }
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
@@ -9,6 +9,7 @@
 import Atelier.Effects.FileWatcher (FileEvent, FileWatcher)
 import Atelier.Effects.Input (Input)
 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)
@@ -19,6 +20,7 @@
 import Effectful.State.Static.Shared (State)
 import Relude.Extra.Tuple (dup)
 import System.FilePath ((</>))
+import Text.Regex.TDFA.Pattern (showPattern)
 
 import Atelier.Effects.Conc qualified as Conc
 import Atelier.Effects.FileSystem qualified as FileSystem
@@ -27,10 +29,12 @@
 import Atelier.Effects.Publishing.Pub qualified as Pub
 import Atelier.Effects.Publishing.Sub qualified as Sub
 import Data.Map.Strict qualified as Map
+import Data.Text qualified as T
 import Effectful.Reader.Static qualified as Reader
 import Effectful.State.Static.Shared qualified as State
 
 import Tricorder.Build (BuildId, BuildPhase, BuildResult, PostBuild (..), Severity (..))
+import Tricorder.Build.ByteSize (ByteSize)
 import Tricorder.Build.Changes (CabalChangeDetected (..), SourceChangeDetected (..))
 import Tricorder.Daemon.Builder
     ( BuildConsideration (..)
@@ -60,10 +64,14 @@
 import Tricorder.Runtime (ProjectRoot (..))
 import Tricorder.Session (Session (..), loadSession)
 import Tricorder.Session.CabalFile (CabalFile)
-import Tricorder.Session.Command (Command (..))
+import Tricorder.Session.Command (Command (..), Repl)
 import Tricorder.Session.GenerateWithHpack (GenerateWithHpack (..))
+import Tricorder.Session.IdleTimeout (IdleTimeout)
+import Tricorder.Session.ReplBuildDir (ReplBuildDir (..))
 import Tricorder.Session.TestTarget (TestTarget, renderTestTarget)
-import Tricorder.Session.TestTimeout (TestTimeout)
+import Tricorder.Session.TestTimeout (TestTimeout (..))
+import Tricorder.Session.WatchDirs (WatchDirs (..))
+import Tricorder.Session.WatchExclusionPatterns (WatchExclusionPatterns (..))
 import Tricorder.Waiters (Waiters)
 
 import Tricorder.Build qualified as Build
@@ -75,11 +83,19 @@
 import Tricorder.Daemon.Hpack qualified as Hpack
 import Tricorder.Daemon.TestRunner qualified as TestRunner
 import Tricorder.Daemon.Watch qualified as Watch
+import Tricorder.Session.Command qualified as Command
+import Tricorder.Session.Hooks qualified as Hooks
+import Tricorder.Session.Target qualified as Target
+import Tricorder.Session.TestTarget qualified as TestTarget
 import Tricorder.Waiters qualified as Waiters
 
 
 data ReloadSession = ReloadSession
+
+
 data RestartBuilder = RestartBuilder
+
+
 data ReloadBuilder = ReloadBuilder FilePath FileEvent
 
 
@@ -99,9 +115,12 @@
        , Input LoadedConfig :> es
        , Input [CabalFile] :> es
        , Log :> es
+       , Process :> es
        , Pub BuildPhase :> es
        , Reader ProjectRoot :> es
        , State BuildId :> es
+       , State IdleTimeout :> es
+       , State Repl :> es
        , TestRunner :> es
        , Waiters :> es
        )
@@ -115,6 +134,10 @@
     $ Conc.restartableFork waitForReloadSession do
         root <- Reader.ask
         session <- loadSession
+        logSession session
+
+        State.put session.command.repl
+        State.put session.idleTimeout
         Conc.fork_ $ watchConfigFile root
         conditionallyWatchStackYaml root
 
@@ -123,10 +146,11 @@
 
         Conc.fork_ $ Sub.listen_ \(CabalChangeDetected _ _) -> do
             needsSessionReload <- shouldReloadSession session
-            if needsSessionReload then
-                Pub.publish ReloadSession
-            else
-                Pub.publish RestartBuilder
+            if needsSessionReload
+                then
+                    Pub.publish ReloadSession
+                else
+                    Pub.publish RestartBuilder
 
         Conc.fork_ $ Sub.listen_ \(SourceChangeDetected fp event) ->
             Pub.publish $ ReloadBuilder fp event
@@ -189,6 +213,7 @@
        , EvalCommentRunner :> es
        , GhciSession :> es
        , Log :> es
+       , Process :> es
        , Pub BuildPhase :> es
        , Reader ProjectRoot :> es
        , State BuildId :> es
@@ -213,6 +238,7 @@
        , EvalCommentRunner :> es
        , GhciSession :> es
        , Log.Log :> es
+       , Process :> es
        , Pub BuildPhase :> es
        , Reader ProjectRoot :> es
        , State BuilderState :> es
@@ -224,9 +250,11 @@
 runSession buildId session = do
     Log.info $ "Starting session " <> show buildId.getBuildId
     Pub.publish Build.Starting
+    whenJust (session.hooks.start >>= (.before)) Hooks.runHook
     startupError <- fmap (either id absurd)
         $ Pub.map (Build.Building session.testTargets)
         $ Builder.with buildId session.command session.watchDirs \_ initialLoad -> do
+            whenJust (session.hooks.start >>= (.after)) Hooks.runHook
             processPostBuild session $ Right initialLoad
             Log.debug "Waiting for reload"
             newestReloadEvent <- atomically newEmptyTMVar
@@ -254,6 +282,7 @@
        , Conc :> es
        , EvalCommentRunner :> es
        , Log :> es
+       , Process :> es
        , Pub BuildPhase :> es
        , Reader ProjectRoot :> es
        , State BuilderState :> es
@@ -279,6 +308,7 @@
        , Conc :> es
        , EvalCommentRunner :> es
        , Log :> es
+       , Process :> es
        , Pub BuildPhase :> es
        , Reader ProjectRoot :> es
        , State BuilderState :> es
@@ -288,7 +318,9 @@
     -> DispatchAction
     -> Eff es ()
 processSource session action = do
+    whenJust (session.hooks.reload >>= (.before)) Hooks.runHook
     res <- Builder.build action
+    whenJust (session.hooks.reload >>= (.after)) Hooks.runHook
     Log.debug "Finished build"
     processPostBuild session res
 
@@ -343,7 +375,8 @@
     => Session -> LoadResult -> Eff es Eval.Phase
 runEvalComments session loadResult = do
     builderState <- State.get @BuilderState
-    evalComments <- findEvalCommentsInModules $ resolveKnownTargets builderState.loadedModules loadResult
+    evalComments <-
+        findEvalCommentsInModules $ resolveKnownTargets builderState.loadedModules loadResult
 
     case nonEmpty evalComments of
         Nothing -> pure Eval.NoneFound
@@ -370,7 +403,11 @@
     => Session -> BuildResult -> Eff es Test.Suites
 runTests session buildResult
     | hasTargets session.testTargets && noErrors buildResult.diagnostics =
-        runTestsForTargets session.command session.testTimeout session.testTargets
+        runTestsForTargets
+            session.command
+            session.testMemoryLimit
+            session.testTimeout
+            session.testTargets
     | otherwise = pure mempty
   where
     hasTargets = not . null
@@ -383,10 +420,11 @@
        , TestRunner :> es
        )
     => Command
+    -> Maybe ByteSize
     -> TestTimeout
     -> [TestTarget]
     -> Eff es Test.Suites
-runTestsForTargets command testTimeout testTargets = do
+runTestsForTargets command memoryLimit testTimeout testTargets = do
     Pub.publish $ Test.Suites initial
     Log.info $ "Running " <> show (length testTargets) <> " test suite(s)"
     fmap Test.Suites . State.execState initial $ traverse_ go testTargets
@@ -400,6 +438,7 @@
                     updated <- State.state $ dup . Map.insert target suite
                     Pub.publish $ Test.Suites updated
                 )
+                memoryLimit
                 command.repl
                 testTimeout
                 target
@@ -420,3 +459,28 @@
                     s.diagnosticMap
                     newLoadResult
         in  (buildResult, s {diagnosticMap = newDiagnosticMap})
+
+
+logSession :: (Log :> es) => Session -> Eff es ()
+logSession session =
+    Log.info
+        $ T.intercalate
+            "\n"
+            [ "Loaded session"
+            , "Command: " <> Command.render session.command
+            , "Targets:"
+            , showList Target.renderTarget session.targets
+            , "Test targets:"
+            , showList TestTarget.renderTestTarget session.testTargets
+            , "Watch dirs:"
+            , showList toText session.watchDirs.getWatchDirs
+            , "Watch exclusion patterns:"
+            , showList (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: " <> show session.testTimeout.getTestTimeout <> " seconds"
+            , "Test memory limit: " <> show session.testMemoryLimit
+            , "Generate with hpack: " <> show session.generateWithHpack.getGenerateWithHpack
+            ]
+  where
+    showList f = T.intercalate "\n" . fmap (("- " <>) . f)
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
@@ -2,7 +2,8 @@
     ( DaemonInfo (..)
     , load
     , runInput
-    ) where
+    )
+where
 
 import Atelier.Effects.Input (Input, input, runInputEff)
 import Data.Aeson (FromJSON (..), ToJSON (..))
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
@@ -9,7 +9,8 @@
     , filterToWatchDirs
     , mergeDiagnostics
     , preserveFailureVisibility
-    ) where
+    )
+where
 
 import Atelier.Effects.FileWatcher (FileEvent (..))
 import System.FilePath (isAbsolute, normalise, splitDirectories, takeExtension, (</>))
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
@@ -6,7 +6,8 @@
 
       -- * Interpreters
     , run
-    ) where
+    )
+where
 
 import Atelier.Effects.Conc (Conc)
 import Atelier.Effects.File (File)
diff --git a/src/Tricorder/Daemon/GhciSession.hs b/src/Tricorder/Daemon/GhciSession.hs
--- a/src/Tricorder/Daemon/GhciSession.hs
+++ b/src/Tricorder/Daemon/GhciSession.hs
@@ -13,7 +13,8 @@
       -- * Interpreters
     , runGhciSession
     , runGhciSessionScripted
-    ) where
+    )
+where
 
 import Atelier.Effects.Conc (Conc)
 import Atelier.Effects.File (File)
@@ -49,7 +50,14 @@
     , LoadResult (..)
     , LoadedModule (..)
     )
-import Tricorder.Daemon.GhciSession.GhciProcess (addGhci, collectGhciResult, interruptGhci, reloadGhci, unaddGhci, withGhciProcess)
+import Tricorder.Daemon.GhciSession.GhciProcess
+    ( addGhci
+    , collectGhciResult
+    , interruptGhci
+    , reloadGhci
+    , unaddGhci
+    , withGhciProcess
+    )
 import Tricorder.Runtime (ProjectRoot (..))
 import Tricorder.Session.Command (Command)
 
@@ -104,7 +112,8 @@
 -- 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
+    :: 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
diff --git a/src/Tricorder/Daemon/GhciSession/GhciParser.hs b/src/Tricorder/Daemon/GhciSession/GhciParser.hs
--- a/src/Tricorder/Daemon/GhciSession/GhciParser.hs
+++ b/src/Tricorder/Daemon/GhciSession/GhciParser.hs
@@ -22,7 +22,8 @@
     , toAbsolute
     , toRelative
     , unattributedFailure
-    ) where
+    )
+where
 
 import Data.Char (isAlpha, isDigit, isSpace, toLower)
 import System.FilePath (dropExtension, isAbsolute, makeRelative, normalise, splitDirectories, (</>))
@@ -216,12 +217,13 @@
     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
+        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)
@@ -338,11 +340,12 @@
 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
+    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.
@@ -515,10 +518,11 @@
     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
+    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
@@ -534,7 +538,8 @@
         , 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"
+        , text =
+            "GHCi reported a failed load with no located error.\nRun `tricorder log` to see the full GHCi output.\n"
         }
 
 
diff --git a/src/Tricorder/Daemon/GhciSession/GhciProcess.hs b/src/Tricorder/Daemon/GhciSession/GhciProcess.hs
--- a/src/Tricorder/Daemon/GhciSession/GhciProcess.hs
+++ b/src/Tricorder/Daemon/GhciSession/GhciProcess.hs
@@ -14,7 +14,8 @@
     , reloadGhci
     , addGhci
     , unaddGhci
-    ) where
+    )
+where
 
 import Atelier.Effects.Conc (Conc)
 import Atelier.Effects.File (BufferMode (..), File, Handle)
@@ -448,11 +449,12 @@
         case result of
             Left ex -> throwIO ex
             Right line ->
-                if isVersionLine line then
-                    pure ()
-                else do
-                    captureLine line
-                    go
+                if isVersionLine line
+                    then
+                        pure ()
+                    else do
+                        captureLine line
+                        go
 
 
 drainUntilEof :: (File :> es) => Handle -> (Text -> Eff es ()) -> Eff es ()
diff --git a/src/Tricorder/Daemon/Hpack/Effect.hs b/src/Tricorder/Daemon/Hpack/Effect.hs
--- a/src/Tricorder/Daemon/Hpack/Effect.hs
+++ b/src/Tricorder/Daemon/Hpack/Effect.hs
@@ -4,7 +4,8 @@
     , hpackIsInPath
     , hpack
     , run
-    ) where
+    )
+where
 
 import Atelier.Effects.Process (Process, readProcess, runProcess, setWorkingDir, shell)
 import Effectful (Effect)
@@ -56,7 +57,8 @@
                             | "generated with a newer version" `B8.isInfixOf` stdout -> WasGeneratedWithNewerHpack
                             | "was modified manually" `B8.isInfixOf` stdout -> WasEditedManually
                             | otherwise -> UnknownSuccess $ decodeUtf8 stdout
-                if exitCode == ExitSuccess then
-                    pure $ Right $ infoMsg
-                else
-                    pure $ Left $ decodeUtf8 stderr
+                if exitCode == ExitSuccess
+                    then
+                        pure $ Right $ infoMsg
+                    else
+                        pure $ Left $ decodeUtf8 stderr
diff --git a/src/Tricorder/Daemon/IdleTimer.hs b/src/Tricorder/Daemon/IdleTimer.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Daemon/IdleTimer.hs
@@ -0,0 +1,87 @@
+module Tricorder.Daemon.IdleTimer
+    ( IdleTimer
+    , withActivity
+    , quitOnTimeout
+    )
+where
+
+import Atelier.Effects.Clock (Clock, currentTime)
+import Atelier.Effects.Conc (Conc)
+import Atelier.Effects.Delay (Delay)
+import Atelier.Effects.Exit (Exit, exitSuccess)
+import Atelier.Effects.Input (Input, input)
+import Atelier.Effects.Log (Log)
+import Atelier.Time (Second)
+import Data.Time (diffUTCTime)
+import Effectful (Effect, Limit (..), Persistence (..), UnliftStrategy (..))
+import Effectful.Concurrent (Concurrent)
+import Effectful.Concurrent.STM (atomically, modifyTVar', newTVarIO, readTVar, writeTVar)
+import Effectful.Dispatch.Dynamic (interpretWith, localUnlift)
+import Effectful.Exception (finally)
+import Effectful.TH (makeEffect)
+
+import Atelier.Effects.Conc qualified as Conc
+import Atelier.Effects.Delay qualified as Delay
+import Atelier.Effects.Log qualified as Log
+
+import Tricorder.Session.IdleTimeout (IdleTimeout (..))
+
+
+-- | Performs an interpreter-specific action after a certain amount of time has
+-- passed without activity.
+data IdleTimer :: Effect where
+    WithActivity :: m a -> IdleTimer m a
+
+
+makeEffect ''IdleTimer
+
+
+-- | Run the idle timer, shutting the process down once
+-- @idle_timeout_seconds@ (read from 'Session', re-read on every check so
+-- config reloads apply live) elapses with no open connections. A timeout of
+-- zero or less disables shutdown.
+quitOnTimeout
+    :: ( Clock :> es
+       , Conc :> es
+       , Concurrent :> es
+       , Delay :> es
+       , Exit :> es
+       , Input IdleTimeout :> es
+       , Log :> es
+       )
+    => Eff (IdleTimer : es) a -> Eff es a
+quitOnTimeout act = do
+    startedAt <- currentTime
+    lastActivity <- newTVarIO startedAt
+    activeActions <- newTVarIO (0 :: Int)
+
+    Conc.fork_ $ Log.withNamespace "IdleTimer" $ forever do
+        Delay.wait (2 :: Second)
+        idleTimeout <- input
+        case idleTimeout of
+            IdleTimeout secs | secs <= 0 -> pure ()
+            IdleTimeout secs -> do
+                now <- currentTime
+                shouldExit <- atomically do
+                    connections <- readTVar activeActions
+                    idleSince <- readTVar lastActivity
+                    pure $ connections <= 0 && diffUTCTime now idleSince >= fromIntegral secs
+                when shouldExit do
+                    Log.info
+                        $ "Idle for "
+                            <> show secs
+                            <> " with no active connections, shutting down."
+                    exitSuccess
+
+    interpretWith act \env -> \case
+        WithActivity action -> do
+            start <- currentTime
+            atomically do
+                modifyTVar' activeActions (+ 1)
+                writeTVar lastActivity start
+            localUnlift env (ConcUnlift Persistent Unlimited) \unlift -> do
+                unlift action `finally` do
+                    end <- currentTime
+                    atomically do
+                        modifyTVar' activeActions (max 0 . subtract 1)
+                        writeTVar lastActivity end
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
@@ -19,6 +19,7 @@
 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
@@ -29,19 +30,21 @@
 import Tricorder.Config (inputLoadedConfig)
 import Tricorder.Daemon.GhciSession (runGhciSession)
 import Tricorder.Logging (runLogging)
-import Tricorder.Module (ModuleName, PackageId)
 import Tricorder.Runtime (runLogPath, runProjectRoot, runRuntimeDir, runSocketPath)
 import Tricorder.Session (inputSession)
 import Tricorder.Session.CabalFile (inputCabalFiles)
+import Tricorder.Session.IdleTimeout (IdleTimeout)
 import Tricorder.Socket.UnixSocket (runUnixSocketIO)
-import Tricorder.SourceLookup (SourceQuery)
 import Tricorder.SourceLookup.GhcPkg (runGhcPkgIO)
+import Tricorder.SourceLookup.PackageId (PackageId)
 
 import Tricorder.Daemon.Core qualified as Core
 import Tricorder.Daemon.DaemonInfo qualified as DaemonInfo
 import Tricorder.Daemon.EvalCommentRunner qualified as EvalCommentRunner
 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.Command qualified as Repl
 import Tricorder.Socket.Server qualified as Server
 import Tricorder.SourceLookup qualified as SourceLookup
 import Tricorder.SourceLookup.Hackage qualified as Hackage
@@ -85,6 +88,11 @@
         . runGhciSession
         . evalState (BuildId 1)
         . Input.fromState @BuildId
+        . evalState Repl.Unknown
+        . Input.fromState @Repl.Repl
+        . evalState @IdleTimeout def
+        . Input.fromState @IdleTimeout
+        . IdleTimer.quitOnTimeout
         . runPubSub @BuildPhase
         . Hpack.run
         . Hackage.run
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
@@ -13,7 +13,8 @@
 
       -- * Internal helpers (exported for testing)
     , loadingToProgress
-    ) where
+    )
+where
 
 import Atelier.Effects.Conc (Conc)
 import Atelier.Effects.File (File)
@@ -35,17 +36,19 @@
 import Data.List qualified as List
 import Data.Text qualified as T
 
+import Tricorder.Build.ByteSize (ByteSize)
 import Tricorder.Daemon.GhciSession.GhciParser (GhciLoading (..))
 import Tricorder.Daemon.GhciSession.GhciProcess
     ( execGhci
     , withGhciProcess
     )
 import Tricorder.Runtime (ProjectRoot (..))
-import Tricorder.Session.Command (Command (..), Repl)
+import Tricorder.Session.Command (Command (..), Repl (..))
 import Tricorder.Session.TestTarget (TestTarget, getTestTarget, renderTestTarget)
 import Tricorder.Session.TestTimeout (TestTimeout (..))
 import Tricorder.TestOutput (parseHspecDuration, parseHspecOutput)
 
+import Tricorder.Build.ByteSize qualified as ByteSize
 import Tricorder.Build.Test qualified as Test
 
 
@@ -55,6 +58,8 @@
     RunTestSuite
         :: (Test.Suite -> m ())
         -- ^ Handler for test run progress
+        -> Maybe ByteSize
+        -- ^ Memory limit for test suite
         -> Repl
         -> TestTimeout
         -> TestTarget
@@ -79,20 +84,51 @@
     => Eff (TestRunner : es) a -> Eff es a
 run act = do
     interpretWith act \env -> \case
-        RunTestSuite progressHandler repl testTimeout target ->
+        RunTestSuite progressHandler mMemoryLimit repl testTimeout target ->
             localUnlift env (ConcUnlift Persistent Unlimited) \unlift -> do
                 let onProgress = unlift . progressHandler . loadingToProgress
                     noProgress _ = pure ()
                     noReady _ = pure ()
+                    memoryLimitArg =
+                        maybe
+                            []
+                            ( \limit ->
+                                let
+                                    stack =
+                                        [ "--ghc-options"
+                                        , "+RTS -M"
+                                            <> ByteSize.toRTSSize limit
+                                            <> " -RTS"
+                                        ]
+                                    cabal =
+                                        [ "--repl-options"
+                                        , "+RTS -M"
+                                            <> ByteSize.toRTSSize limit
+                                            <> " -RTS"
+                                        ]
+                                in
+                                    case repl of
+                                        Stack -> stack
+                                        StackMulti -> stack
+                                        Cabal -> cabal
+                                        Unknown -> cabal
+                            )
+                            mMemoryLimit
                 ProjectRoot projectRoot <- ask
                 result <- trySync
-                    $ withGhciProcess def (Command repl [] [getTestTarget target]) projectRoot onProgress noReady \ghci _ ->
-                        case testTimeout of
-                            TestTimeout secs | secs <= 0 -> Right <$> execGhci ghci ":main" noProgress
-                            TestTimeout secs ->
-                                let duration = fromIntegral secs :: Second
-                                in  maybeToRight secs
-                                        <$> timeout duration (execGhci ghci ":main" noProgress)
+                    $ withGhciProcess
+                        def
+                        (Command repl memoryLimitArg [getTestTarget target])
+                        projectRoot
+                        onProgress
+                        noReady
+                        \ghci _ ->
+                            case testTimeout of
+                                TestTimeout secs | secs <= 0 -> Right <$> execGhci ghci ":main" noProgress
+                                TestTimeout secs ->
+                                    let duration = fromIntegral secs :: Second
+                                    in  maybeToRight secs
+                                            <$> timeout duration (execGhci ghci ":main" noProgress)
                 case result of
                     Left ex ->
                         pure
@@ -141,7 +177,7 @@
 runScripted results =
     reinterpret_
         (evalState results)
-        (\(RunTestSuite _ _ _ _) -> popResult)
+        (\(RunTestSuite _ _ _ _ _) -> popResult)
   where
     popResult :: Eff (State [Either SomeException Test.Suite] : es) Test.Suite
     popResult =
@@ -186,13 +222,15 @@
                 Nothing -> GhciPassed
                 Just rest ->
                     let r = T.strip rest
-                    in  if r == "ExitSuccess" then
-                            GhciPassed
-                        else
-                            if "ExitFailure" `T.isPrefixOf` r then
-                                GhciFailed
+                    in  if r == "ExitSuccess"
+                            then
+                                GhciPassed
                             else
-                                GhciCrashed r
+                                if "ExitFailure" `T.isPrefixOf` r
+                                    then
+                                        GhciFailed
+                                    else
+                                        GhciCrashed r
         Nothing -> case List.find isCompileErrorLine outputLines of
             Just errLine -> GhciCrashed (T.strip errLine)
             Nothing -> GhciPassed
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
@@ -4,7 +4,8 @@
     , publishChange
     , specs
     , isCabalFile
-    ) where
+    )
+where
 
 import Atelier.Effects.Debounce (Debounce)
 import Atelier.Effects.FileWatcher
diff --git a/src/Tricorder/Module.hs b/src/Tricorder/Module.hs
deleted file mode 100644
--- a/src/Tricorder/Module.hs
+++ /dev/null
@@ -1,29 +0,0 @@
-module Tricorder.Module
-    ( ModuleName (..)
-    , PackageId (..)
-    , splitPackageId
-    ) where
-
-import Data.Aeson (FromJSON, ToJSON)
-
-import Data.Text qualified as T
-
-
--- | A dotted Haskell module name, e.g. @"Data.Map.Strict"@.
-newtype ModuleName = ModuleName {unModuleName :: Text}
-    deriving newtype (Eq, FromJSON, Hashable, IsString, Ord, Show, ToJSON)
-
-
--- | A @ghc-pkg@ package identifier, e.g. @"containers-0.6.8"@.
-newtype PackageId = PackageId {unPackageId :: Text}
-    deriving newtype (Eq, FromJSON, Hashable, IsString, Ord, Show, ToJSON)
-
-
--- | Split a 'PackageId' into its package name and version. The version is the
--- final hyphen-delimited component (versions are dot-, not hyphen-separated),
--- so @"list-t-1.0.5.7"@ → @("list-t", "1.0.5.7")@.
-splitPackageId :: PackageId -> (Text, Text)
-splitPackageId (PackageId pid) =
-    case reverse (T.splitOn "-" pid) of
-        (ver : nameParts@(_ : _)) -> (T.intercalate "-" (reverse nameParts), ver)
-        _ -> (pid, "")
diff --git a/src/Tricorder/Runtime.hs b/src/Tricorder/Runtime.hs
--- a/src/Tricorder/Runtime.hs
+++ b/src/Tricorder/Runtime.hs
@@ -10,7 +10,8 @@
     , runSocketPath
     , LogPath (..)
     , runLogPath
-    ) where
+    )
+where
 
 import Atelier.Effects.FileSystem
     ( FileSystem
diff --git a/src/Tricorder/Session.hs b/src/Tricorder/Session.hs
--- a/src/Tricorder/Session.hs
+++ b/src/Tricorder/Session.hs
@@ -2,7 +2,8 @@
     ( Session (..)
     , loadSession
     , inputSession
-    ) where
+    )
+where
 
 import Atelier.Config (LoadedConfig, extractConfig)
 import Atelier.Effects.FileSystem (FileSystem)
@@ -14,28 +15,39 @@
 import Atelier.Effects.Log qualified as Log
 import Data.Text qualified as T
 
+import Tricorder.Build.ByteSize (ByteSize)
 import Tricorder.Runtime (ProjectRoot (..))
 import Tricorder.Session.CabalFile (CabalFile)
 import Tricorder.Session.Command (Command (..), resolveCommand)
 import Tricorder.Session.Config (Config (..))
 import Tricorder.Session.GenerateWithHpack (GenerateWithHpack (..))
+import Tricorder.Session.Hooks (Hooks)
+import Tricorder.Session.IdleTimeout (IdleTimeout (..))
 import Tricorder.Session.ReplBuildDir (ReplBuildDir (..))
 import Tricorder.Session.Target (Target, definesCustomPrelude, resolveTargets)
 import Tricorder.Session.TestTarget (TestTarget, resolveTestTargets)
 import Tricorder.Session.TestTimeout (TestTimeout (..))
 import Tricorder.Session.WatchDirs (WatchDirs (..), resolveWatchDirs)
-import Tricorder.Session.WatchExclusionPatterns (WatchExclusionPatterns (..), resolveWatchExclusionPatterns)
+import Tricorder.Session.WatchExclusionPatterns
+    ( WatchExclusionPatterns (..)
+    , resolveWatchExclusionPatterns
+    )
 
+import Tricorder.Build.ByteSize qualified as ByteSize
 
+
 data Session = Session
     { command :: Command
     , targets :: [Target]
     , testTargets :: [TestTarget]
+    , testMemoryLimit :: Maybe ByteSize
     , watchDirs :: WatchDirs
     , watchExclusionPatterns :: WatchExclusionPatterns
     , replBuildDir :: ReplBuildDir
     , testTimeout :: TestTimeout
     , generateWithHpack :: GenerateWithHpack
+    , hooks :: Hooks
+    , idleTimeout :: IdleTimeout
     }
     deriving stock (Eq)
 
@@ -46,11 +58,14 @@
             { command = def
             , targets = []
             , testTargets = []
+            , testMemoryLimit = Nothing
             , watchDirs = def
             , watchExclusionPatterns = def
             , replBuildDir = def
             , testTimeout = def
             , generateWithHpack = def
+            , hooks = def
+            , idleTimeout = def
             }
 
 
@@ -71,7 +86,17 @@
         effectiveTargets = resolveTargets projectFiles cfgFile.targets
         testTargets = resolveTestTargets cfgFile effectiveTargets
         watchDirs = resolveWatchDirs projectRoot projectFiles cfgFile effectiveTargets
+        hooks = fromMaybe def cfgFile.hooks
 
+    testMemoryLimit <- case cfgFile.testMemoryLimit of
+        Nothing -> pure Nothing
+        Just limit -> case ByteSize.fromText limit of
+            Nothing -> do
+                Log.err $ "Unable to parse test_memory_limit: " <> limit
+                pure Nothing
+            Just parsedLimit ->
+                pure $ Just parsedLimit
+
     watchExclusionPatterns <-
         case resolveWatchExclusionPatterns cfgFile.watchExclusionPatterns of
             Left err -> do
@@ -101,10 +126,13 @@
             , command
             , watchDirs
             , watchExclusionPatterns
+            , testMemoryLimit
             , testTargets
             , replBuildDir = ReplBuildDir cfgFile.replBuildDir
             , testTimeout = TestTimeout cfgFile.testTimeout
             , generateWithHpack = GenerateWithHpack cfgFile.generateWithHpack
+            , hooks
+            , idleTimeout = IdleTimeout $ fromIntegral cfgFile.idleTimeoutSeconds
             }
 
 
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
@@ -2,7 +2,8 @@
     ( CabalFile (..)
     , inputCabalFiles
     , discoverCabalFiles
-    ) where
+    )
+where
 
 import Atelier.Effects.Env (Env)
 import Atelier.Effects.FileSystem (FileSystem, doesFileExist, listDirectory, readFileBs)
@@ -108,7 +109,13 @@
         Left _ -> []
         Right fields -> filter (notElem '*') $ concatMap fromField fields
   where
-    fromField (Field (Name _ name) fieldLines)
-        | name == "packages" = concatMap fromLine fieldLines
-    fromField _ = []
-    fromLine (FieldLine _ bs) = map BC.unpack (BC.words bs)
+    fromField = \case
+        (Field (Name _ name) fieldLines)
+            | name == "packages" -> concatMap fromLine fieldLines
+            | otherwise -> []
+        _ -> []
+    fromLine (FieldLine _ bs) =
+        fmap BC.unpack $ filter (not . BC.null) $ BC.words $ BC.map dropComma bs
+
+    dropComma ',' = ' '
+    dropComma c = c
diff --git a/src/Tricorder/Session/Command.hs b/src/Tricorder/Session/Command.hs
--- a/src/Tricorder/Session/Command.hs
+++ b/src/Tricorder/Session/Command.hs
@@ -3,7 +3,8 @@
     , Repl (..)
     , render
     , resolveCommand
-    ) where
+    )
+where
 
 import Atelier.Effects.FileSystem (FileSystem)
 import Data.Default (Default (..))
@@ -59,7 +60,8 @@
 -- The @testTargets@ are the discovered @test:@ components; they are appended to
 -- the auto-detected @all@ target (see 'detectCommand'). They are ignored when
 -- the user has pinned an explicit @command@ or explicit @targets@ in config.
-resolveCommand :: (FileSystem :> es) => ProjectRoot -> Config -> [Target] -> [TestTarget] -> Eff es Command
+resolveCommand
+    :: (FileSystem :> es) => ProjectRoot -> Config -> [Target] -> [TestTarget] -> Eff es Command
 resolveCommand projectRoot@(ProjectRoot root) cfg targets testTargets =
     case cfg.command of
         Just cmd -> case words cmd of
@@ -73,14 +75,16 @@
     detectStackKind args = do
         hasCabalFileInRoot <- any (".cabal" `List.isSuffixOf`) <$> FileSystem.listDirectory root
         let repl =
-                if hasCabalFileInRoot then
-                    Stack
-                else
-                    StackMulti
+                if hasCabalFileInRoot
+                    then
+                        Stack
+                    else
+                        StackMulti
         pure $ Command repl args []
 
 
-detectCommand :: (FileSystem :> es) => [Target] -> [TestTarget] -> FilePath -> ProjectRoot -> Eff es Command
+detectCommand
+    :: (FileSystem :> es) => [Target] -> [TestTarget] -> FilePath -> ProjectRoot -> Eff es Command
 detectCommand targets testTargets replBuildDir projectRoot = do
     cmd <-
         fmap (fromMaybe (fallback replBuildDir) . rightToMaybe)
@@ -90,35 +94,38 @@
     pure
         $ cmd
             { targets =
-                if not (null targets) then
-                    targets
-                else
-                    Bare "all" : (getTestTarget <$> testTargets)
+                if not (null targets)
+                    then
+                        targets
+                    else
+                        Bare "all" : (getTestTarget <$> testTargets)
             }
 
 
 useStack :: (FileSystem :> es, NonDet :> es) => ProjectRoot -> Eff es Command
 useStack (ProjectRoot projectRoot) = do
     hasStack <- FileSystem.doesFileExist $ projectRoot </> "stack.yaml"
-    if hasStack then
-        pure $ Command Stack [] []
-    else
-        emptyEff
+    if hasStack
+        then
+            pure $ Command Stack [] []
+        else
+            emptyEff
 
 
 useMultiCabal :: (FileSystem :> es, NonDet :> es) => ProjectRoot -> FilePath -> Eff es Command
 useMultiCabal (ProjectRoot projectRoot) replBuildDir = do
     hasCabalProject <- FileSystem.doesFileExist $ projectRoot </> "cabal.project"
     hasCabalFiles <- any (".cabal" `List.isSuffixOf`) <$> FileSystem.listDirectory projectRoot
-    if hasCabalFiles || hasCabalProject then
-        pure
-            $ Command
-                { repl = Cabal
-                , arguments = ["--enable-multi-repl"] <> buildDirFlag replBuildDir
-                , targets = []
-                }
-    else
-        emptyEff
+    if hasCabalFiles || hasCabalProject
+        then
+            pure
+                $ Command
+                    { repl = Cabal
+                    , arguments = ["--enable-multi-repl"] <> buildDirFlag replBuildDir
+                    , targets = []
+                    }
+        else
+            emptyEff
 
 
 fallback :: FilePath -> Command
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
@@ -5,7 +5,9 @@
 import Data.Aeson (FromJSON (..))
 import Data.Default (Default (..))
 
+import Tricorder.Session.Hooks (Hooks)
 
+
 data Config = Config
     { command :: Maybe Text
     , targets :: [Text]
@@ -15,6 +17,9 @@
     , replBuildDir :: FilePath
     , testTimeout :: Int
     , generateWithHpack :: Bool
+    , testMemoryLimit :: Maybe Text
+    , hooks :: Maybe Hooks
+    , idleTimeoutSeconds :: Int
     }
     deriving stock (Eq, Generic, Show)
     deriving (FromJSON) via WithDefaults (QuietSnake Config)
@@ -31,4 +36,7 @@
             , replBuildDir = "dist-newstyle/tricorder"
             , testTimeout = 10
             , generateWithHpack = True
+            , testMemoryLimit = Nothing
+            , hooks = Nothing
+            , idleTimeoutSeconds = 300
             }
diff --git a/src/Tricorder/Session/Hooks.hs b/src/Tricorder/Session/Hooks.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Session/Hooks.hs
@@ -0,0 +1,61 @@
+module Tricorder.Session.Hooks
+    ( Hooks (..)
+    , Hook (..)
+    , runHook
+    )
+where
+
+import Atelier.Effects.Process (Process)
+import Atelier.Types.QuietSnake (QuietSnake (..))
+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 Tricorder.Runtime (ProjectRoot (..))
+
+
+data Hooks = Hooks
+    { start :: Maybe Hook
+    , reload :: Maybe Hook
+    }
+    deriving stock (Eq, Generic, Show)
+    deriving (FromJSON, ToJSON) via QuietSnake Hooks
+
+
+instance Default Hooks where
+    def =
+        Hooks
+            { start = def
+            , reload = def
+            }
+
+
+data Hook = Hook
+    { before :: Maybe Text
+    , after :: Maybe Text
+    }
+    deriving stock (Eq, Generic, Show)
+    deriving (FromJSON, ToJSON) via QuietSnake Hook
+
+
+instance Default Hook where
+    def =
+        Hook
+            { 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
diff --git a/src/Tricorder/Session/IdleTimeout.hs b/src/Tricorder/Session/IdleTimeout.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/Session/IdleTimeout.hs
@@ -0,0 +1,14 @@
+module Tricorder.Session.IdleTimeout (IdleTimeout (..)) where
+
+import Atelier.Time (AsRawUnit (..), Second)
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Default (Default (..))
+
+
+newtype IdleTimeout = IdleTimeout {getIdleTimeout :: Second}
+    deriving stock (Eq, Generic, Show)
+    deriving (FromJSON, ToJSON) via AsRawUnit Second
+
+
+instance Default IdleTimeout where
+    def = IdleTimeout 300
diff --git a/src/Tricorder/Session/Target.hs b/src/Tricorder/Session/Target.hs
--- a/src/Tricorder/Session/Target.hs
+++ b/src/Tricorder/Session/Target.hs
@@ -8,7 +8,8 @@
     , definesCustomPrelude
     , compareTargets
     , allComponentTargets
-    ) where
+    )
+where
 
 import Data.Aeson (FromJSON (..), FromJSONKey, ToJSON (..), ToJSONKey)
 import Distribution.Types.CondTree (condTreeData)
@@ -60,6 +61,8 @@
 
 
 instance ToJSONKey Target
+
+
 instance FromJSONKey Target
 
 
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
@@ -4,7 +4,8 @@
     , parseTestTargets
     , resolveTestTargets
     , projectTestTargets
-    ) where
+    )
+where
 
 import Data.Aeson (FromJSON (..), FromJSONKey, ToJSON (..), ToJSONKey)
 
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,7 +2,8 @@
     ( WatchDirs (..)
     , resolveWatchDirs
     , sourceDirsForTarget
-    ) where
+    )
+where
 
 import Data.Aeson (FromJSON (..), ToJSON (..))
 import Data.Default (Default (..))
diff --git a/src/Tricorder/Session/WatchExclusionPatterns.hs b/src/Tricorder/Session/WatchExclusionPatterns.hs
--- a/src/Tricorder/Session/WatchExclusionPatterns.hs
+++ b/src/Tricorder/Session/WatchExclusionPatterns.hs
@@ -2,7 +2,8 @@
     ( WatchExclusionPatterns (..)
     , Pattern
     , resolveWatchExclusionPatterns
-    ) where
+    )
+where
 
 import Data.Default (Default (..))
 import Text.Regex.TDFA.ReadRegex (parseRegex)
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
@@ -8,7 +8,8 @@
     , requestShutdown
     , isDaemonRunning
     , isDaemonReady
-    ) where
+    )
+where
 
 import Atelier.Effects.Delay (Delay)
 import Atelier.Effects.File (File, Handle)
@@ -20,6 +21,7 @@
 import Effectful.Reader.Static (Reader, ask)
 import Effectful.State.Static.Shared (evalState, get, modify, put)
 import System.IO.Error (isEOFError)
+import Tricorder.SourceLookup.SourceQuery (SourceQuery)
 import Prelude hiding (force)
 
 import Atelier.Effects.Delay qualified as Delay
@@ -38,7 +40,7 @@
     , Waiters (..)
     )
 import Tricorder.Socket.UnixSocket (UnixSocket, withConnection)
-import Tricorder.SourceLookup (ModuleSourceResult, SourceQuery)
+import Tricorder.SourceLookup (ModuleSourceResult)
 
 import Tricorder.Version qualified as Version
 
@@ -144,10 +146,11 @@
 requestShutdown force sockPath = withConnection sockPath \h -> do
     sendQuery h $ Quit waiters
     line <- File.hGetLine h
-    if eitherDecode (BSL.fromStrict (encodeUtf8 line)) == Right True then
-        pure $ Right ()
-    else
-        pure $ Left "Failed to request shutdown"
+    if eitherDecode (BSL.fromStrict (encodeUtf8 line)) == Right True
+        then
+            pure $ Right ()
+        else
+            pure $ Left "Failed to request shutdown"
   where
     waiters = case force of
         Force -> IgnoreWaiters
diff --git a/src/Tricorder/Socket/Protocol.hs b/src/Tricorder/Socket/Protocol.hs
--- a/src/Tricorder/Socket/Protocol.hs
+++ b/src/Tricorder/Socket/Protocol.hs
@@ -6,14 +6,12 @@
     , ErrorResponse (..)
     , ClientMessage (..)
     , Waiters (..)
-    ) where
+    )
+where
 
 import Data.Aeson (FromJSON, ToJSON)
-
-import Tricorder.SourceLookup (SourceQuery)
-
-
-data Force = Force | NoForce
+import Tricorder.CLI.Command (Force (..))
+import Tricorder.SourceLookup.SourceQuery (SourceQuery)
 
 
 data StatusQuery = StatusQuery {awaitDone :: Bool}
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
@@ -12,6 +12,7 @@
 import Effectful.Reader.Static (Reader, ask)
 import Effectful.State.Static.Shared (State)
 import System.IO (Handle)
+import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery)
 
 import Atelier.Effects.Conc qualified as Conc
 import Atelier.Effects.Log qualified as Log
@@ -21,8 +22,9 @@
 
 import Tricorder.Build (BuildId, BuildPhase, BuildState (..), Diagnostic)
 import Tricorder.Daemon.DaemonInfo (DaemonInfo)
-import Tricorder.Module (ModuleName, PackageId)
+import Tricorder.Daemon.IdleTimer (IdleTimer)
 import Tricorder.Runtime (SocketPath (..))
+import Tricorder.Session.Command (Repl)
 import Tricorder.Socket.Protocol
     ( ClientMessage (..)
     , DiagnosticQuery (..)
@@ -39,9 +41,10 @@
     , removeSocketFile
     , sendLine
     )
-import Tricorder.SourceLookup (ModuleSourceResult, SourceQuery (..), lookupModuleSource)
+import Tricorder.SourceLookup (ModuleSourceResult, lookupModuleSource)
 import Tricorder.SourceLookup.GhcPkg (GhcPkg)
 import Tricorder.SourceLookup.Hackage (Hackage)
+import Tricorder.SourceLookup.PackageId (PackageId)
 import Tricorder.SourceLookup.PackageStore (PackageStore)
 import Tricorder.Version (VersionMismatch (..), checkVersion)
 import Tricorder.Waiters (Waiters)
@@ -49,6 +52,7 @@
 import Tricorder.Build qualified as Build
 import Tricorder.Build.EvalComment qualified as Eval
 import Tricorder.Build.Test qualified as Test
+import Tricorder.Daemon.IdleTimer qualified as IdleTimer
 import Tricorder.Socket.Protocol qualified as Protocol
 import Tricorder.Waiters qualified as Waiters
 
@@ -65,8 +69,10 @@
        , FileSystem :> es
        , GhcPkg :> es
        , Hackage :> es
+       , IdleTimer :> es
        , Input BuildId :> es
        , Input DaemonInfo :> es
+       , Input Repl :> es
        , Log :> es
        , PackageStore :> es
        , Reader SocketPath :> es
@@ -91,8 +97,10 @@
        , FileSystem :> es
        , GhcPkg :> es
        , Hackage :> es
+       , IdleTimer :> es
        , Input BuildId :> es
        , Input DaemonInfo :> es
+       , Input Repl :> es
        , Log :> es
        , PackageStore :> es
        , Reader SocketPath :> es
@@ -119,8 +127,10 @@
        , FileSystem :> es
        , GhcPkg :> es
        , Hackage :> es
+       , IdleTimer :> es
        , Input BuildId :> es
        , Input DaemonInfo :> es
+       , Input Repl :> es
        , Log :> es
        , PackageStore :> es
        , State BuildPhase :> es
@@ -130,7 +140,7 @@
        )
     => Handle
     -> Eff es ()
-handleConnection h = do
+handleConnection h = IdleTimer.withActivity do
     line <- readLine h
     case decode (BSL.fromStrict (encodeUtf8 line)) of
         Nothing -> sendJson h (ErrorResponse "invalid request")
@@ -153,6 +163,7 @@
        , Hackage :> es
        , Input BuildId :> es
        , Input DaemonInfo :> es
+       , Input Repl :> es
        , Log :> es
        , PackageStore :> es
        , State BuildPhase :> es
@@ -270,6 +281,7 @@
        , FileSystem :> es
        , GhcPkg :> es
        , Hackage :> es
+       , Input Repl :> es
        , Log :> es
        , PackageStore :> es
        , UnixSocket :> es
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
@@ -14,7 +14,8 @@
     , runUnixSocketIO
     , runUnixSocketScripted
     , SocketScript (..)
-    ) where
+    )
+where
 
 import Atelier.Effects.File (BufferMode (..), File, Handle)
 import Atelier.Exception (trySyncIO)
@@ -124,7 +125,8 @@
 -- 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 :: (File :> es, IOE :> es) => [SocketScript] -> Eff (UnixSocket : es) a -> Eff es a
+runUnixSocketScripted
+    :: (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
diff --git a/src/Tricorder/SourceLookup.hs b/src/Tricorder/SourceLookup.hs
--- a/src/Tricorder/SourceLookup.hs
+++ b/src/Tricorder/SourceLookup.hs
@@ -1,22 +1,23 @@
 module Tricorder.SourceLookup
-    ( -- * Types
-      SourceQuery (..)
-    , ModuleSourceResult (..)
-
-      -- * Lookup
+    ( ModuleSourceResult (..)
     , lookupModuleSource
-    ) where
+    )
+where
 
 import Atelier.Effects.Cache (Cache, cacheInsert, cacheLookup)
 import Atelier.Effects.FileSystem (FileSystem)
+import Atelier.Effects.Input (Input, input)
 import Atelier.Effects.Log (Log)
 import Data.Aeson (FromJSON, ToJSON)
+import GHC.Generics (Generically (..))
+import Tricorder.SourceLookup.SourceQuery (ModuleName (..), SourceQuery (..))
 
 import Atelier.Effects.Log qualified as Log
 
-import Tricorder.Module (ModuleName (..), PackageId (..))
+import Tricorder.Session.Command (Repl)
 import Tricorder.SourceLookup.GhcPkg (GhcPkg)
 import Tricorder.SourceLookup.Hackage (Hackage)
+import Tricorder.SourceLookup.PackageId (PackageId (..))
 import Tricorder.SourceLookup.PackageStore (PackageStore)
 import Tricorder.SourceLookup.Slice (sliceSymbol)
 import Tricorder.SourceLookup.Tarball
@@ -40,19 +41,7 @@
     | -- | The module source was found but the requested symbol was not in it.
       FunctionNotFound SourceQuery
     deriving stock (Eq, Generic, Show)
-    deriving anyclass (FromJSON, ToJSON)
-
-
--- | A query for module source: optionally scoped to a single top-level symbol.
-data SourceQuery = SourceQuery
-    { moduleName :: ModuleName
-    , function :: Maybe Text
-    -- ^ The symbol to slice: 'Nothing' is the whole module; @'Just' name@ is a
-    -- single top-level declaration — a value binding, or (by initial casing) a
-    -- type, class, or constructor.
-    }
-    deriving stock (Eq, Generic, Show)
-    deriving anyclass (FromJSON, Hashable, ToJSON)
+    deriving (FromJSON, ToJSON) via Generically ModuleSourceResult
 
 
 -- ── Lookup logic ───────────────────────────────────────────────────────────
@@ -71,6 +60,7 @@
        , FileSystem :> es
        , GhcPkg :> es
        , Hackage :> es
+       , Input Repl :> es
        , Log :> es
        , PackageStore :> es
        )
@@ -91,7 +81,11 @@
 
 -- | Resolve a module to its package, consulting the module -> package cache first.
 resolvePackage
-    :: (Cache ModuleName PackageId :> es, GhcPkg :> es, Log :> es)
+    :: ( Cache ModuleName PackageId :> es
+       , GhcPkg :> es
+       , Input Repl :> es
+       , Log :> es
+       )
     => ModuleName
     -> Eff es (Maybe PackageId)
 resolvePackage modName = do
@@ -101,7 +95,8 @@
             Log.debug $ "Source: " <> unModuleName modName <> " → " <> unPackageId p <> " (cached)"
             pure (Just p)
         Nothing -> do
-            result <- GhcPkg.findModule modName
+            repl <- input
+            result <- GhcPkg.findModule repl modName
             Log.debug $ "Source: find-module " <> unModuleName modName <> " → " <> show result
             whenJust result (cacheInsert @ModuleName @PackageId modName)
             pure result
diff --git a/src/Tricorder/SourceLookup/GhcPkg.hs b/src/Tricorder/SourceLookup/GhcPkg.hs
--- a/src/Tricorder/SourceLookup/GhcPkg.hs
+++ b/src/Tricorder/SourceLookup/GhcPkg.hs
@@ -4,21 +4,24 @@
     , runGhcPkgIO
     , runGhcPkgScripted
     , GhcPkgScript (..)
-    ) where
+    )
+where
 
 import Atelier.Effects.Process (Process, readProcessSafe)
 import Effectful (Effect)
 import Effectful.Dispatch.Dynamic (interpret, reinterpret)
 import Effectful.State.Static.Shared (evalState, get, put)
 import Effectful.TH (makeEffect)
+import Tricorder.SourceLookup.SourceQuery (ModuleName (..))
 
 import Data.Text qualified as T
 
-import Tricorder.Module (ModuleName (..), PackageId (..))
+import Tricorder.Session.Command (Repl (..))
+import Tricorder.SourceLookup.PackageId (PackageId (..))
 
 
 data GhcPkg :: Effect where
-    FindModule :: ModuleName -> GhcPkg m (Maybe PackageId)
+    FindModule :: Repl -> ModuleName -> GhcPkg m (Maybe PackageId)
 
 
 makeEffect ''GhcPkg
@@ -26,8 +29,16 @@
 
 runGhcPkgIO :: (Process :> es) => Eff (GhcPkg : es) a -> Eff es a
 runGhcPkgIO = interpret \_ -> \case
-    FindModule modName -> do
-        out <- readProcessSafe "ghc-pkg" ["find-module", "--simple-output", toString (unModuleName modName)]
+    FindModule repl modName -> do
+        let cmd = "ghc-pkg"
+            args = ["find-module", "--simple-output", toString $ unModuleName modName]
+            stack = readProcessSafe "stack" $ ["exec", "--", cmd] <> args
+            direct = readProcessSafe cmd args
+        out <- case repl of
+            Stack -> stack
+            StackMulti -> stack
+            Cabal -> direct
+            Unknown -> direct
         pure $ out >>= fmap PackageId . listToMaybe . filter (not . T.null) . map T.strip . T.lines
 
 
@@ -40,7 +51,7 @@
 -- | Scripted interpreter for testing. Does not require 'IOE'.
 runGhcPkgScripted :: [GhcPkgScript] -> Eff (GhcPkg : es) a -> Eff es a
 runGhcPkgScripted script = reinterpret (evalState script) \_ -> \case
-    FindModule _ ->
+    FindModule _ _ ->
         get >>= \case
             NextFindModule result : rest -> put rest >> pure result
             _ -> error "GhcPkgScripted: expected NextFindModule but queue was empty or mismatched"
diff --git a/src/Tricorder/SourceLookup/Hackage.hs b/src/Tricorder/SourceLookup/Hackage.hs
--- a/src/Tricorder/SourceLookup/Hackage.hs
+++ b/src/Tricorder/SourceLookup/Hackage.hs
@@ -3,7 +3,8 @@
     , Result (..)
     , fetchPackage
     , run
-    ) where
+    )
+where
 
 import Atelier.Effects.Log (Log)
 import Effectful (Effect, IOE)
@@ -30,7 +31,7 @@
 
 import Atelier.Effects.Log qualified as Log
 
-import Tricorder.Module (PackageId, unPackageId)
+import Tricorder.SourceLookup.PackageId (PackageId (..))
 
 
 data Hackage :: Effect where
@@ -79,7 +80,9 @@
                     | statusCode == 404 ->
                         pure NotFound
                     | otherwise -> do
-                        pure $ Failure $ show (responseStatusCode response) <> ": " <> decodeUtf8 (responseStatusMessage response)
+                        pure
+                            $ Failure
+                            $ show (responseStatusCode response) <> ": " <> decodeUtf8 (responseStatusMessage response)
 
 
 packageUrl :: PackageId -> Url 'Https
diff --git a/src/Tricorder/SourceLookup/PackageId.hs b/src/Tricorder/SourceLookup/PackageId.hs
new file mode 100644
--- /dev/null
+++ b/src/Tricorder/SourceLookup/PackageId.hs
@@ -0,0 +1,25 @@
+module Tricorder.SourceLookup.PackageId
+    ( PackageId (..)
+    , splitPackageId
+    )
+where
+
+import Data.Aeson (FromJSON, ToJSON)
+
+import Data.Text qualified as T
+
+
+-- | A @ghc-pkg@ package identifier, e.g. @"containers-0.6.8"@.
+newtype PackageId = PackageId {unPackageId :: Text}
+    deriving stock (Eq, Ord, Show)
+    deriving (FromJSON, Hashable, IsString, ToJSON) via Text
+
+
+-- | Split a 'PackageId' into its package name and version. The version is the
+-- final hyphen-delimited component (versions are dot-, not hyphen-separated),
+-- so @"list-t-1.0.5.7"@ → @("list-t", "1.0.5.7")@.
+splitPackageId :: PackageId -> (Text, Text)
+splitPackageId (PackageId pid) =
+    case reverse (T.splitOn "-" pid) of
+        (ver : nameParts@(_ : _)) -> (T.intercalate "-" (reverse nameParts), ver)
+        _ -> (pid, "")
diff --git a/src/Tricorder/SourceLookup/PackageStore.hs b/src/Tricorder/SourceLookup/PackageStore.hs
--- a/src/Tricorder/SourceLookup/PackageStore.hs
+++ b/src/Tricorder/SourceLookup/PackageStore.hs
@@ -3,7 +3,8 @@
     , add
     , getPath
     , run
-    ) where
+    )
+where
 
 import Atelier.Effects.Env (Env, getEnvironment)
 import Atelier.Effects.FileSystem (FileSystem)
@@ -16,7 +17,7 @@
 import Atelier.Effects.FileSystem qualified as FileSystem
 import Data.Map.Strict qualified as Map
 
-import Tricorder.Module (PackageId, splitPackageId, unPackageId)
+import Tricorder.SourceLookup.PackageId (PackageId, splitPackageId, unPackageId)
 
 
 data PackageStore :: Effect where
@@ -43,10 +44,11 @@
         GetPath packageId -> do
             let path = packagePath packageDir packageId
             exists <- FileSystem.doesPathExist path
-            if exists then
-                pure $ Just path
-            else
-                pure Nothing
+            if exists
+                then
+                    pure $ Just path
+                else
+                    pure Nothing
 
 
 packagePath :: FilePath -> PackageId -> FilePath
@@ -133,7 +135,8 @@
 getDir :: (FileSystem :> es, NonDet :> es) => FilePath -> Eff es FilePath
 getDir fp = do
     exists <- FileSystem.doesDirectoryExist fp
-    if exists then
-        pure fp
-    else
-        emptyEff
+    if exists
+        then
+            pure fp
+        else
+            emptyEff
diff --git a/src/Tricorder/SourceLookup/Slice.hs b/src/Tricorder/SourceLookup/Slice.hs
--- a/src/Tricorder/SourceLookup/Slice.hs
+++ b/src/Tricorder/SourceLookup/Slice.hs
@@ -8,7 +8,8 @@
 -- throws.
 module Tricorder.SourceLookup.Slice
     ( sliceSymbol
-    ) where
+    )
+where
 
 import Data.Char (isAlphaNum, isSpace, isUpper)
 
@@ -27,10 +28,11 @@
     | T.null symbol = Nothing
     | otherwise =
         let ls = T.lines source
-        in  if isTypeSymbol symbol then
-                sliceType symbol ls <|> sliceConstructor symbol ls
-            else
-                sliceValue symbol ls
+        in  if isTypeSymbol symbol
+                then
+                    sliceType symbol ls <|> sliceConstructor symbol ls
+                else
+                    sliceValue symbol ls
 
 
 -- | Evaluate whether the symbol references a type-level entity.
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
@@ -11,13 +11,15 @@
     , cabalPackagesDirs
     , matchesModule
     , extractModule
-    ) where
+    )
+where
 
 import Atelier.Effects.FileSystem (FileSystem, readFileLbs)
 import Atelier.Effects.Log (Log)
 import Data.Char (isUpper)
 import Effectful.Exception (trySync)
 import System.FilePath (splitDirectories, (</>))
+import Tricorder.SourceLookup.SourceQuery (ModuleName (..))
 
 import Atelier.Effects.Log qualified as Log
 import Codec.Archive.Tar qualified as Tar
@@ -26,8 +28,8 @@
 import Data.List qualified as List
 import Data.Text qualified as T
 
-import Tricorder.Module (ModuleName (..), PackageId (..), splitPackageId)
 import Tricorder.SourceLookup.Hackage (Hackage)
+import Tricorder.SourceLookup.PackageId (PackageId (..), splitPackageId)
 import Tricorder.SourceLookup.PackageStore (PackageStore)
 
 import Tricorder.SourceLookup.Hackage qualified as Hackage
diff --git a/src/Tricorder/TestOutput.hs b/src/Tricorder/TestOutput.hs
--- a/src/Tricorder/TestOutput.hs
+++ b/src/Tricorder/TestOutput.hs
@@ -1,10 +1,12 @@
 module Tricorder.TestOutput (parseHspecOutput, parseHspecDuration, stripGhciNoise) where
 
-import Atelier.Time (Millisecond, fromMicroseconds)
+import Atelier.Time (fromMicroseconds)
 import Data.Char (isDigit)
 
 import Data.Text qualified as T
 
+import Tricorder.Build.Duration (Duration (..))
+
 import Tricorder.Build.Test qualified as Test
 
 
@@ -48,20 +50,21 @@
     | otherwise =
         let withoutClose = T.init t
             (timePart, rest) = T.span (\c -> isDigit c || c `elem` (".smμ" :: [Char])) (T.reverse withoutClose)
-        in  if T.null timePart then
-                t
-            else case T.uncons rest of
-                Just ('(', afterParen) ->
-                    case T.uncons afterParen of
-                        Just (' ', desc) -> T.stripEnd (T.reverse desc)
-                        _ -> t
-                _ -> t
+        in  if T.null timePart
+                then
+                    t
+                else case T.uncons rest of
+                    Just ('(', afterParen) ->
+                        case T.uncons afterParen of
+                            Just (' ', desc) -> T.stripEnd (T.reverse desc)
+                            _ -> t
+                    _ -> t
 
 
 -- | Extract the test suite duration from hspec summary output.
 -- Matches non-indented summary lines ending with @"(Xs)"@,
 -- e.g. @"All 160 tests passed (0.33s)"@ or @"1 out of 177 tests failed (0.06s)"@.
-parseHspecDuration :: Text -> Maybe Millisecond
+parseHspecDuration :: Text -> Maybe Duration
 parseHspecDuration output =
     listToMaybe $ mapMaybe extractMs (T.lines output)
   where
@@ -70,7 +73,7 @@
         guard $ T.isSuffixOf "s)" line
         let numStr = T.takeWhileEnd (/= '(') (T.dropEnd 2 line)
         secs <- readMaybe (T.unpack numStr) :: Maybe Double
-        pure $ fromMicroseconds (round (secs * 1_000_000))
+        pure $ Duration $ fromMicroseconds (round (secs * 1_000_000))
 
 
 -- | Strip GHCi/cabal startup and shutdown noise from captured output lines.
diff --git a/src/Tricorder/Version.hs b/src/Tricorder/Version.hs
--- a/src/Tricorder/Version.hs
+++ b/src/Tricorder/Version.hs
@@ -11,12 +11,15 @@
 -- in until something else triggers a rebuild.
 module Tricorder.Version (gitHash, VersionMismatch (..), checkVersion) where
 
+import Data.Version (showVersion)
 import Language.Haskell.TH (litE, runIO, stringL)
 import System.Environment (lookupEnv)
 import System.IO.Error (tryIOError)
 import System.Process (readProcess)
 
+import Paths_tricorder qualified as Pack
 
+
 -- | Short git hash of the commit this binary was built from.
 --
 -- Resolution order at compile time:
@@ -28,19 +31,23 @@
 -- 3. @"unknown"@ — fallback when @git@ is unavailable.
 gitHash :: Text
 gitHash =
-    toText
-        ( $( do
-                hash <- runIO $ do
-                    override <- lookupEnv "TRICORDER_VERSION"
-                    case override of
-                        Just v -> pure v
-                        Nothing ->
-                            either (const "unknown") (filter (/= '\n'))
-                                <$> tryIOError (readProcess "git" ["rev-parse", "--short", "HEAD"] "")
-                litE (stringL hash)
-           )
-            :: String
-        )
+    "v"
+        <> toText (showVersion Pack.version)
+        <> " ("
+        <> toText
+            ( $( do
+                    hash <- runIO $ do
+                        override <- lookupEnv "TRICORDER_VERSION"
+                        case override of
+                            Just v -> pure v
+                            Nothing ->
+                                either (const "unknown") (filter (/= '\n'))
+                                    <$> tryIOError (readProcess "git" ["rev-parse", "--short", "HEAD"] "")
+                    litE (stringL hash)
+               )
+                :: String
+            )
+        <> ")"
 
 
 data VersionMismatch = VersionMismatch
diff --git a/src/Tricorder/Waiters.hs b/src/Tricorder/Waiters.hs
--- a/src/Tricorder/Waiters.hs
+++ b/src/Tricorder/Waiters.hs
@@ -4,7 +4,8 @@
     , without
     , wait
     , run
-    ) where
+    )
+where
 
 import Effectful (Effect)
 import Effectful.Concurrent (Concurrent)
diff --git a/test/Unit/Tricorder/Build/ByteSizeSpec.hs b/test/Unit/Tricorder/Build/ByteSizeSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Unit/Tricorder/Build/ByteSizeSpec.hs
@@ -0,0 +1,73 @@
+module Unit.Tricorder.Build.ByteSizeSpec (spec_ByteSize) where
+
+import Test.Hspec (Spec, describe, it, shouldBe)
+
+import Tricorder.Build.ByteSize (ByteSize (..), Unit (..))
+
+import Tricorder.Build.ByteSize qualified as ByteSize
+
+
+spec_ByteSize :: Spec
+spec_ByteSize = describe "fromText" testFromText
+
+
+testFromText :: Spec
+testFromText = do
+    it "parses no unit as bytes" do
+        ByteSize.fromText "1000" `shouldBe` Just (ByteSize 1000 B)
+
+    it "parses bytes with unit" do
+        ByteSize.fromText "1000B" `shouldBe` Just (ByteSize 1000 B)
+
+    it "parses decimal kilobytes" do
+        ByteSize.fromText "10kb" `shouldBe` Just (ByteSize 10 KB)
+
+    it "parses binary kibibytes" do
+        ByteSize.fromText "10kib" `shouldBe` Just (ByteSize 10 KiB)
+
+    it "parses decimal megabytes" do
+        ByteSize.fromText "1mb" `shouldBe` Just (ByteSize 1 MB)
+
+    it "parses binary mebibytes" do
+        ByteSize.fromText "1mib" `shouldBe` Just (ByteSize 1 MiB)
+
+    it "parses decimal gigabytes" do
+        ByteSize.fromText "1gb" `shouldBe` Just (ByteSize 1 GB)
+
+    it "parses binary gibibytes" do
+        ByteSize.fromText "1gib" `shouldBe` Just (ByteSize 1 GiB)
+
+    it "parses decimal terabytes" do
+        ByteSize.fromText "1tb" `shouldBe` Just (ByteSize 1 TB)
+
+    it "parses binary tebibytes" do
+        ByteSize.fromText "1tib" `shouldBe` Just (ByteSize 1 TiB)
+
+    it "parses decimal petabytes" do
+        ByteSize.fromText "1pb" `shouldBe` Just (ByteSize 1 PB)
+
+    it "parses binary pebibytes" do
+        ByteSize.fromText "1pib" `shouldBe` Just (ByteSize 1 PiB)
+
+    it "allows a space between the number and the unit" do
+        ByteSize.fromText "10 kb" `shouldBe` Just (ByteSize 10 KB)
+
+    it "allows multiple spaces between the number and the unit" do
+        ByteSize.fromText "10   kb" `shouldBe` Just (ByteSize 10 KB)
+
+    it "is case-insensitive on the unit" do
+        ByteSize.fromText "10KB" `shouldBe` Just (ByteSize 10 KB)
+        ByteSize.fromText "10Kb" `shouldBe` Just (ByteSize 10 KB)
+        ByteSize.fromText "10KiB" `shouldBe` Just (ByteSize 10 KiB)
+
+    it "fails when there is no number" do
+        ByteSize.fromText "kb" `shouldBe` Nothing
+
+    it "fails on an unrecognized unit" do
+        ByteSize.fromText "10xb" `shouldBe` Nothing
+
+    it "fails on empty text" do
+        ByteSize.fromText "" `shouldBe` Nothing
+
+    it "fails on unrelated text" do
+        ByteSize.fromText "hello world" `shouldBe` Nothing
diff --git a/test/Unit/Tricorder/Daemon/BuildStateSpec.hs b/test/Unit/Tricorder/Daemon/BuildStateSpec.hs
--- a/test/Unit/Tricorder/Daemon/BuildStateSpec.hs
+++ b/test/Unit/Tricorder/Daemon/BuildStateSpec.hs
@@ -12,6 +12,7 @@
     , PostBuild (..)
     , Severity (..)
     )
+import Tricorder.Build.Duration (Duration (..))
 import Tricorder.Daemon.DaemonInfo (DaemonInfo (..))
 
 import Tricorder.Build qualified as Build
@@ -31,7 +32,8 @@
                         , endLine = 2
                         , endCol = 8
                         , title = "Found \8216qualified\8217 in prepositive position"
-                        , text = "Found \8216qualified\8217 in prepositive position\n    Suggested fixes:\n      \8226 Place \8216qualified\8217 after the module name."
+                        , text =
+                            "Found \8216qualified\8217 in prepositive position\n    Suggested fixes:\n      \8226 Place \8216qualified\8217 after the module name."
                         }
                 bs = mkBuildState [msg]
             eitherDecode (encode bs) `shouldBe` Right bs
@@ -89,7 +91,7 @@
             Build.Finished
                 ( BuildResult
                     { completedAt = epoch
-                    , duration = 0
+                    , duration = Duration 0
                     , moduleCount = 0
                     , diagnostics = msgs
                     }
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
@@ -7,6 +7,7 @@
 import Data.Set qualified as Set
 
 import Tricorder.Build (BuildResult (..), Diagnostic (..), Severity (..))
+import Tricorder.Build.Duration (Duration (..))
 import Tricorder.Daemon.Builder (NewLoadResult (..), compileBuildResults)
 import Tricorder.Daemon.GhciSession.GhciParser
     ( LoadResult (..)
@@ -49,7 +50,7 @@
                                 , diagnostics = []
                                 }
                         }
-        r.duration `shouldBe` 10_000
+        r.duration `shouldBe` Duration 10_000
     it "merges with existing results" do
         let (m, _) =
                 compileBuildResults root watchDirs (Map.fromList [(errMsg.file, [errMsg])])
@@ -89,7 +90,7 @@
             expected =
                 BuildResult
                     { completedAt = addUTCTime 10 epoch
-                    , duration = 10_000
+                    , duration = Duration 10_000
                     , moduleCount = 2
                     , diagnostics = [warnMsg]
                     }
diff --git a/test/Unit/Tricorder/Daemon/GhciSession/GhciParserSpec.hs b/test/Unit/Tricorder/Daemon/GhciSession/GhciParserSpec.hs
--- a/test/Unit/Tricorder/Daemon/GhciSession/GhciParserSpec.hs
+++ b/test/Unit/Tricorder/Daemon/GhciSession/GhciParserSpec.hs
@@ -55,8 +55,20 @@
                 , "Ok, 3 modules loaded."
                 ]
         parseReload input
-            `shouldBe` [ 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"}
+            `shouldBe` [ 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
                        ]
@@ -84,7 +96,15 @@
     it "parses a single-line error" do
         let input = ["src/Foo.hs:10:5: error: Variable not in scope: foo"]
         parseReload input
-            `shouldBe` [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"]}]
+            `shouldBe` [ 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"]
+                                }
+                       ]
 
     it "parses a warning with continuation lines" do
         let input =
@@ -110,17 +130,41 @@
     it "parses a span position (L:C-C2:)" do
         let input = ["src/Baz.hs:5:1-10: error: Parse error"]
         parseReload input
-            `shouldBe` [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"]}]
+            `shouldBe` [ 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"]
+                                }
+                       ]
 
     it "parses a span position ((L1,C1)-(L2,C2):)" do
         let input = ["src/Qux.hs:(3,1)-(5,20): error: Multi-line error"]
         parseReload input
-            `shouldBe` [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"]}]
+            `shouldBe` [ 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"]
+                                }
+                       ]
 
     it "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
-            `shouldBe` [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"]}]
+            `shouldBe` [ 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"]
+                                }
+                       ]
 
     it "parses source-display continuation lines (pipe format)" do
         let input =
@@ -150,12 +194,28 @@
     it "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]
-            `shouldBe` [GMessage GhciMessage {severity = GError, file = "src/Foo.hs", startPos = Position 10 5, endPos = Position 10 5, messageLines = [ansiHeader]}]
+            `shouldBe` [ GMessage
+                            GhciMessage
+                                { severity = GError
+                                , file = "src/Foo.hs"
+                                , startPos = Position 10 5
+                                , endPos = Position 10 5
+                                , messageLines = [ansiHeader]
+                                }
+                       ]
 
     it "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
-            `shouldBe` [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"]}]
+            `shouldBe` [ 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"]
+                                }
+                       ]
 
     it "parses mixed Loading, Message, and summary items" do
         let input =
@@ -166,7 +226,14 @@
                 ]
         parseReload input
             `shouldBe` [ 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"]}
+                       , 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
                        ]
@@ -231,7 +298,15 @@
 
     it "handles <no location info>: error: with no continuation" do
         parseReload ["<no location info>: error: some error"]
-            `shouldBe` [GMessage GhciMessage {severity = GError, file = "<no location info>", startPos = Position 0 0, endPos = Position 0 0, messageLines = ["<no location info>: error: some error"]}]
+            `shouldBe` [ GMessage
+                            GhciMessage
+                                { severity = GError
+                                , file = "<no location info>"
+                                , startPos = Position 0 0
+                                , endPos = Position 0 0
+                                , messageLines = ["<no location info>: error: some error"]
+                                }
+                       ]
 
 
 --------------------------------------------------------------------------------
diff --git a/test/Unit/Tricorder/Daemon/GhciSessionSpec.hs b/test/Unit/Tricorder/Daemon/GhciSessionSpec.hs
--- a/test/Unit/Tricorder/Daemon/GhciSessionSpec.hs
+++ b/test/Unit/Tricorder/Daemon/GhciSessionSpec.hs
@@ -128,7 +128,15 @@
 
 -- | 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}
+simpleResult msgs =
+    Right
+        LoadResult
+            { moduleCount = 0
+            , compiledFiles = Set.empty
+            , loadedModules = Map.empty
+            , targetNames = []
+            , diagnostics = msgs
+            }
 
 
 runScripted
diff --git a/test/Unit/Tricorder/Daemon/TestRunnerSpec.hs b/test/Unit/Tricorder/Daemon/TestRunnerSpec.hs
--- a/test/Unit/Tricorder/Daemon/TestRunnerSpec.hs
+++ b/test/Unit/Tricorder/Daemon/TestRunnerSpec.hs
@@ -104,14 +104,14 @@
     it "returns scripted TestRun" do
         result <-
             runScripted [Right passingRun]
-                $ runTestSuite noProgress Cabal testTimeout
+                $ runTestSuite noProgress Nothing Cabal testTimeout
                 $ mkTestTarget "test:foo"
         result `shouldBe` passingRun
 
     it "ignores the target name argument" do
         result <-
             runScripted [Right failingRun]
-                $ runTestSuite noProgress Cabal testTimeout
+                $ runTestSuite noProgress Nothing Cabal testTimeout
                 $ mkTestTarget "test:anything"
         result `shouldBe` failingRun
 
@@ -119,23 +119,28 @@
         result <-
             runScripted [Left (toException boom)]
                 $ try @ErrorCall
-                $ runTestSuite noProgress Cabal testTimeout
+                $ runTestSuite noProgress Nothing Cabal testTimeout
                 $ mkTestTarget "test:foo"
         result `shouldBe` Left boom
 
     describe "sequencing" do
         it "consumes results in order across multiple calls" do
             (a, b) <- runScripted [Right passingRun, Right failingRun] do
-                a <- runTestSuite noProgress Cabal testTimeout $ mkTestTarget "test:foo"
-                b <- runTestSuite noProgress Cabal testTimeout $ mkTestTarget "test:bar"
+                a <- runTestSuite noProgress Nothing Cabal testTimeout $ mkTestTarget "test:foo"
+                b <- runTestSuite noProgress Nothing Cabal testTimeout $ mkTestTarget "test:bar"
                 pure (a, b)
             a `shouldBe` passingRun
             b `shouldBe` failingRun
 
         it "recover scenario: error then success" do
             result <- runScripted [Left (toException boom), Right passingRun] do
-                r1 <- try @ErrorCall $ runTestSuite noProgress Cabal testTimeout $ mkTestTarget "test:foo"
-                r2 <- runTestSuite noProgress Cabal testTimeout $ mkTestTarget "test:bar"
+                r1 <-
+                    try @ErrorCall
+                        $ runTestSuite noProgress Nothing Cabal testTimeout
+                        $ mkTestTarget "test:foo"
+                r2 <-
+                    runTestSuite noProgress Nothing Cabal testTimeout
+                        $ mkTestTarget "test:bar"
                 pure (r1, r2)
             fst result `shouldBe` Left boom
             snd result `shouldBe` passingRun
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
@@ -5,13 +5,13 @@
 import Effectful (runPureEff)
 import Effectful.Reader.Static (runReader)
 import Effectful.State.Static.Shared (evalState)
-import Test.Hspec (Spec, describe, it, shouldBe)
+import Test.Hspec (Spec, describe, it, shouldBe, shouldMatchList)
 
 import Data.Map.Strict qualified as Map
 
 import Tricorder.Runtime (ProjectRoot (..))
 import Tricorder.Session.CabalFile (discoverCabalFiles)
-import Unit.Tricorder.Session.Helpers (cabalFixture, libTestCabal, multiPackageFs)
+import Unit.Tricorder.Session.Helpers (cabalFixture, multiPackageCabalFs, multiPackageFs)
 
 
 spec_CabalFile :: Spec
@@ -49,7 +49,7 @@
                         [ ("/cabal.project.local", "packages: pkg-a\n")
                         , ("/cabal.project", "packages: pkg-b\n")
                         ]
-                        `Map.union` multiPackageCabalFiles
+                        `Map.union` multiPackageCabalFs
                 actual = runDiscovery fs [] discoverCabalFiles
             actual `shouldBe` ["/pkg-a/pkg-a.cabal"]
 
@@ -59,7 +59,7 @@
                         [ ("/cabal.project.freeze", "packages: pkg-a\n")
                         , ("/cabal.project", "packages: pkg-b\n")
                         ]
-                        `Map.union` multiPackageCabalFiles
+                        `Map.union` multiPackageCabalFs
                 actual = runDiscovery fs [] discoverCabalFiles
             actual `shouldBe` ["/pkg-a/pkg-a.cabal"]
 
@@ -70,7 +70,7 @@
                             [ ("/cabal.project.local", "tests: True\n")
                             , ("/cabal.project", "packages: pkg-b\n")
                             ]
-                            `Map.union` multiPackageCabalFiles
+                            `Map.union` multiPackageCabalFs
                     actual = runDiscovery fs [] discoverCabalFiles
                 actual `shouldBe` ["/pkg-b/pkg-b.cabal"]
 
@@ -90,7 +90,7 @@
             it "uses $HOME/.cabal/config as a last-resort packages source" do
                 let fs =
                         Map.singleton "/home/user/.cabal/config" "packages: pkg-a\n"
-                            `Map.union` multiPackageCabalFiles
+                            `Map.union` multiPackageCabalFs
                     actual = runDiscovery fs [("HOME", "/home/user")] discoverCabalFiles
                 actual `shouldBe` ["/pkg-a/pkg-a.cabal"]
 
@@ -103,13 +103,17 @@
                             ]
                     actual = runDiscovery fs [("HOME", "/home/user")] discoverCabalFiles
                 actual `shouldBe` ["/myapp.cabal"]
+
+    describe "packages: single-line list" do
+        describe "when package list is comma-separated" do
+            it "parses package names correctly" do
+                let fs =
+                        Map.singleton "/cabal.project" "packages: pkg-a, pkg-b\n"
+                            `Map.union` multiPackageCabalFs
+                    actual = runDiscovery fs [] discoverCabalFiles
+                actual `shouldMatchList` ["/pkg-a/pkg-a.cabal", "/pkg-b/pkg-b.cabal"]
   where
     pr = ProjectRoot "/"
-    multiPackageCabalFiles =
-        Map.fromList
-            [ ("/pkg-a/pkg-a.cabal", libTestCabal "pkg-a")
-            , ("/pkg-b/pkg-b.cabal", libTestCabal "pkg-b")
-            ]
     runDiscovery fs env =
         runPureEff
             . runEnvConst env
diff --git a/test/Unit/Tricorder/Session/Helpers.hs b/test/Unit/Tricorder/Session/Helpers.hs
--- a/test/Unit/Tricorder/Session/Helpers.hs
+++ b/test/Unit/Tricorder/Session/Helpers.hs
@@ -2,13 +2,15 @@
     ( multiCabalFiles
     , singleCabalFile
     , multiPackageFs
+    , multiPackageCabalFs
     , preludeOnlyLibCabal
     , libWithPreludeCabal
     , libTestCabal
     , gpdFixture
     , cabalFixture
     , gpd
-    ) where
+    )
+where
 
 import Distribution.PackageDescription (GenericPackageDescription)
 import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe)
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
@@ -3,8 +3,8 @@
 import Atelier.Config (LoadedConfig (..))
 import Atelier.Effects.FileSystem (runFileSystemState)
 import Atelier.Effects.Input (runInputConst)
-import Atelier.Effects.Log (Message (..), Severity (..), runLogWriter)
-import Data.Aeson (Value (Null))
+import Atelier.Effects.Log (Message (..), Severity (..), runLogNoOp, runLogWriter)
+import Data.Aeson (Value (Null), object, (.=))
 import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe)
 import Effectful (runPureEff)
 import Effectful.Reader.Static (runReader)
@@ -13,14 +13,16 @@
 import Test.Hspec
 
 import Tricorder.Runtime (ProjectRoot (..))
-import Tricorder.Session (loadSession)
+import Tricorder.Session (Session (..), loadSession)
 import Tricorder.Session.CabalFile (CabalFile (..))
+import Tricorder.Session.IdleTimeout (IdleTimeout (..))
 import Unit.Tricorder.Session.Helpers (libWithPreludeCabal, preludeOnlyLibCabal)
 
 
 spec_Session :: Spec
 spec_Session = do
     describe "loadSession" testLoadSession
+    describe "loadSession idleTimeout" testIdleTimeout
 
 
 testLoadSession :: Spec
@@ -56,4 +58,24 @@
             . runInputConst cabalFiles
             . runReader (ProjectRoot "/")
             . runInputConst (LoadedConfig Null)
+            $ loadSession
+
+
+testIdleTimeout :: Spec
+testIdleTimeout = do
+    it "defaults to 300 seconds when unset" do
+        (loadSessionWith (LoadedConfig Null)).idleTimeout `shouldBe` IdleTimeout 300
+
+    it "reads idle_timeout_seconds from the session config" do
+        let cfg = LoadedConfig $ object ["session" .= object ["idle_timeout_seconds" .= (5 :: Int)]]
+        (loadSessionWith cfg).idleTimeout `shouldBe` IdleTimeout 5
+  where
+    loadSessionWith cfg =
+        runPureEff
+            . runLogNoOp
+            . evalState @(Map FilePath ByteString) mempty
+            . runFileSystemState
+            . runInputConst ([] :: [CabalFile])
+            . runReader (ProjectRoot "/")
+            . runInputConst cfg
             $ loadSession
diff --git a/test/Unit/Tricorder/SourceLookup/GhcPkgSpec.hs b/test/Unit/Tricorder/SourceLookup/GhcPkgSpec.hs
--- a/test/Unit/Tricorder/SourceLookup/GhcPkgSpec.hs
+++ b/test/Unit/Tricorder/SourceLookup/GhcPkgSpec.hs
@@ -3,6 +3,7 @@
 import Effectful (runPureEff)
 import Test.Hspec
 
+import Tricorder.Session.Command (Repl (..))
 import Tricorder.SourceLookup.GhcPkg (GhcPkg, GhcPkgScript (..), findModule, runGhcPkgScripted)
 
 
@@ -14,15 +15,17 @@
 testFindModule :: Spec
 testFindModule = do
     it "returns Just pkgId when module is known" do
-        let result = runScripted [NextFindModule (Just "base-4.18")] $ findModule "Prelude"
+        let result = runScripted [NextFindModule (Just "base-4.18")] $ findModule Cabal "Prelude"
         result `shouldBe` Just "base-4.18"
 
     it "returns Nothing for an unknown module" do
-        let result = runScripted [NextFindModule Nothing] $ findModule "No.Such.Module"
+        let result = runScripted [NextFindModule Nothing] $ findModule Cabal "No.Such.Module"
         result `shouldBe` Nothing
 
     it "returns the first scripted result" do
-        let result = runScripted [NextFindModule (Just "pkg-1.0"), NextFindModule (Just "pkg-2.0")] $ findModule "Foo"
+        let result =
+                runScripted [NextFindModule (Just "pkg-1.0"), NextFindModule (Just "pkg-2.0")]
+                    $ findModule Cabal "Foo"
         result `shouldBe` Just "pkg-1.0"
 
 
diff --git a/test/Unit/Tricorder/SourceLookup/TarballSpec.hs b/test/Unit/Tricorder/SourceLookup/TarballSpec.hs
--- a/test/Unit/Tricorder/SourceLookup/TarballSpec.hs
+++ b/test/Unit/Tricorder/SourceLookup/TarballSpec.hs
@@ -8,7 +8,7 @@
 import Codec.Compression.GZip qualified as GZip
 import Data.ByteString.Lazy qualified as BSL
 
-import Tricorder.Module (splitPackageId)
+import Tricorder.SourceLookup.PackageId (splitPackageId)
 import Tricorder.SourceLookup.Tarball
     ( cabalPackagesDirs
     , extractModule
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
@@ -3,6 +3,7 @@
 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.Log (Log, runLogNoOp)
 import Effectful (IOE, runEff)
 import Effectful.Concurrent (Concurrent, runConcurrent)
@@ -10,6 +11,7 @@
 import Effectful.State.Static.Shared (State, evalState, gets, modify)
 import System.FilePath ((</>))
 import Test.Hspec
+import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery (..))
 
 import Codec.Archive.Tar qualified as Tar
 import Codec.Archive.Tar.Entry qualified as Tar
@@ -20,14 +22,14 @@
 import Data.Map.Strict qualified as Map
 import Data.Text qualified as T
 
-import Tricorder.Module (ModuleName, PackageId)
+import Tricorder.Session.Command (Repl (..))
 import Tricorder.SourceLookup
     ( ModuleSourceResult (..)
-    , SourceQuery (..)
     , lookupModuleSource
     )
 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
@@ -64,7 +66,10 @@
 
     it "fetches from Hackage on a cache miss, then reads the now-fetched tarball" do
         result <-
-            runTest [NextFindModule (Just "aeson-2.2.5.0")] Map.empty (pure (Success (BSL.toStrict tarballBytes)))
+            runTest
+                [NextFindModule (Just "aeson-2.2.5.0")]
+                Map.empty
+                (pure (Success (BSL.toStrict tarballBytes)))
                 $ lookupModuleSource (wholeModule "Data.Aeson")
         result `shouldBe` SourceFound (wholeModule "Data.Aeson") moduleSource
 
@@ -204,8 +209,18 @@
 runTest
     :: [GhcPkgScript]
     -> Map FilePath LByteString
-    -> Eff '[PackageStore, Env, FileSystem, State (Map FilePath LByteString), Log, Concurrent, IOE] Result
     -> Eff
+        '[ PackageStore
+         , Env
+         , FileSystem
+         , State (Map FilePath LByteString)
+         , Input Repl
+         , Log
+         , Concurrent
+         , IOE
+         ]
+        Result
+    -> Eff
         '[ Cache ModuleName PackageId
          , Cache (PackageId, SourceQuery) ModuleSourceResult
          , GhcPkg
@@ -214,6 +229,7 @@
          , Env
          , FileSystem
          , State (Map FilePath LByteString)
+         , Input Repl
          , Log
          , Concurrent
          , IOE
@@ -224,6 +240,7 @@
     runEff
         . runConcurrent
         . runLogNoOp
+        . runInputConst Cabal
         . evalState initialFs
         . runFileSystemFake
         . runEnvConst [("HOME", "/h")]
diff --git a/test/Unit/Tricorder/TestOutputSpec.hs b/test/Unit/Tricorder/TestOutputSpec.hs
--- a/test/Unit/Tricorder/TestOutputSpec.hs
+++ b/test/Unit/Tricorder/TestOutputSpec.hs
@@ -2,6 +2,7 @@
 
 import Test.Hspec
 
+import Tricorder.Build.Duration (Duration (..))
 import Tricorder.TestOutput (parseHspecDuration, parseHspecOutput, stripGhciNoise)
 
 import Tricorder.Build.Test qualified as Test
@@ -98,11 +99,11 @@
 
         it "parses duration from passing summary line" do
             parseHspecDuration "All 177 tests passed (0.05s)\n"
-                `shouldBe` Just 50
+                `shouldBe` Just (Duration 50)
 
         it "parses duration from failing summary line" do
             parseHspecDuration "1 out of 177 tests failed (0.06s)\n"
-                `shouldBe` Just 60
+                `shouldBe` Just (Duration 60)
 
         it "does not match indented individual test timing lines" do
             parseHspecDuration "      entry is evicted after cleanup thread fires past TTL:  OK (0.05s)\n"
@@ -115,7 +116,7 @@
                         <> "    slow test:                                       OK (0.05s)\n"
                         <> "\n"
                         <> "All 2 tests passed (0.5s)\n"
-            parseHspecDuration output `shouldBe` Just 500
+            parseHspecDuration output `shouldBe` Just (Duration 500)
 
     describe "stripGhciNoise" do
         it "passes through empty list" do
diff --git a/tricorder.cabal b/tricorder.cabal
--- a/tricorder.cabal
+++ b/tricorder.cabal
@@ -5,7 +5,7 @@
 -- see: https://github.com/sol/hpack
 
 name:            tricorder
-version:         0.2.1.0
+version:         0.2.2.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
@@ -27,7 +27,9 @@
 library tricorder-internal
   exposed-modules:
       Tricorder.Build
+      Tricorder.Build.ByteSize
       Tricorder.Build.Changes
+      Tricorder.Build.Duration
       Tricorder.Build.EvalComment
       Tricorder.Build.Test
       Tricorder.CLI.App
@@ -56,17 +58,19 @@
       Tricorder.Daemon.GhciSession.GhciProcess
       Tricorder.Daemon.Hpack
       Tricorder.Daemon.Hpack.Effect
+      Tricorder.Daemon.IdleTimer
       Tricorder.Daemon.Main
       Tricorder.Daemon.TestRunner
       Tricorder.Daemon.Watch
       Tricorder.Logging
-      Tricorder.Module
       Tricorder.Runtime
       Tricorder.Session
       Tricorder.Session.CabalFile
       Tricorder.Session.Command
       Tricorder.Session.Config
       Tricorder.Session.GenerateWithHpack
+      Tricorder.Session.Hooks
+      Tricorder.Session.IdleTimeout
       Tricorder.Session.ReplBuildDir
       Tricorder.Session.Target
       Tricorder.Session.TestTarget
@@ -80,6 +84,7 @@
       Tricorder.SourceLookup
       Tricorder.SourceLookup.GhcPkg
       Tricorder.SourceLookup.Hackage
+      Tricorder.SourceLookup.PackageId
       Tricorder.SourceLookup.PackageStore
       Tricorder.SourceLookup.Slice
       Tricorder.SourceLookup.Tarball
@@ -143,6 +148,7 @@
     , text ==2.1.*
     , time >=1.12 && <1.17
     , time-units ==1.0.*
+    , tricorder-types ==0.1.*
     , vty >=6.5 && <6.7
     , vty-crossplatform ==0.5.*
     , yaml ==0.11.*
@@ -233,6 +239,7 @@
   type: exitcode-stdio-1.0
   main-is: Driver.hs
   other-modules:
+      Unit.Tricorder.Build.ByteSizeSpec
       Unit.Tricorder.Build.EvalCommentSpec
       Unit.Tricorder.CLI.RenderSpec
       Unit.Tricorder.Daemon.BuilderSpec
@@ -308,6 +315,7 @@
     , time >=1.12 && <1.17
     , time-units ==1.0.*
     , tricorder-internal
+    , tricorder-types ==0.1.*
     , typed-process ==0.2.*
     , unagi-chan ==0.4.*
     , unix ==2.8.*
