C++ Runtime debugging library for AI agents
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jorijn van der Graaf 2217037d72
feat(cli): pick the app of the project the launcher belongs to
Discovery is per user, so every project's launcher sees every debug app
running, and with two of them up every command refused to guess and
asked for -i, though the agent in one project meant its own app. Two
copies of the example, both named basic, could only be told apart by
id.

The launcher now exports CRAFTER_DEBUG_PROJECT, its project's directory
(flatpak-spawn gets it spelled out), and the client counts an app as
the project's when its executable lies under it, as `-r` runs it from
<project>/bin/<variant>/. With other projects' apps running, commands
pick this project's; two copies of it still need -i. Another project's
app is never picked by default: with this project's app not up yet, or
crashed, the agent would otherwise drive, or quit, someone else's. It
is listed instead, for -i.

`ls` lists this project's apps first, marked, `--json` says which are,
and `--wait` waits for one of them rather than any app; the exit code
says whether one runs. A client started without the launcher has no
project and picks the one app running, as before, and the command the
protocol check prints for an older app's own client now names it with
-i.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:33:59 +01:00
examples/Basic fix(examples): restart ball ids when the scene is cleared 2026-11-01 18:24:35 +01:00
implementations feat(cli): pick the app of the project the launcher belongs to 2026-11-01 18:33:59 +01:00
interfaces feat(cli): pick the app of the project the launcher belongs to 2026-11-01 18:33:59 +01:00
tests feat(cli): pick the app of the project the launcher belongs to 2026-11-01 18:33:59 +01:00
tooling feat(cli): pick the app of the project the launcher belongs to 2026-11-01 18:33:59 +01:00
.gitignore chore: start the repository 2026-11-01 01:18:07 +01:00
AGENTS.template.md chore(transport): drop the CRAFTER_DEBUG_NETWORK hook 2026-11-01 18:04:22 +01:00
LICENSE chore: start the repository 2026-11-01 01:18:07 +01:00
LICENSE.GPL chore: start the repository 2026-11-01 01:18:07 +01:00
project.cpp chore(transport): drop the CRAFTER_DEBUG_NETWORK hook 2026-11-01 18:04:22 +01:00
README.md fix(tooling): build first, then start the app, then list it 2026-11-01 18:24:35 +01:00

Crafter.Debug

Runtime debugging for AI agents, and for people. An app built with Crafter.Debug can be inspected and driven while it runs. From a shell, crafter-debug can:

  • call the commands the app registers;
  • read and set its watched state;
  • pause it, step it, and pause it when a condition holds;
  • wait for conditions, frames or events;
  • trace values over frames;
  • read its log;
  • take screenshots.

Breakpoints and memory stay with a debugger; crafter-debug debugger prints how to attach one.

$ crafter-build --debug-api                   # the app's debug build, and the client with it
$ crafter-build --debug-api -r &              # the app, in the background
$ ./build/crafter-debug ls --wait 60s
game  id 4f1c09a2b7e3d851  pid 3 (sandbox 48211)  up 4s  port 41207
$ ./build/crafter-debug call Lobby_Start && ./build/crafter-debug wait --frames 30
frame 342
$ ./build/crafter-debug pause --when 'fort1.core.hp <= 0' && ./build/crafter-debug call Player_Fire
$ ./build/crafter-debug wait --paused
paused at frame 1840: 'fort1.core.hp <= 0' became true
$ ./build/crafter-debug log --since-frame 1790 --level warn
   1838  WARN   physics    brace 17 strain 4.2 > limit  Forts3D-Brace.cpp:212
   1839  CHECK  physics    body 9 has mass -0  (x3)  Forts3D-Physics.cpp:88
$ ./build/crafter-debug screenshot
/home/me/game/build/crafter-debug-shots/game-1793488099646.png (2215871 bytes)

How it fits together

