diff --git a/CLAUDE.md b/CLAUDE.md
index 2bb6d7c3..83123970 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -130,7 +130,7 @@ If you are executing in a git worktree, stay within that worktree and do not att
- macOS stops compositing fully covered windows and `CAMetalLayer` stops returning drawables; any render attempt then blocks the main thread for the full ~1s `nextDrawable` timeout, stalling input and PTY processing. The frame loop gates rendering on the `SDL_WINDOW_OCCLUDED` window flag (`shouldRenderFrame` in `src/app/runtime.zig`); keep any new render/present paths behind the same gate.
### Adding New SDL3 Key Codes
-When adding references to SDL3 key codes (SDLK_*) or other SDL constants, always add them to `src/c.zig` first instead of searching the web for their values. SDL3 constants are exposed through the c_import and must be explicitly re-exported in c.zig to be accessible throughout the codebase.
+When adding references to SDL3 key codes (SDLK_*) or other SDL constants, always add them to `src/c.zig` first instead of searching the web for their values. The build system provides the translated SDL declarations through the `c_sdl` module, and SDL constants must be explicitly re-exported from `src/c.zig` to be accessible throughout the codebase.
**Pattern:**
```zig
@@ -181,6 +181,7 @@ const result = grid_row * GRID_COLS + grid_col; // usize, works correctly
- When hoisting shared locals (e.g., `cursor`) to wider scopes inside long functions, avoid re-declaring them later with the same name. Zig treats this as shadowing and fails compilation. Prefer a single binding per logical value or choose distinct names for nested scopes to prevent "local constant shadows" errors.
### Zig 0.16 API notes
+- `@cImport` is deprecated in Zig 0.16. C headers are translated through `addTranslateC` modules backed by the shims under `src/c/`.
- `std.ArrayList(T)` is the unmanaged list: init with `.empty` (or `initCapacity`), and pass the allocator to each method (`list.append(allocator, item)`). `std.ArrayListUnmanaged` is a deprecated alias — do not use it.
- `std.fs` retains only `path`, `max_path_bytes`, `max_name_bytes`, and the base64 alphabets. Every filesystem operation moved to `std.Io.Dir` / `std.Io.File` and takes an `io` argument. Two irregularities to remember: `Dir.renameAbsolute(old, new, io)` takes `io` **last**, and `makeDirAbsolute`/`makePath` were renamed to `createDirAbsolute`/`createDirPath` rather than merely gaining a parameter.
- `std.time` retains only its duration constants. Timestamps come from `clock.zig`, which wraps `std.Io.Timestamp.now(io, .real)`. The clock enum tags are `real`, `awake`, `boot`, `cpu_process`, `cpu_thread`.
diff --git a/build.zig b/build.zig
index 00f9b1ba..6746b302 100644
--- a/build.zig
+++ b/build.zig
@@ -62,6 +62,21 @@ pub fn build(b: *std.Build) void {
});
exe_mod.addImport("assets", assets_mod);
+ const c_sdl = addTranslateCModule(b, exe_mod, "c_sdl", "src/c/sdl.h", target, optimize);
+ const pty_header = switch (target.result.os.tag) {
+ .macos => "src/c/pty_macos.h",
+ .freebsd => "src/c/pty_freebsd.h",
+ else => "src/c/pty_linux.h",
+ };
+ _ = addTranslateCModule(b, exe_mod, "c_pty", pty_header, target, optimize);
+ _ = addTranslateCModule(b, exe_mod, "c_stdlib", "src/c/libc_stdlib.h", target, optimize);
+ _ = addTranslateCModule(b, exe_mod, "c_time", "src/c/libc_time.h", target, optimize);
+
+ if (target.result.os.tag == .macos) {
+ _ = addTranslateCModule(b, exe_mod, "c_libproc", "src/c/libproc.h", target, optimize);
+ _ = addTranslateCModule(b, exe_mod, "c_sysctl", "src/c/sysctl.h", target, optimize);
+ }
+
if (b.lazyDependency("ghostty", .{
.target = target,
.optimize = optimize,
@@ -98,6 +113,12 @@ pub fn build(b: *std.Build) void {
exe_mod.linkSystemLibrary("SDL3", .{});
exe_mod.linkSystemLibrary("SDL3_ttf", .{});
+ const framework_path: ?[]const u8 = if (target.result.os.tag == .macos)
+ if (findSdkRoot(b)) |sdk_root| b.fmt("{s}/System/Library/Frameworks", .{sdk_root}) else null
+ else
+ null;
+ addSdlPaths(b, exe_mod, c_sdl, framework_path);
+
if (target.result.os.tag == .macos) {
exe.headerpad_max_install_names = true;
mcp_exe.headerpad_max_install_names = true;
@@ -106,22 +127,6 @@ pub fn build(b: *std.Build) void {
exe_mod.linkFramework("Carbon", .{});
exe_mod.linkFramework("CoreFoundation", .{});
exe_mod.linkFramework("AppKit", .{});
-
- if (findSdkRoot(b)) |sdk_root| {
- const framework_path = b.fmt("{s}/System/Library/Frameworks", .{sdk_root});
- exe_mod.addFrameworkPath(.{ .cwd_relative = framework_path });
- }
- }
-
- if (b.graph.environ_map.get("SDL3_INCLUDE_PATH")) |sdl3_include| {
- exe_mod.addIncludePath(.{ .cwd_relative = sdl3_include });
- const lib_path = b.fmt("{s}/../lib", .{sdl3_include});
- exe_mod.addLibraryPath(.{ .cwd_relative = lib_path });
- }
- if (b.graph.environ_map.get("SDL3_TTF_INCLUDE_PATH")) |sdl3_ttf_include| {
- exe_mod.addIncludePath(.{ .cwd_relative = sdl3_ttf_include });
- const ttf_lib_path = b.fmt("{s}/../lib", .{sdl3_ttf_include});
- exe_mod.addLibraryPath(.{ .cwd_relative = ttf_lib_path });
}
b.installArtifact(exe);
@@ -169,6 +174,51 @@ pub fn build(b: *std.Build) void {
lint_step.dependOn(&run_zwanzig.step);
}
+fn addTranslateCModule(
+ b: *std.Build,
+ exe_mod: *std.Build.Module,
+ name: []const u8,
+ header: []const u8,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+) *std.Build.Step.TranslateC {
+ const translate_c = b.addTranslateC(.{
+ .root_source_file = b.path(header),
+ .target = target,
+ .optimize = optimize,
+ .link_libc = true,
+ });
+ exe_mod.addImport(name, translate_c.createModule());
+ return translate_c;
+}
+
+fn addSdlPaths(
+ b: *std.Build,
+ exe_mod: *std.Build.Module,
+ translate_c: *std.Build.Step.TranslateC,
+ framework_path: ?[]const u8,
+) void {
+ if (b.graph.environ_map.get("SDL3_INCLUDE_PATH")) |sdl3_include| {
+ const include_path: std.Build.LazyPath = .{ .cwd_relative = sdl3_include };
+ exe_mod.addIncludePath(include_path);
+ translate_c.addIncludePath(include_path);
+ const lib_path = b.fmt("{s}/../lib", .{sdl3_include});
+ exe_mod.addLibraryPath(.{ .cwd_relative = lib_path });
+ }
+ if (b.graph.environ_map.get("SDL3_TTF_INCLUDE_PATH")) |sdl3_ttf_include| {
+ const include_path: std.Build.LazyPath = .{ .cwd_relative = sdl3_ttf_include };
+ exe_mod.addIncludePath(include_path);
+ translate_c.addIncludePath(include_path);
+ const lib_path = b.fmt("{s}/../lib", .{sdl3_ttf_include});
+ exe_mod.addLibraryPath(.{ .cwd_relative = lib_path });
+ }
+ if (framework_path) |path| {
+ const framework_lazy_path: std.Build.LazyPath = .{ .cwd_relative = path };
+ exe_mod.addFrameworkPath(framework_lazy_path);
+ translate_c.addFrameworkPath(framework_lazy_path);
+ }
+}
+
// Prefer the active developer selection over hardcoded SDK locations so
// macOS SDK overrides in the dev shell stay local to the environment.
fn findSdkRoot(b: *std.Build) ?[]const u8 {
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 545dca84..b37785d3 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -22,7 +22,7 @@ graph TD
subgraph Platform Layer
SDL["platform/sdl.zig
SDL3 window, renderer, HiDPI"]
IM["input/mapper.zig
Keycodes to VT sequences"]
- CZIG["c.zig
C FFI re-exports"]
+ CZIG["c.zig
SDL C FFI re-exports from c_sdl"]
end
subgraph Session Layer
@@ -489,7 +489,7 @@ Rotate: rename active file to architect-.log and continue in new
| `app/*` (app_state, layout, ui_host, grid_nav, grid_layout, input_keys, input_text, terminal_actions, worktree) | Application logic decomposed by concern: state enums, grid sizing, UI snapshot building, navigation, input encoding, clipboard and submitted-paste construction, worktree commands (with configurable external directory and post-create init) | `ViewMode`, `AnimationState`, `SessionStatus`, `buildUiHost()`, `applyTerminalResize()`, `encodeKey()`, `pasteText()`, `buildSubmittedPaste()`, `clearTerminal()`, `resolveWorktreeDir()` | `geom`, `anim/easing`, `ui/types`, `ui/session_view_state`, `colors`, `input/mapper`, `session/state`, `c` |
| `platform/sdl.zig` | SDL3 initialization, window management, HiDPI | `init()`, `createWindow()`, `createRenderer()` | `c` |
| `input/mapper.zig` | SDL keycodes to VT escape sequences, shortcut detection | `encodeKey()`, modifier helpers | `c` |
-| `c.zig` | C FFI re-exports (SDL3, SDL3_ttf constants) | `SDLK_*`, `SDL_*`, `TTF_*` re-exports | SDL3 system libs (via `@cImport`) |
+| `c.zig` | SDL3 and SDL3_ttf FFI re-exports from the build-system `c_sdl` module | `SDLK_*`, `SDL_*`, `TTF_*` re-exports | `c_sdl`, SDL3 system libraries |
| `session/state.zig` | Terminal session lifecycle: PTY, ghostty-vt, process watcher, foreground agent detection, graceful agent teardown at quit, and main-thread ring-buffer consumption | `SessionState`, `AgentKind`, `init()`, `despawn()`, `deinit()`, `ensureSpawnedWithDir()`, `processOutput()`, `render_epoch`, `pending_write`, `detectForegroundAgent()`, `sendTermToForegroundPgrp()` | `shell`, `pty`, `pty_reader`, `vt_stream`, `cwd`, `font`, xev |
| `session/notify.zig` | Background notification socket thread and queue; handles status and story notifications | `NotificationQueue`, `Notification` (union: status/story), `startThread()`, `push()`, `drain()` | std (socket, thread) |
| `session/pty_reader.zig` | Background thread that `poll(2)`s spawned sessions' PTY master fds and drains readable ones into per-session SPSC ring buffers; registry with retire handshake so teardown can safely close fds | `PtyReader`, `PtyOutputBuffer`, `start()`, `register()`, `retire()` | std (poll, thread) |
@@ -574,7 +574,7 @@ Rotate: rename active file to architect-.log and continue in new
### ADR-006: SDL3 for Rendering and Input
- **Decision:** Use SDL3 as the platform abstraction layer for window management, GPU-accelerated 2D rendering, input events, and font rendering (via SDL3_ttf with HarfBuzz).
-- **Context:** The application needs cross-platform window management, hardware-accelerated texture rendering, and HiDPI support. SDL3 provides all of these with a C API that Zig can import directly via `@cImport`.
+- **Context:** The application needs cross-platform window management, hardware-accelerated texture rendering, and HiDPI support. SDL3 provides all of these with a C API whose declarations are translated at build time and re-exported through `c.zig`.
- **Alternatives considered:**
- *Native platform APIs (AppKit/Metal)* -- rejected because it locks the project to macOS; SDL3 allows future Linux/Windows porting.
- *Vulkan/OpenGL directly* -- rejected because 2D terminal rendering does not need low-level GPU control, and SDL3's renderer API is sufficient and simpler.
@@ -681,3 +681,13 @@ Rotate: rename active file to architect-.log and continue in new
- Process-exit detection can lag by up to 1 s (the idle ceiling).
- The output cadence cap is a constant (`output_render_interval_ns`) rather than config.
- **Date:** 2026-09
+
+### ADR-017: Build-System C Header Translation
+
+- **Decision:** Translate each C header site with a dedicated `std.Build.addTranslateC` step and a small shim under `src/c/`, then expose the generated declarations to the executable through named private modules. The SDL and SDL_ttf shim is shared by `c.zig`; PTY, libc, time, and macOS process APIs each retain their own module.
+- **Context:** Zig 0.16 provides build-system translation as the supported path for C headers. Architect also has platform-specific headers: the PTY shim varies by target, while the libproc and sysctl shims are only valid on macOS. SDL translation must share the executable's environment-provided include paths and macOS SDK framework path.
+- **Alternatives considered:**
+ - *One monolithic C translation module* -- rejected because a platform-specific or difficult header would make unrelated C declarations fail together and would require non-macOS builds to analyze macOS-only APIs.
+ - *Direct compiler-side header imports* -- rejected because the build-system translation step is the supported Zig 0.16 integration and makes target-specific inputs explicit in `build.zig`.
+- **Consequences:** Each C import site has an isolated translation failure boundary, target-specific headers are only created and imported when applicable, and `src/c.zig` remains the stable SDL symbol surface for the rest of the codebase. The executable continues to own the SDL3, SDL3_ttf, proc, and framework link configuration.
+- **Date:** 2026-09
diff --git a/docs/development.md b/docs/development.md
index b2460323..9033d3ca 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -73,6 +73,13 @@ zig build run -- --log-dir .tmp/architect-debug-logs
- **Zwanzig v0.15.1** is pinned as a Zig build dependency and runs as a host-targeted `ReleaseFast` build tool through `zig build lint`. Architect passes its requested target architecture and operating system to Zwanzig for target-aware analysis.
- **SDL3** and **SDL3_ttf** are provided by Nix. SDL3 is pinned to 3.4.10 via `overlays/sdl3-3-4-10.nix` with binaries cached in the public `forketyfork` Cachix to avoid rebuilds.
+SDL3 and the platform C APIs are translated at build time with Zig's built-in
+`addTranslateC` steps using the small header shims under `src/c/`. When SDL is
+provided outside the compiler's default search paths, `SDL3_INCLUDE_PATH` and
+`SDL3_TTF_INCLUDE_PATH` supply the include paths to both translation and
+compilation. On macOS, framework headers use the SDK path discovered from
+`SDKROOT`, `DEVELOPER_DIR`, or `xcrun`, in that order.
+
## Tests and Formatting
Run tests:
diff --git a/src/c.zig b/src/c.zig
index 1ee5add9..0b9463bd 100644
--- a/src/c.zig
+++ b/src/c.zig
@@ -1,10 +1,7 @@
-// Minimal re-export layer that isolates C includes so the rest of the codebase
-// can `@import("c.zig")` without pulling headers repeatedly.
+// Minimal re-export layer that keeps the translated SDL symbols in one place
+// so the rest of the codebase can `@import("c.zig")` without repeating names.
// zwanzig-disable: identifier-style
-const c_import = @cImport({
- @cInclude("SDL3/SDL.h");
- @cInclude("SDL3_ttf/SDL_ttf.h");
-});
+const c_import = @import("c_sdl");
pub const SDL_Init = c_import.SDL_Init;
pub const SDL_Quit = c_import.SDL_Quit;
diff --git a/src/c/libc_stdlib.h b/src/c/libc_stdlib.h
new file mode 100644
index 00000000..c8b49f26
--- /dev/null
+++ b/src/c/libc_stdlib.h
@@ -0,0 +1 @@
+#include
diff --git a/src/c/libc_time.h b/src/c/libc_time.h
new file mode 100644
index 00000000..91fd1871
--- /dev/null
+++ b/src/c/libc_time.h
@@ -0,0 +1 @@
+#include
diff --git a/src/c/libproc.h b/src/c/libproc.h
new file mode 100644
index 00000000..8547a67e
--- /dev/null
+++ b/src/c/libproc.h
@@ -0,0 +1,2 @@
+#include
+#include
diff --git a/src/c/pty_freebsd.h b/src/c/pty_freebsd.h
new file mode 100644
index 00000000..80029d27
--- /dev/null
+++ b/src/c/pty_freebsd.h
@@ -0,0 +1,2 @@
+#include
+#include
diff --git a/src/c/pty_linux.h b/src/c/pty_linux.h
new file mode 100644
index 00000000..8e12bf0a
--- /dev/null
+++ b/src/c/pty_linux.h
@@ -0,0 +1,2 @@
+#include
+#include
diff --git a/src/c/pty_macos.h b/src/c/pty_macos.h
new file mode 100644
index 00000000..d47a43e7
--- /dev/null
+++ b/src/c/pty_macos.h
@@ -0,0 +1,2 @@
+#include
+#include
diff --git a/src/c/sdl.h b/src/c/sdl.h
new file mode 100644
index 00000000..1b3df773
--- /dev/null
+++ b/src/c/sdl.h
@@ -0,0 +1,2 @@
+#include
+#include
diff --git a/src/c/sysctl.h b/src/c/sysctl.h
new file mode 100644
index 00000000..db7fac84
--- /dev/null
+++ b/src/c/sysctl.h
@@ -0,0 +1,3 @@
+#include
+#include
+#include
diff --git a/src/cwd.zig b/src/cwd.zig
index e8b02fab..23a0b7e8 100644
--- a/src/cwd.zig
+++ b/src/cwd.zig
@@ -16,10 +16,7 @@ pub const CwdError = error{
OutOfMemory,
};
-const c = @cImport({
- @cInclude("libproc.h");
- @cInclude("sys/proc_info.h");
-});
+const c = @import("c_libproc");
pub fn getCwd(allocator: std.mem.Allocator, pid: std.c.pid_t) CwdError![]const u8 {
var vnode_info: c.struct_proc_vnodepathinfo = undefined;
diff --git a/src/logging.zig b/src/logging.zig
index 70b2a943..44ad0815 100644
--- a/src/logging.zig
+++ b/src/logging.zig
@@ -5,9 +5,7 @@ const Dir = std.Io.Dir;
const File = std.Io.File;
const clock = @import("clock.zig");
const env = @import("env.zig");
-const time_c = @cImport({
- @cInclude("time.h");
-});
+const time_c = @import("c_time");
pub const active_log_filename = "architect.log";
pub const default_max_file_size_bytes: u64 = 10 * 1024 * 1024;
diff --git a/src/pty.zig b/src/pty.zig
index 9688e9ce..7595a32a 100644
--- a/src/pty.zig
+++ b/src/pty.zig
@@ -34,20 +34,7 @@ const PosixPty = struct {
const TIOCSWINSZ = if (builtin.os.tag == .macos) 2148037735 else c.TIOCSWINSZ;
const TIOCGWINSZ = if (builtin.os.tag == .macos) 1074295912 else c.TIOCGWINSZ;
extern "c" fn setsid() std.c.pid_t;
- const c = switch (builtin.os.tag) {
- .macos => @cImport({
- @cInclude("sys/ioctl.h");
- @cInclude("util.h");
- }),
- .freebsd => @cImport({
- @cInclude("termios.h");
- @cInclude("libutil.h");
- }),
- else => @cImport({
- @cInclude("sys/ioctl.h");
- @cInclude("pty.h");
- }),
- };
+ const c = @import("c_pty");
master: Fd,
slave: Fd,
diff --git a/src/session/state.zig b/src/session/state.zig
index e06f9610..aeea210c 100644
--- a/src/session/state.zig
+++ b/src/session/state.zig
@@ -15,11 +15,7 @@ const vt_stream = @import("../vt_stream.zig");
const pty_reader_mod = @import("pty_reader.zig");
const posix_util = @import("../posix_util.zig");
const mac = if (builtin.os.tag == .macos)
- @cImport({
- @cInclude("sys/types.h");
- @cInclude("sys/sysctl.h");
- @cInclude("sys/proc.h");
- })
+ @import("c_sysctl")
else
struct {};
diff --git a/src/shell.zig b/src/shell.zig
index 2637b78d..4affd169 100644
--- a/src/shell.zig
+++ b/src/shell.zig
@@ -8,9 +8,7 @@ const env = @import("env.zig");
const proc = @import("proc.zig");
const pty_mod = @import("pty.zig");
const posix_util = @import("posix_util.zig");
-const libc = @cImport({
- @cInclude("stdlib.h");
-});
+const libc = @import("c_stdlib");
const log = std.log.scoped(.shell);