diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,33 @@
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
 and this project adheres to the [PVP](https://pvp.haskell.org/).
 
-## [Unreleased]
+## [0.2.0.0] - 2026-06-26
+
+### Added
+
+- `Atelier.Effects.File` now re-exports `IOMode (..)`.
+- `Atelier.Effects.Process`: `withProcessGroup` runs a process in its own
+  process group and guarantees the whole group is torn down when the body
+  returns or throws; `terminateProcessGroup` aborts a running group from
+  another thread (for children that trap `SIGINT`, where interrupting the
+  group is not enough).
+- New module `Atelier.Effects.Process.Internal`, exposing the `RunningProcess`
+  constructor for tests that fabricate a handle. Production code should not
+  import it.
+
+### Changed
+
+- **Breaking:** `Atelier.Effects.Process.RunningProcess` is now a distinct
+  `newtype` wrapping `System.Process.Typed.Process` (previously a type
+  synonym). `getStdin`/`getStdout`/`getStderr` operate on the new type.
+
+### Removed
+
+- **Breaking:** `Atelier.Effects.Process` no longer exports `setCreateGroup`,
+  `startProcess`, or `stopProcess`. Process lifecycle is now managed through
+  `withProcessGroup`.
+
+## [0.1.0.0] - 2026-06-05
 
 ### Added
 
diff --git a/atelier-core.cabal b/atelier-core.cabal
--- a/atelier-core.cabal
+++ b/atelier-core.cabal
@@ -1,11 +1,11 @@
 cabal-version: 2.0
 
--- This file has been generated from package.yaml by hpack version 0.38.2.
+-- This file has been generated from package.yaml by hpack version 0.38.3.
 --
 -- see: https://github.com/sol/hpack
 
 name:           atelier-core
-version:        0.1.0.0
+version:        0.2.0.0
 synopsis:       Foundational Effectful-based effects and utilities
 description:    Core effects and utilities for effect-based applications, built on Effectful — part of the atelier toolkit.
 category:       Control
@@ -16,6 +16,11 @@
 license:        MIT
 license-file:   LICENSE
 build-type:     Simple
+tested-with:
+    GHC == 9.10.3
+  , GHC == 9.6.7
+  , GHC == 9.8.4
+  , GHC == 9.12.4
 extra-doc-files:
     CHANGELOG.md
     README.md
@@ -57,6 +62,7 @@
       Atelier.Effects.Posix.Daemons
       Atelier.Effects.Posix.IO
       Atelier.Effects.Process
+      Atelier.Effects.Process.Internal
       Atelier.Effects.Publishing
       Atelier.Effects.Tally
       Atelier.Effects.Timeout
@@ -94,27 +100,26 @@
       StrictData
       TemplateHaskell
       TypeFamilies
-  ghc-options: -Weverything -Wno-unsafe -Wno-missing-safe-haskell-mode -Wno-monomorphism-restriction -Wno-missing-kind-signatures -Wno-missing-poly-kind-signatures -Wno-missing-role-annotations -Wno-missing-local-signatures -Wno-missing-import-lists -Wno-implicit-prelude -Wno-unticked-promoted-constructors -Wno-unused-packages -Wno-all-missed-specialisations -Wno-missed-specialisations -fplugin=Effectful.Plugin -threaded
+  ghc-options: -Weverything -Wno-unsafe -Wno-missing-safe-haskell-mode -Wno-monomorphism-restriction -Wno-missing-kind-signatures -Wno-missing-local-signatures -Wno-missing-import-lists -Wno-implicit-prelude -Wno-unticked-promoted-constructors -Wno-unused-packages -Wno-all-missed-specialisations -Wno-missed-specialisations -fplugin=Effectful.Plugin -threaded
   build-depends:
       aeson ==2.2.*
     , atelier-prelude ==0.1.*
-    , base ==4.20.*
+    , base >=4.18 && <4.23
     , base64-bytestring ==1.2.*
-    , bytestring ==0.12.*
+    , bytestring >=0.11 && <0.13
     , casing ==0.1.*
-    , containers ==0.7.*
+    , containers >=0.6 && <0.9
     , contra-tracer ==0.2.*
     , daemons ==0.4.*
     , data-default ==0.8.*
     , directory ==1.3.*
     , effectful ==2.6.*
     , effectful-core ==2.6.*
-    , effectful-plugin ==2.0.*
+    , effectful-plugin >=2.0 && <2.2
     , effectful-th ==1.0.*
-    , filepath ==1.5.*
+    , filepath >=1.4 && <1.6
     , fsnotify ==0.4.*
     , hs-opentelemetry-api ==0.3.*
-    , hs-opentelemetry-exporter-otlp ==0.1.*
     , hs-opentelemetry-sdk ==0.1.*
     , http-api-data ==0.7.*
     , http-types ==0.12.*
@@ -127,7 +132,7 @@
     , stm ==2.5.*
     , stm-containers ==1.2.*
     , text ==2.1.*
-    , time ==1.12.*
+    , time >=1.12 && <1.16
     , time-units ==1.0.*
     , typed-process ==0.2.*
     , unagi-chan ==0.4.*
@@ -139,6 +144,8 @@
   mixins:
       base hiding (Prelude)
   default-language: GHC2021
+  if impl(GHC >= 9.8)
+    ghc-options: -Wno-missing-poly-kind-signatures -Wno-missing-role-annotations
 
 test-suite atelier-test
   type: exitcode-stdio-1.0
@@ -186,21 +193,21 @@
       StrictData
       TemplateHaskell
       TypeFamilies
-  ghc-options: -Weverything -Wno-unsafe -Wno-missing-safe-haskell-mode -Wno-monomorphism-restriction -Wno-missing-kind-signatures -Wno-missing-poly-kind-signatures -Wno-missing-role-annotations -Wno-missing-local-signatures -Wno-missing-import-lists -Wno-implicit-prelude -Wno-unticked-promoted-constructors -Wno-unused-packages -Wno-all-missed-specialisations -Wno-missed-specialisations -fplugin=Effectful.Plugin -threaded -Wno-prepositive-qualified-module
+  ghc-options: -Weverything -Wno-unsafe -Wno-missing-safe-haskell-mode -Wno-monomorphism-restriction -Wno-missing-kind-signatures -Wno-missing-local-signatures -Wno-missing-import-lists -Wno-implicit-prelude -Wno-unticked-promoted-constructors -Wno-unused-packages -Wno-all-missed-specialisations -Wno-missed-specialisations -fplugin=Effectful.Plugin -threaded -Wno-prepositive-qualified-module
   build-tool-depends:
       tasty-discover:tasty-discover
   build-depends:
       aeson ==2.2.*
     , async ==2.2.*
-    , atelier-core ==0.1.*
+    , atelier-core ==0.2.*
     , atelier-prelude ==0.1.*
-    , base ==4.20.*
-    , bytestring ==0.12.*
-    , containers ==0.7.*
+    , base >=4.18 && <4.23
+    , bytestring >=0.11 && <0.13
+    , containers >=0.6 && <0.9
     , data-default ==0.8.*
     , effectful ==2.6.*
     , effectful-core ==2.6.*
-    , effectful-plugin ==2.0.*
+    , effectful-plugin >=2.0 && <2.2
     , hedgehog ==1.7.*
     , hs-opentelemetry-api ==0.3.*
     , hspec ==2.11.*
@@ -209,7 +216,9 @@
     , stm-containers ==1.2.*
     , tasty ==1.5.*
     , tasty-hspec ==1.2.*
-    , time ==1.12.*
+    , time >=1.12 && <1.16
   mixins:
       base hiding (Prelude)
   default-language: GHC2021
+  if impl(GHC >= 9.8)
+    ghc-options: -Wno-missing-poly-kind-signatures -Wno-missing-role-annotations
diff --git a/src/Atelier/Effects/File.hs b/src/Atelier/Effects/File.hs
--- a/src/Atelier/Effects/File.hs
+++ b/src/Atelier/Effects/File.hs
@@ -10,6 +10,7 @@
     ( File
     , Handle
     , BufferMode (..)
+    , IOMode (..)
     , withFile
     , hClose
     , hFlush
diff --git a/src/Atelier/Effects/Process.hs b/src/Atelier/Effects/Process.hs
--- a/src/Atelier/Effects/Process.hs
+++ b/src/Atelier/Effects/Process.hs
@@ -1,12 +1,13 @@
 -- | Effect for spawning and interacting with external processes.
 --
--- Unifies @typed-process@ (configuration, lifecycle and stream capture) and
--- the one operation only @process@ provides (process-group interruption)
--- behind a single effect, so callers never depend on either package directly.
+-- Unifies @typed-process@ (configuration, lifecycle and stream capture) and the
+-- process-group signalling that @process@ provides behind a single effect, so
+-- callers never depend on either package directly.
 --
--- Build a 'ProcessConfig' with the re-exported @typed-process@ DSL
--- ('proc', 'shell', 'setStdin', …), then run it with one of the operations
--- below.
+-- Build a 'ProcessConfig' with the re-exported @typed-process@ DSL ('proc',
+-- 'shell', 'setStdin', …), then run it with 'withProcessGroup', which runs the
+-- process in its own group and guarantees the /whole group/ is torn down on
+-- exit. Callers never manage pids or process groups themselves.
 module Atelier.Effects.Process
     ( -- * Effect
       Process
@@ -20,7 +21,6 @@
     , setStdout
     , setStderr
     , setWorkingDir
-    , setCreateGroup
     , createPipe
     , getStdin
     , getStdout
@@ -29,27 +29,26 @@
       -- * Operations
     , readProcessStdout
     , readProcessSafe
-    , startProcess
-    , stopProcess
-    , waitExitCode
+    , withProcessGroup
+    , terminateProcessGroup
     , interruptProcessGroup
+    , waitExitCode
 
       -- * Interpreters
     , runProcessIO
     ) where
 
+import Control.Exception (IOException, catch)
 import Effectful (Effect, IOE)
 import Effectful.Dispatch.Dynamic (interpret_)
-import Effectful.Exception (trySync)
+import Effectful.Exception (bracket, trySync)
 import Effectful.TH (makeEffect)
 import System.Exit (ExitCode (..))
-import System.Process (interruptProcessGroupOf)
+import System.Posix.Signals (sigTERM, signalProcessGroup)
+import System.Process (Pid, getPid, interruptProcessGroupOf)
 import System.Process.Typed
     ( ProcessConfig
     , createPipe
-    , getStderr
-    , getStdin
-    , getStdout
     , proc
     , setCreateGroup
     , setStderr
@@ -61,30 +60,81 @@
 
 import System.Process.Typed qualified as TP
 
+import Atelier.Effects.Process.Internal (RunningProcess (..))
 
--- | @typed-process@'s started-process type, re-exported under a non-clashing
--- name (the effect itself is called 'Process'). Parameterised by its stdin,
--- stdout and stderr stream types.
-type RunningProcess = TP.Process
 
+-- | The process's stdin stream (its type set by the 'ProcessConfig').
+getStdin :: RunningProcess i o e -> i
+getStdin (RunningProcess p) = TP.getStdin p
 
+
+-- | The process's stdout stream.
+getStdout :: RunningProcess i o e -> o
+getStdout (RunningProcess p) = TP.getStdout p
+
+
+-- | The process's stderr stream.
+getStderr :: RunningProcess i o e -> e
+getStderr (RunningProcess p) = TP.getStderr p
+
+
 data Process :: Effect where
     -- | Run a process to completion, returning its exit code and captured stdout.
     ReadProcessStdout :: ProcessConfig i o e -> Process m (ExitCode, LByteString)
-    -- | Spawn a process and return its handle for further interaction.
-    StartProcess :: ProcessConfig i o e -> Process m (TP.Process i o e)
-    -- | Stop a process: close its streams, terminate it, and wait for it to exit.
-    StopProcess :: TP.Process i o e -> Process m ()
+    -- | Spawn a process and return its handle. Internal; callers use 'withProcessGroup'.
+    StartProcess :: ProcessConfig i o e -> Process m (RunningProcess i o e)
+    -- | Terminate the leader and close its streams. Internal; does not reach the
+    -- rest of the group.
+    StopProcess :: RunningProcess i o e -> Process m ()
     -- | Block until the process exits and return its exit code.
-    WaitExitCode :: TP.Process i o e -> Process m ExitCode
-    -- | Send an interrupt (SIGINT) to the process's group. Requires the process
-    -- to have been started with @'setCreateGroup' True@.
-    InterruptProcessGroup :: TP.Process i o e -> Process m ()
+    WaitExitCode :: RunningProcess i o e -> Process m ExitCode
+    -- | Send SIGINT to the process's group. Requires @'setCreateGroup' True@.
+    InterruptProcessGroup :: RunningProcess i o e -> Process m ()
+    -- | Look up the OS process id, or 'Nothing' if it has already exited. Internal.
+    GetProcessId :: RunningProcess i o e -> Process m (Maybe Pid)
+    -- | Send SIGTERM to the given process group. Internal.
+    SignalProcessGroupTerm :: Pid -> Process m ()
 
 
 makeEffect ''Process
 
 
+-- | Run a process in its own process group, terminating the /whole group/ — the
+-- process and every descendant it spawned — when the body returns or throws.
+--
+-- Cleanup is guaranteed even if the process has already exited. The body may
+-- share the handle so another thread can 'terminateProcessGroup' it early.
+withProcessGroup
+    :: (Process :> es)
+    => ProcessConfig i o e
+    -> (RunningProcess i o e -> Eff es a)
+    -> Eff es a
+withProcessGroup cfg body =
+    bracket acquire release (\(p, _) -> body p)
+  where
+    acquire = do
+        p <- startProcess (setCreateGroup True cfg)
+        pgid <- getProcessId p
+        pure (p, pgid)
+    release (p, pgid) = tearDownGroup pgid p
+
+
+-- | Terminate a running process and its /whole group/ immediately. Use this to
+-- abort a process started with 'withProcessGroup' from another thread — for
+-- children that trap SIGINT, where 'interruptProcessGroup' is not enough.
+terminateProcessGroup :: (Process :> es) => RunningProcess i o e -> Eff es ()
+terminateProcessGroup p = do
+    pgid <- getProcessId p
+    tearDownGroup pgid p
+
+
+-- | SIGTERM the group (if its id is known), then best-effort reap the leader.
+tearDownGroup :: (Process :> es) => Maybe Pid -> RunningProcess i o e -> Eff es ()
+tearDownGroup pgid p = do
+    maybe (pure ()) signalProcessGroupTerm pgid
+    void $ trySync $ stopProcess p
+
+
 -- | Run @cmd@ with @args@ and return its stdout as 'Text', or 'Nothing' on any
 -- error (non-zero exit, the executable not being found, etc.).
 readProcessSafe :: (Process :> es) => FilePath -> [String] -> Eff es (Maybe Text)
@@ -98,7 +148,10 @@
 runProcessIO :: (IOE :> es) => Eff (Process : es) a -> Eff es a
 runProcessIO = interpret_ \case
     ReadProcessStdout cfg -> liftIO $ TP.readProcessStdout cfg
-    StartProcess cfg -> liftIO $ TP.startProcess cfg
-    StopProcess p -> liftIO $ TP.stopProcess p
-    WaitExitCode p -> liftIO $ TP.waitExitCode p
-    InterruptProcessGroup p -> liftIO $ interruptProcessGroupOf (TP.unsafeProcessHandle p)
+    StartProcess cfg -> liftIO $ RunningProcess <$> TP.startProcess cfg
+    StopProcess (RunningProcess p) -> liftIO $ TP.stopProcess p
+    WaitExitCode (RunningProcess p) -> liftIO $ TP.waitExitCode p
+    InterruptProcessGroup (RunningProcess p) -> liftIO $ interruptProcessGroupOf (TP.unsafeProcessHandle p)
+    GetProcessId (RunningProcess p) -> liftIO $ getPid (TP.unsafeProcessHandle p)
+    SignalProcessGroupTerm pid ->
+        liftIO $ signalProcessGroup sigTERM pid `catch` \(_ :: IOException) -> pure ()
diff --git a/src/Atelier/Effects/Process/Internal.hs b/src/Atelier/Effects/Process/Internal.hs
new file mode 100644
--- /dev/null
+++ b/src/Atelier/Effects/Process/Internal.hs
@@ -0,0 +1,15 @@
+-- | The 'RunningProcess' constructor, for tests that fabricate a handle.
+--
+-- Constructing one directly bypasses 'Atelier.Effects.Process.withProcessGroup'
+-- and its guarantee that the process heads its own group, so the group-signalling
+-- operations may target the wrong group. Production code should not import this.
+module Atelier.Effects.Process.Internal
+    ( RunningProcess (..)
+    ) where
+
+import System.Process.Typed qualified as TP
+
+
+-- | A process run in its own group by 'Atelier.Effects.Process.withProcessGroup'.
+-- Parameterised by its stdin, stdout and stderr stream types.
+newtype RunningProcess i o e = RunningProcess (TP.Process i o e)
diff --git a/src/Atelier/Exception.hs b/src/Atelier/Exception.hs
--- a/src/Atelier/Exception.hs
+++ b/src/Atelier/Exception.hs
@@ -1,3 +1,5 @@
+{-# OPTIONS_GHC -Wno-redundant-constraints #-}
+
 -- | Helpers for telling synchronous exceptions apart from asynchronous ones.
 --
 -- Synchronous exceptions are genuine failures worth catching, logging, or
