diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 9fc7f84a394dcb444e3ef6086f6eba04aee74c40..76b18755f735c0b4c7fa4f47a7c0f0bd7c64d721 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -22,6 +22,7 @@ set(BASE_SOURCES
     util/debug.cpp
     util/hostname.cpp
     util/path.cpp
+    util/prefixbuf.cpp
     util/unwind.cpp
 )
 set(CUDA_SOURCES
diff --git a/src/util/prefixbuf.cpp b/src/util/prefixbuf.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..71dfea842abf53c1d058775a31065259c9bb7f59
--- /dev/null
+++ b/src/util/prefixbuf.cpp
@@ -0,0 +1,144 @@
+#include <iostream>
+#include <stack>
+#include <string>
+#include <vector>
+
+#include <util/prefixbuf.hpp>
+
+namespace arb {
+namespace util {
+
+// prefixbuf implementation:
+
+std::streamsize prefixbuf::xsputn(const char_type* s, std::streamsize count) {
+    std::streamsize written = 0;
+
+    while (count>0) {
+        if (bol_) {
+            inner_->sputn(&prefix[0], prefix.size());
+            bol_ = false;
+        }
+
+        std::streamsize i = 0;
+        while (i<count && s[i]!='\n') {
+            ++i;
+        }
+
+        if (i<count) { // encountered '\n'
+            ++i;
+            bol_ = true;
+        }
+
+        std::streamsize n = inner_->sputn(s, i);
+        written += n;
+        if (n<i) {
+            break;
+        }
+
+        s += i;
+        count -= i;
+    }
+
+    return written;
+}
+
+prefixbuf::int_type prefixbuf::overflow(int_type ch) {
+    static int_type eof = traits_type::eof();
+
+    if (ch!=eof) {
+        char_type c = (char_type)ch;
+        return xsputn(&c, 1)? 0: eof;
+    }
+
+    return eof;
+}
+
+// setprefix implementation:
+
+std::ostream& operator<<(std::ostream& os, const setprefix& sp) {
+    if (auto pbuf = dynamic_cast<prefixbuf*>(os.rdbuf())) {
+        pbuf->prefix = sp.prefix_;
+    }
+
+    return os;
+}
+
+// indent_manip implementation:
+
+using indent_stack = std::stack<unsigned, std::vector<unsigned>>;
+
+int indent_manip::xindex() {
+    static int i = std::ios_base::xalloc();
+    return i;
+}
+
+static void apply_indent_prefix(std::ios& s, int index) {
+    if (auto pbuf = dynamic_cast<prefixbuf*>(s.rdbuf())) {
+        indent_stack* stack_ptr = static_cast<indent_stack*>(s.pword(index));
+        unsigned tabwidth = s.iword(index);
+
+        unsigned tabs = (!stack_ptr || stack_ptr->empty())? 0: stack_ptr->top();
+        pbuf->prefix = std::string(tabs*tabwidth, ' ');
+    }
+}
+
+static void indent_stack_callback(std::ios_base::event ev, std::ios_base& ios, int index) {
+    void*& pword = ios.pword(index);
+
+    switch (ev) {
+    case std::ios_base::erase_event:
+        if (pword) {
+            indent_stack* stack_ptr = static_cast<indent_stack*>(pword);
+            delete stack_ptr;
+            pword = nullptr;
+        }
+        break;
+    case std::ios_base::copyfmt_event:
+        if (pword) {
+            // Clone stack:
+            indent_stack* stack_ptr = static_cast<indent_stack*>(pword);
+            pword = new indent_stack(*stack_ptr);
+
+            // Set prefix if streambuf is a prefixbuf:
+            if (auto stream_ptr = dynamic_cast<std::ios*>(&ios)) {
+                apply_indent_prefix(*stream_ptr, index);
+            }
+        }
+        break;
+    default:
+        ;
+    }
+}
+
+std::ostream& operator<<(std::ostream& os, indent_manip in) {
+    int xindex = indent_manip::xindex();
+    void*& pword = os.pword(xindex);
+    long& iword = os.iword(xindex);
+
+    if (!pword) {
+        os.register_callback(&indent_stack_callback, xindex);
+        pword = new indent_stack();
+        iword = static_cast<long>(indent_manip::default_tabwidth);
+    }
+
+    indent_stack& stack = *static_cast<indent_stack*>(pword);
+    switch (in.action_) {
+    case indent_manip::pop:
+        while (!stack.empty() && in.value_--) {
+            stack.pop();
+        }
+        break;
+    case indent_manip::push:
+        stack.push(stack.empty()? in.value_: in.value_+stack.top());
+        break;
+    case indent_manip::settab:
+        iword = static_cast<long>(in.value_);
+        break;
+    }
+
+    apply_indent_prefix(os, xindex);
+    return os;
+}
+
+} // namespace util
+} // namespace arb
diff --git a/src/util/prefixbuf.hpp b/src/util/prefixbuf.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..5f4c342e78cab89f69029e8a6a72b930891cd594
--- /dev/null
+++ b/src/util/prefixbuf.hpp
@@ -0,0 +1,135 @@
+#pragma once
+
+// Output-only stream buffer that prepends a prefix to each line of output,
+// together with stream manipulators for setting the prefix and managing
+// an indentation level.
+
+#include <ostream>
+#include <sstream>
+#include <string>
+
+namespace arb {
+namespace util {
+
+// `prefixbuf` acts an output-only filter for another streambuf, inserting
+// the contents of the `prefix` string before the first character in a line.
+//
+// The following code, for example:
+//
+//     prefixbuf p(std::cout.rdbuf());
+//     std::cout.rdbuf(&p);
+//     p.prefix = ">>> ";
+//     std::cout << "hello\nworld\n";
+//
+// would emit to stdout:
+//
+//     >>> hello
+//     >>> world
+
+class prefixbuf: public std::streambuf {
+public:
+    explicit prefixbuf(std::streambuf* inner): inner_(inner) {}
+
+    prefixbuf(prefixbuf&&) = default;
+    prefixbuf(const prefixbuf&) = delete;
+
+    prefixbuf& operator=(prefixbuf&&) = default;
+    prefixbuf& operator=(const prefixbuf&) = delete;
+
+    std::streambuf* inner() { return inner_; }
+    std::string prefix;
+
+protected:
+    std::streambuf* inner_;
+    bool bol_ = true;
+
+    std::streamsize xsputn(const char_type* s, std::streamsize count) override;
+    int_type overflow(int_type ch) override;
+};
+
+// Manipulators:
+//
+// setprefix(s):   explicitly set prefix string on corresponding prefixbuf.
+// indent:         increase indentation level by one tab width.
+// indent(n):      increase indentation level by n tab widths.
+// popindent:      undo last `indent` operation.
+// popindent(n)    undo last n `indent` operations.
+// settab(w):      set tab width to w (default is 4).
+//
+// Note that the prefix string is a property of the prefixbuf, not the stream,
+// and so will not be preserved by e.g. `copyfmt`.
+//
+// All but `setprefix` are implemented as values of type `indent_manip`
+// below.
+//
+// The manipulator `indent(0)` can be used to reset the prefix of the underlying
+// stream to match the current indentation level.
+
+class setprefix {
+public:
+    explicit setprefix(std::string prefix): prefix_(std::move(prefix)) {}
+
+    friend std::ostream& operator<<(std::ostream& os, const setprefix& sp);
+
+private:
+    std::string prefix_;
+};
+
+struct indent_manip {
+    enum action_enum {push, pop, settab};
+
+    explicit constexpr indent_manip(action_enum action, unsigned value=0):
+        action_(action), value_(value)
+    {}
+
+    // convenience interface: allows using both `indent` and `indent(n)`
+    // as stream manipulators.
+    indent_manip operator()(unsigned n) const {
+        return indent_manip(action_, n);
+    }
+
+    friend std::ostream& operator<<(std::ostream& os, indent_manip in);
+
+private:
+    static constexpr unsigned default_tabwidth = 4;
+    static int xindex();
+
+    action_enum action_;
+    unsigned value_;
+};
+
+constexpr indent_manip indent{indent_manip::push, 1u};
+constexpr indent_manip popindent{indent_manip::pop, 1u};
+
+inline indent_manip settab(unsigned w) {
+    return indent_manip{indent_manip::settab, w};
+}
+
+// Wrap an stringbuf with a prefixbuf, and present as a stream.
+// Acts very much like a `std::ostringstream`, but with prefix
+// and indent functionality.
+
+class pfxstringstream: public std::ostream {
+public:
+    pfxstringstream():
+        std::ostream(&pbuf_),
+        sbuf_(std::ios_base::out),
+        pbuf_(&sbuf_)
+    {}
+
+    pfxstringstream(pfxstringstream&&) = default;
+    pfxstringstream& operator=(pfxstringstream&&) = default;
+
+    std::string str() const { return sbuf_.str(); }
+    void str(const std::string& s) { sbuf_.str(s); }
+
+    prefixbuf* rdbuf() { return &pbuf_; }
+
+private:
+    std::stringbuf sbuf_;
+    prefixbuf pbuf_;
+};
+
+
+} // namespace util
+} // namespace arb
diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt
index 62eb4695dbda122a0d5807ecd85d1d77238d1615..3c64b0a497dad0e282b77c28f57fbfe3b106992b 100644
--- a/tests/unit/CMakeLists.txt
+++ b/tests/unit/CMakeLists.txt
@@ -61,6 +61,7 @@ set(TEST_SOURCES
     test_partition.cpp
     test_path.cpp
     test_point.cpp
+    test_prefixbuf.cpp
     test_probe.cpp
     test_range.cpp
     test_segment.cpp
diff --git a/tests/unit/test_prefixbuf.cpp b/tests/unit/test_prefixbuf.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..b211c8161338da03ea164f599c5fd066a52a9436
--- /dev/null
+++ b/tests/unit/test_prefixbuf.cpp
@@ -0,0 +1,169 @@
+#include "../gtest.h"
+
+#include <cstring>
+#include <iomanip>
+#include <string>
+#include <sstream>
+
+#include <util/prefixbuf.hpp>
+
+using namespace arb::util;
+
+// Test public std::stringbuf 'put' interfaces on prefixbuf.
+
+TEST(prefixbuf, prefix) {
+    // Write a C-string with `std::steambuf::sputn` ---
+    // exercises `prefixbuf::xsputn`.
+
+    auto write_sputn = [](std::streambuf& b, const char* c) {
+        b.sputn(c, std::strlen(c));
+    };
+
+    // Write a C-string with `std::steambuf::sputc` ---
+    // exercises `prefixbuf::overflow`.
+
+    auto write_sputc = [](std::streambuf& b, const char* c) {
+        while (*c) {
+            b.sputc(*c++);
+        }
+    };
+
+    std::stringbuf s;
+    write_sputn(s, "starting text\n");
+
+    prefixbuf p(&s);
+    p.prefix = ":) ";
+
+    write_sputn(p, "foo\nbar ");
+    write_sputc(p, "quux\nbaz ");
+    p.prefix = "^^ ";
+    write_sputn(p, "xyzzy\nplugh\n");
+
+    std::string expected =
+        "starting text\n"
+        ":) foo\n"
+        ":) bar quux\n"
+        ":) baz xyzzy\n"
+        "^^ plugh\n";
+
+    EXPECT_EQ(expected, s.str());
+}
+
+// Test `pfxstringstream` basic functionality:
+//
+//   1. `rdbuf()` method gives pointer to `prefixbuf`.
+//
+//   2. Formatted write operations behave as expected,
+//      but with the prefixbuf prefix-inserting behaviour.
+//
+//   3. `str()` method gives string from wrapped `std::stringbuf`.
+
+TEST(prefixbuf, pfxstringstream) {
+    pfxstringstream p;
+
+    p.rdbuf()->prefix = "...";
+
+    p << "_foo_ " << std::setw(5) << 123 << "\n";
+    p << std::showbase << std::hex << 42;
+
+    std::string expected =
+        "..._foo_   123\n"
+        "...0x2a";
+
+    EXPECT_EQ(expected, p.str());
+}
+
+// Test that the `pfxstringstream::str(const std::string&)` method
+// behaves analagously to that of `std::ostringstream`, viz. initializing
+// the contents of the buffer.
+
+TEST(prefixbuf, pfxstringstream_str) {
+    pfxstringstream p;
+    p.str("0123456789");
+    p.rdbuf()->prefix = "__";
+    p << "a\nb";
+
+    std::string expected = "__a\n__b789";
+    EXPECT_EQ(expected, p.str());
+}
+
+// Test indent manipulators against expected prefixes.
+
+TEST(prefixbuf, indent_manip) {
+    pfxstringstream p;
+
+    p << settab(2);
+    p << "0\n"
+      << indent
+      << "1\n"
+      << indent(2)
+      << "3\n"
+      << indent
+      << "4\n"
+      << popindent(2)
+      << "1\n"
+      << popindent
+      << "0";
+
+    std::string expected =
+        "0\n"
+        "  1\n"
+        "      3\n"
+        "        4\n"
+        "  1\n"
+        "0";
+
+    EXPECT_EQ(expected, p.str());
+}
+
+// `setprefix` goes behind the stream's back and sets the prefix
+// in the underling streambuf directly.
+//
+// The stream's indentation will be re-applied to the streambuf
+// when it encounters an indent manipulator; `indent(0)` would
+// otherwise constitute a NOP.
+
+TEST(prefixbuf, setprefix) {
+    pfxstringstream p;
+
+    p << indent << "one\ntwo ";
+    p << setprefix("--->"); // override prefix
+    p << "three\nfour ";
+    p << indent(0)          // restore indentation
+      << "five\nsix";
+
+    std::string expected =
+        "    one\n"
+        "    two three\n"
+        "--->four five\n"
+        "    six";
+
+    EXPECT_EQ(expected, p.str());
+}
+
+// Confirm the callback associated with the indentation state
+// propagates the full indentation stack after `copyfmt`,
+// and imposes the corresponding prefix on the underlying
+// `prefixbuf`.
+
+TEST(prefixbuf, copyfmt) {
+    pfxstringstream p1;
+    pfxstringstream p2;
+
+    p1 << indent << "1\n" << indent << "2\n";
+    p2 << "0\n";
+
+    p2.copyfmt(p1);
+    p2 << "2\n" << popindent << "1\n";
+
+    p1 << indent << "3\n";
+
+    std::string expected1 =
+        "    1\n        2\n            3\n";
+
+    std::string expected2 =
+        "0\n        2\n    1\n";
+
+    EXPECT_EQ(expected1, p1.str());
+    EXPECT_EQ(expected2, p2.str());
+}