Piece What it is
The library, Crafter.Debug Linked into the app's debug builds only. A Debug::Session serves requests on 127.0.0.1 over QUIC, with a random port and a token, and runs the ones that touch the app on the app's own thread, between frames, in Debug::Pump().
The client, crafter-debug Built in the same pass as the app, from the same sources. The launcher build/crafter-debug runs it inside the CrafterBuild Flatpak sandbox it was built for. Nothing the Flatpak compiles runs on the host.
Discovery Each running app writes $HOME/.cache/crafter-debug/instances/<id>.json (its user's only) and holds a lock while it lives. The client finds apps there, across Flatpak sandboxes, and forgets the ones that died.
The build helper, Crafter.Debug.Build Loaded into a consumer's project.cpp by #pragma crafter dependency. It adds the library under the consumer's own debug flag and writes the launcher.

Adding it to a project

1. In project.cpp, load the helper and call Debug::Use with your own flag:

import std;
import Crafter.Build;
#pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.Debug.git" project "tooling/project.cpp"
import Crafter.Debug.Build;
using namespace Crafter;

extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
    Configuration cfg;
    // ...
    const ArgQuery opts = ApplyStandardArgs(cfg, args);
    std::vector<std::string> depArgs;                     // your args minus your own flags
    for (const std::string_view arg : args) {
        if (arg != "--debug-api") depArgs.emplace_back(arg);
    }
    // ...give depArgs to every other dependency too...
    const bool debugApi = Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api",
                                            .define = "MYAPP_DEBUG_API", .args = depArgs });
    // ...your sources, plus the debug-only ones when debugApi...
}

What Use does with the flag:

  • Flag on, native Linux target: it adds the define and the library, from the checkout the pragma names, writes build/crafter-debug, and returns true.
  • Otherwise: it adds nothing and returns false, and release builds never link Crafter.Debug. Until a debug build has run, build/crafter-debug is a stub that says to build one.

Add your debug-only sources when Use returns true, not when the flag is on: a non-Linux target with the flag gets neither the library nor the define.

A dependency that two paths in the build graph share must get the same arg list on both. Otherwise crafter-build refuses the graph with "module … built twice". Build one depArgs and give it to every dependency, as above.

That includes Crafter.Network, which Crafter.Debug takes from https://forgejo.catcrafts.net/Catcrafts/Crafter.Network.git, unpinned (with --local, from ../Crafter.Network). If your project depends on Crafter.Network too, name it the same way. A project that pins its own Crafter.Network to a commit can't add Crafter.Debug yet.

2. In main, start a session and pump it once per frame. Gate the parts the agent should be able to pause:

import MyApp.DebugShim;                                   // see "Without #ifdefs" below
ThreadPool::Start();                                      // the session's transport needs a pool worker
#ifdef MYAPP_DEBUG_API
    Debug::Session debug({ .name = "MyApp" });
    MyApp::RegisterDebug();                               // your commands and watches
#endif
while (running && !Debug::QuitRequested()) {
    Debug::Pump();
    while (acc >= dt && Debug::Advance()) { sim.Tick(); acc -= dt; }
    render();
}

The Session must be destroyed before main returns and before ThreadPool::Stop(), because msquic waits for open connections at exit. A stack object in main, as above, does that. It needs the pool started with at least one worker (Start(1) starts none) and throws without one.

3. Register what the agent needs in debug-only source files, which project.cpp adds only under the flag. See "The app side" below.

4. For agents, Crafter.Build merges Crafter.Debug's notes into your AGENTS.md. Check three things:

  • Your project needs an AGENTS.template.md. Without one, crafter-build writes no AGENTS.md.
  • Name your debug flag in it. The merged notes say "build with the project's debug flag", and only you know which flag that is.
  • If the repository has a CLAUDE.md, put @AGENTS.md in it. Claude Code ignores AGENTS.md whenever a CLAUDE.md applies.

Optionally, allow Bash(./build/crafter-debug *) in .claude/settings.json.

Working on Crafter.Debug itself next to a consumer: point the pragma at your checkout:

  • A local path, #pragma crafter dependency "../Crafter.Debug" project "tooling/project.cpp", uses the working tree.
  • A file URL, "file:///…/Crafter.Debug", is a git source, so it uses the committed state.

Pragmas are read as plain text, so neither #if nor a flag can switch them.

The app side

Everything is in import Crafter.Debug;, namespace Crafter::Debug.

Commands

struct AimArgs {
    Debug::Required<std::int32_t> slot;      // must be passed
    float yaw = 0;                           // optional; the default shows in `help`
    float pitch = 0;
};
Debug::Command("Player_SetAim", "Aim the slot's turret, in degrees.",
    [](const AimArgs& a) -> Debug::Result<> {
        Game* g = CurrentGame();
        if (!g) return Debug::Error{ "InvalidScene", "not in a game; call Lobby_Start first" };
        g->Aim(*a.slot, a.yaw, a.pitch);
        return {};
    });

Arguments:

  • A handler takes nothing, or one argument type. That is usually an aggregate, whose fields are found by reflection, so no serialization code is written; Debug::Value gives raw JSON.
  • Fields a call leaves out keep their default member initializers.
  • A call with mistakes is answered with every one of them, each with its path and the likely intended name, and never reaches the handler: slot: missing, and this field is required; yw: unknown field; did you mean 'yaw'?.

Results: a handler returns void, any value with a codec, Debug::Result<T>, or Debug::Async<T>.

  • Debug::Result<T> is a value or a Debug::Error{code, message}. Write the message for the agent: what went wrong and the next step.
  • Debug::Async<T> is for results that take frames, such as a GPU readback. Its poller runs once per Pump until it yields, and the caller simply waits:
Debug::Command("Sim_StateHash", "Hash of the GPU sim state.", []() -> Debug::Async<StateHash> {
    Physics::RequestCapture();
    return Debug::Async<StateHash>([]() -> std::optional<StateHash> {
        if (!Physics::CaptureReady()) return std::nullopt;
        return HashCapture();
    });
});

A handler that throws is answered with an Exception error. The exception does not unwind into the frame loop.

Watches and samples

Debug::Watch("physics.gravity", Physics::gravity, { .min = -50, .max = 0, .help = "m/s^2" });   // read and write
Debug::Watch("scene", [] { return SceneName(); });                                               // read only
Debug::Watch("lives", [] { return lives; }, [](const std::int32_t& v) -> Debug::Result<> {       // through a setter
    if (v < 0) return Debug::Error{ "Negative", "lives cannot be negative" };
    lives = v;
    return {};
});
Debug::Sample("xpbd.residual", residual);    // from hot code, any thread; keeps a history for `trace --history`

Paths read into values:

  • get bodies[3].pos.y reads the watch bodies.
  • set fort1.core.pos.x 5 reads fort1.core.pos, replaces x, and writes the whole value back.

Where they run: getters, setters and handlers are only ever called on the thread that calls Pump, so they read app state without locking. Registering and unregistering are safe from any thread.

Groups

Registrations that end with their owner, under a prefix:

class Fort {
    Core core;
    Debug::Group debug;
public:
    explicit Fort(std::int32_t slot) : debug(std::format("fort{}", slot)) {
        debug.Watch("core.hp", core.hp);
        debug.Command("Explode", "Blow it up.", [this] { core.Explode(); });
    }
};

A Group can't be copied or moved, so neither can a class that holds one. Its watches refer to the members they were given, and a move would leave them pointing at the old object. Keep such objects in place: std::unique_ptr, std::deque or std::list, not std::vector.

The timeline

Debug::Log("net", "peer {} joined slot {}", peerId, slot);        // also LogWarn, LogError
Debug::Emit("MatchEnded", MatchEnded{ .winnerSlot = 1 });          // `wait --event MatchEnded`
if (!Debug::Check(mass > 0, "physics", "body {} has mass {}", id, mass)) return;   // a soft assert

One timeline: every entry carries the frame and the source location, next to the agent's own actions. The format strings are checked at compile time.

Check:

  • It returns its condition, and only formats the message when the condition fails.
  • A check that fails every frame is counted every time, but its entries are thinned out, so it cannot flood the log.
  • pause --on-check pauses the app within the frame of the next failure; --trap also stops an attached debugger there.

Without #ifdefs: the frame-loop and timeline calls can be left in release code. Wrap them in a small module of your own that forwards to Crafter.Debug in debug builds and does nothing otherwise. It is one of your own sources, so your define reaches it:

export module MyApp.DebugShim;
import std;
#ifdef MYAPP_DEBUG_API
export import Crafter.Debug;
#else
export namespace Crafter::Debug {
    inline void Pump() {}
    inline bool Advance() { return true; }
    inline bool Paused() { return false; }
    inline std::uint32_t TakeSteps() { return 0; }
    inline bool QuitRequested() { return false; }
    template<class... A> void Log(A&&...) {}
    template<class... A> void LogWarn(A&&...) {}
    template<class... A> void LogError(A&&...) {}
    template<class... A> bool Check(bool ok, A&&...) { return ok; }
    template<class... A> void Emit(A&&...) {}
    template<class T> void Sample(std::string_view, const T&) {}
}
#endif

Pause and step

Pausing holds only what the app gates. Rendering and Pump keep running, so the agent can still read state, take screenshots and step.

// A frame loop:
while (acc >= dt && Debug::Advance()) { sim.Tick(); acc -= dt; }      // false while paused, true once per step

// A fixed-step loop that must not spiral when resumed:
if (Debug::Paused()) {
    for (std::uint32_t n = Debug::TakeSteps(); n > 0; --n) sim.Tick();
    acc = 0;
} else {
    while (acc >= dt) { sim.Tick(); acc -= dt; }
}

Pausing an app that never calls Advance or Paused is refused, rather than silently doing nothing.

Screenshots

Debug::SetCapture tells the session where PNGs come from. It is called on the Pump thread and returns the bytes when they are ready. With Crafter.Graphics:

Debug::SetCapture([&window] {
    const fs::path file = fs::temp_directory_path() / "crafter-debug-capture.png";
    window.CaptureNextFrame(file);
    return Debug::Async<std::vector<std::byte>>([&window, file]() -> std::optional<std::vector<std::byte>> {
        if (window.pendingCapture) return std::nullopt;            // the next Render writes it
        std::vector<std::byte> png(fs::file_size(file));
        std::ifstream(file, std::ios::binary).read(reinterpret_cast<char*>(png.data()), static_cast<std::streamsize>(png.size()));
        return png;
    });
});

Pumping: with Crafter.Graphics, call Debug::Pump() from window.onBeforeUpdate.

Hidden windows: a window that is hidden or minimized stops updating, and with it Pump. Give such windows window.updateInterval with PresentMode::Mailbox.

The PNG travels over the connection, and the client writes it where the agent asked. Paths aren't passed, because the app and the client are in different sandboxes, each with a private /tmp.

Your own types

A field can be any type with a codec. Codecs exist for:

  • bool, integers, floating point, std::string;
  • scoped enums, by name;
  • std::optional, std::vector, std::array, string-keyed maps;
  • Debug::Value, Debug::Required<T>, and aggregates.

For anything else, specialize Debug::Codec<T> with Encode, Decode and Describe; Crafter.Debug.Core-Codec.cppm shows the shape.

Reflection works without C++26 <meta>, so it has limits:

  • Aggregates only: public members, no bases with members, no reference or const members, default-constructible fields.
  • Enums: scoped enums get names for values in -128..127, and EnumRange widens that. Other enums travel as integers.

The agent side

From the consumer's directory:

./build/crafter-debug ls [--wait 60s]          ./build/crafter-debug wait '<cond>' | --frames n | --event E | --paused
./build/crafter-debug status                   ./build/crafter-debug trace <path> [--frames 60] [--history]
./build/crafter-debug cmds [glob]              ./build/crafter-debug log [--level warn] [--cat c] [--grep s] [--follow]
./build/crafter-debug help <name>              ./build/crafter-debug screenshot [-o file.png]
./build/crafter-debug call <cmd> k=v ...       ./build/crafter-debug pause [--when c] [--on-check [--trap]] [--clear]
./build/crafter-debug get <path|glob> ...      ./build/crafter-debug resume | step [n] | quit [--force]
./build/crafter-debug set <path> <value>       ./build/crafter-debug debugger | guide

./build/crafter-debug guide is the full reference.

Exit codes: 0 done, 2 usage, 3 no instance, 4 app and client speak different protocols, 5 timed out, 6 the app answered an error.

Timeouts: a request that times out before the app's next Pump is guaranteed not to have run. A request with no reply is diagnosed and the next step printed. The cases are:

  • the app has exited;
  • it is stopped in a debugger;
  • it no longer calls Pump.

Stopping an app:

  • quit ends it cleanly. quit --force ends it even when its main loop is stuck.
  • kill -KILL <sandbox pid> ends the whole -r sandbox from the host; ls shows that pid.
  • With --die-with-parent in the crafter-build alias, stopping the background -r task stops the app too.

Debuggers: crafter-debug debugger prints flatpak enter <instance> lldb -p <pid>, or gdb, which runs the debugger inside the app's own sandbox. A debugger started in another flatpak run cannot see the app's pid namespace. The session allows any process of the user to attach, which Yama's ptrace_scope 1 requires.

Version skew: the client is built with the app. If an app from an older build is still running, the client compares the protocol and source id recorded at discovery:

  • Different protocol: it refuses with exit 4, and prints the command that runs the app's own client.
  • Different sources: it only warns.

Developing Crafter.Debug

crafter-build test                  # every test
crafter-build test 'Should*Client*'
cd examples/Basic && crafter-build --debug-api -r
  • Library and core tests run in-process, against a Session built with listen = false and a fake transport.
  • ShouldRunClientCommands runs the real client against a real session that way.
  • ShouldServeOverQuic runs a listening session and drives it over loopback with the client's own QUIC transport. It covers discovery, calls, log --follow, wrong tokens, oversized requests, parallel requests, and shutdown with a client connected.
  • Crafter.Network comes from forgejo, or from ../Crafter.Network with --local.
  • AGENTS.template.md has the conventions.

Licensed LGPL-3.0-only; the examples are MIT.