summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-23 09:38:23 -0600
committert <t@tjp.lol>2026-06-23 10:53:26 -0600
commit69a1ee138bb78ad4663fe2d9e58678f7b0d07b74 (patch)
tree453d3ad42a07536220e6ca5d91b439ff57793e32 /src
parent538a5d926fa626a00cc3dc12c555f11f82867efd (diff)
ponytail simplifications to panto cli and lua extensiosn
Diffstat (limited to 'src')
-rw-r--r--src/auth_manager.zig4
-rw-r--r--src/config_file.zig105
-rw-r--r--src/debug_log.zig41
-rw-r--r--src/extension_loader.zig74
-rw-r--r--src/lua_event_bridge.zig10
-rw-r--r--src/lua_runtime.zig53
-rw-r--r--src/luarocks_runtime.zig12
-rw-r--r--src/main.zig23
-rw-r--r--src/models_toml.zig97
-rw-r--r--src/panto_home.zig43
-rw-r--r--src/self_exe.zig90
-rw-r--r--src/session_paths.zig11
-rw-r--r--src/subcommand.zig23
-rw-r--r--src/system_prompt.zig6
-rw-r--r--src/toml_layer.zig68
-rw-r--r--src/tui_component.zig11
-rw-r--r--src/tui_components.zig6
-rw-r--r--src/tui_event.zig35
18 files changed, 239 insertions, 473 deletions
diff --git a/src/auth_manager.zig b/src/auth_manager.zig
index 8889ca8..f349801 100644
--- a/src/auth_manager.zig
+++ b/src/auth_manager.zig
@@ -5,7 +5,7 @@
//!
//! - `api_key` sessions are already resolved at config load (literal/env);
//! nothing to do here.
-//! - `oauth_device` sessions are loaded from `$PANTO_HOME/auth/<name>.json`,
+//! - `oauth_device` sessions are loaded from `<data home>/auth/<name>.json`,
//! refreshed if the access token is near expiry, run through the optional
//! secondary exchange (Copilot) if that token is stale, then turned into a
//! `ResolvedCredential` (bearer + dynamic base_url + auth-derived headers).
@@ -40,7 +40,7 @@ pub const AuthManager = struct {
gpa: Allocator,
io: Io,
client: *std.http.Client,
- /// `$PANTO_HOME/auth`. Borrowed for the manager's lifetime.
+ /// `<data home>/auth`. Borrowed for the manager's lifetime.
auth_dir: []const u8,
file_cfg: *const config_file.Config,
/// Arena for the resolved credential strings the live config borrows.
diff --git a/src/config_file.zig b/src/config_file.zig
index 6d4b705..5bd2a51 100644
--- a/src/config_file.zig
+++ b/src/config_file.zig
@@ -54,15 +54,12 @@ const Io = std.Io;
const toml = @import("toml");
const panto = @import("panto");
+const tvalue = toml.value_mod;
const glob = @import("glob.zig");
const models_toml = @import("models_toml.zig");
-
-// Document-building helpers live in the toml library's `value` module and
-// are not re-exported at its root. `tableIterator` *is* re-exported, so we
-// use `toml.tableIterator` directly but reach through `value_mod` for the
-// constructors/mutators.
-const tvalue = toml.value_mod;
+const panto_home = @import("panto_home.zig");
+const toml_layer = @import("toml_layer.zig");
pub const APIStyle = panto.APIStyle;
@@ -406,22 +403,11 @@ fn layerPaths(
return .{ .base = base, .user = user, .project = project, .local = local };
}
-/// `$PANTO_HOME/config.toml`, falling back to
/// `${XDG_DATA_HOME:-$HOME/.local/share}/panto/config.toml`.
-///
-/// `PANTO_HOME` is checked first so this always agrees with where the
-/// bootstrap stages the default base config (see `panto_home.resolveHome`).
pub fn baseConfigPath(allocator: Allocator, environ_map: *const std.process.Environ.Map) Error![]u8 {
- if (environ_map.get("PANTO_HOME")) |home| {
- return try std.fs.path.join(allocator, &.{ home, "config.toml" });
- }
- if (environ_map.get("XDG_DATA_HOME")) |xdg| {
- return try std.fs.path.join(allocator, &.{ xdg, "panto", "config.toml" });
- }
- if (environ_map.get("HOME")) |home| {
- return try std.fs.path.join(allocator, &.{ home, ".local", "share", "panto", "config.toml" });
- }
- return error.NoHomeDirectory;
+ const home = try panto_home.homePath(allocator, environ_map);
+ defer allocator.free(home);
+ return try std.fs.path.join(allocator, &.{ home, "config.toml" });
}
/// `${XDG_CONFIG_HOME:-$HOME/.config}/panto/config.toml`.
@@ -461,73 +447,23 @@ pub fn loadFromPaths(
defer merged.deinit();
for (paths) |path| {
- const bytes = readFileAlloc(allocator, io, path) catch |err| switch (err) {
+ const bytes = toml_layer.readFileAlloc(allocator, io, path) catch |err| switch (err) {
error.FileNotFound => continue,
- else => return err,
+ error.ReadFailed => return error.ConfigReadFailed,
+ error.Canceled => return error.Canceled,
+ error.OutOfMemory => return error.OutOfMemory,
};
defer allocator.free(bytes);
const doc = parseDoc(allocator, bytes) catch return error.InvalidConfigToml;
defer doc.deinit();
- try mergeTable(merged.allocator(), merged.root, doc.root);
+ try toml_layer.mergeTable(merged.allocator(), merged.root, doc.root);
}
return resolve(allocator, environ_map, merged.root);
}
// ===========================================================================
-// Merge
-// ===========================================================================
-
-/// Deep-merge `src` table into `dst` table (both must be `.table`).
-/// Tables recurse; everything else (scalars, arrays) overwrites. Values are
-/// deep-copied into `alloc` (the merged document's arena) so they outlive
-/// the source document.
-fn mergeTable(alloc: Allocator, dst: *toml.Value, src: *const toml.Value) Allocator.Error!void {
- std.debug.assert(dst.* == .table);
- if (src.* != .table) return;
-
- var it = toml.tableIterator(src);
- while (it.next()) |entry| {
- const existing = dst.get(entry.key);
- if (existing != null and existing.?.* == .table and entry.value.* == .table) {
- // Both sides are tables — recurse to accumulate keys.
- try mergeTable(alloc, @constCast(existing.?), entry.value);
- } else {
- // Overwrite (or insert) with a deep copy of the source value.
- const copy = try cloneValue(alloc, entry.value);
- const key_copy = try alloc.dupe(u8, entry.key);
- try tvalue.tableSet(alloc, dst, key_copy, copy);
- }
- }
-}
-
-fn cloneValue(alloc: Allocator, src: *const toml.Value) Allocator.Error!*toml.Value {
- const out = try alloc.create(toml.Value);
- switch (src.*) {
- .table => {
- out.* = .{ .table = .{} };
- var it = toml.tableIterator(src);
- while (it.next()) |entry| {
- const child = try cloneValue(alloc, entry.value);
- const key_copy = try alloc.dupe(u8, entry.key);
- try tvalue.tableSet(alloc, out, key_copy, child);
- }
- },
- .array => |*a| {
- out.* = .{ .array = .{} };
- for (a.items.items) |*item| {
- const child = try cloneValue(alloc, item);
- try tvalue.arrayAppend(alloc, out, child.*);
- }
- },
- .string => |s| out.* = .{ .string = try alloc.dupe(u8, s) },
- else => out.* = src.*,
- }
- return out;
-}
-
-// ===========================================================================
// Resolve
// ===========================================================================
@@ -956,26 +892,9 @@ pub fn parseModelRef(ref: []const u8) Error!ModelRef {
}
// ===========================================================================
-// File / parse helpers
+// Parse helpers
// ===========================================================================
-fn readFileAlloc(allocator: Allocator, io: Io, path: []const u8) (Allocator.Error || FileError)![]u8 {
- const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) {
- error.FileNotFound => return error.FileNotFound,
- error.Canceled => return error.Canceled,
- else => return error.ConfigReadFailed,
- };
- defer file.close(io);
- const len = file.length(io) catch return error.ConfigReadFailed;
- const bytes = try allocator.alloc(u8, @intCast(len));
- errdefer allocator.free(bytes);
- _ = file.readPositionalAll(io, bytes, 0) catch |err| switch (err) {
- error.Canceled => return error.Canceled,
- else => return error.ConfigReadFailed,
- };
- return bytes;
-}
-
fn parseDoc(allocator: Allocator, source: []const u8) !*toml.Document {
const result = toml.parseWithError(allocator, source, .{});
switch (result) {
diff --git a/src/debug_log.zig b/src/debug_log.zig
index 0adbe63..84a2ac8 100644
--- a/src/debug_log.zig
+++ b/src/debug_log.zig
@@ -1,18 +1,17 @@
-//! Debug-build log redirection.
+//! Optional log redirection.
//!
-//! Debug builds emit a *lot* of `std.log.debug` traffic — most usefully the
-//! raw JSON of every provider API request and response. Writing that to stderr
-//! makes the interactive TUI unusable (every line scribbles over the frame).
+//! `PANTO_DEBUG != 0` captures `std.log` traffic — most usefully the raw JSON
+//! of every provider API request and response — to a per-session file. Writing
+//! that to stderr makes the interactive TUI unusable.
//!
//! This module installs a custom `std.Options.logFn` (wired up via
-//! `std_options` in `main.zig`). In **Debug** builds it routes the entire log
-//! stream to a per-session file under
+//! `std_options` in `main.zig`). When enabled it routes the entire log stream
+//! to a per-session file under
//!
-//! ($PANTO_HOME | $XDG_DATA_HOME/panto | ~/.local/share/panto)/debug/<session-id>.log
+//! ($XDG_DATA_HOME/panto | ~/.local/share/panto)/debug/<session-id>.log
//!
-//! and writes *nothing* to the terminal, leaving the TUI pristine. In any
-//! other build mode it delegates to `std.log.defaultLog` (stderr, level-gated)
-//! so release behavior is unchanged.
+//! and writes nothing to the terminal. Otherwise it delegates to
+//! `std.log.defaultLog`.
//!
//! The file is opened with `O_TRUNC`, so each session starts fresh; the file
//! holds exactly one session's logs and never grows across runs. (A *new*
@@ -26,19 +25,16 @@
//! approach already used by `tui_terminal.zig`.
const std = @import("std");
-const builtin = @import("builtin");
const Io = std.Io;
const Allocator = std.mem.Allocator;
const panto_home = @import("panto_home.zig");
-/// True only in Debug builds; gates the file-redirect path at comptime.
-const enabled = builtin.mode == .Debug;
-
/// A tiny atomic spinlock serializing writes. `logFn` can be called from any
/// thread but has no `Io` handle, so `Io.Mutex` (which needs one) is out;
/// contention is rare and each critical section is a single bounded write.
var log_lock = std.atomic.Value(bool).init(false);
+var capture_enabled = std.atomic.Value(bool).init(false);
fn lockLog() void {
while (log_lock.cmpxchgWeak(false, true, .acquire, .monotonic) != null) {
@@ -58,19 +54,19 @@ var seq: u64 = 0;
/// Buffer backing the per-call `Writer`. Guarded by `log_mutex`.
var writer_buf: [16 * 1024]u8 = undefined;
-/// Open the per-session debug log file and arm `logFn` to write to it. No-op
-/// in non-Debug builds. Best-effort: any failure leaves `log_fd == -1`, and
-/// `logFn` silently drops messages (debug logging must never break startup).
+/// Open the per-session debug log file and arm `logFn` to write to it when
+/// `PANTO_DEBUG != 0`. Best-effort: any failure leaves normal logging in place.
///
/// Safe to call once, after the session id is known. `environ_map` is used to
-/// resolve `$PANTO_HOME`; `io` is used only to create the `debug/` directory.
+/// resolve the data home; `io` is used only to create the `debug/` directory.
pub fn init(
allocator: Allocator,
io: Io,
environ_map: *const std.process.Environ.Map,
session_id: []const u8,
) void {
- if (!enabled) return;
+ const debug = environ_map.get("PANTO_DEBUG") orelse return;
+ if (debug.len == 0 or std.mem.eql(u8, debug, "0")) return;
var layout = panto_home.resolve(allocator, environ_map) catch return;
defer layout.deinit();
@@ -96,17 +92,18 @@ pub fn init(
defer unlockLog();
log_fd = fd;
seq = 0;
+ capture_enabled.store(true, .release);
}
-/// Custom `std.Options.logFn`. Debug builds capture everything to the session
-/// file (terminal stays clean); other builds use the stderr default.
+/// Custom `std.Options.logFn`. `PANTO_DEBUG != 0` captures everything to the
+/// session file; otherwise use the stderr default.
pub fn logFn(
comptime level: std.log.Level,
comptime scope: @EnumLiteral(),
comptime format: []const u8,
args: anytype,
) void {
- if (!enabled) {
+ if (!capture_enabled.load(.acquire)) {
std.log.defaultLog(level, scope, format, args);
return;
}
diff --git a/src/extension_loader.zig b/src/extension_loader.zig
index 0ab8091..87eb609 100644
--- a/src/extension_loader.zig
+++ b/src/extension_loader.zig
@@ -6,13 +6,13 @@
//!
//! Extensions (full-featured; call `panto.ext.register_tool` from a script
//! that may register many tools):
-//! 1. `$PANTO_HOME/agent/extensions/` ("base")
+//! 1. `<data home>/agent/extensions/` ("base")
//! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user")
//! 3. `./.panto/extensions/` ("project")
//!
//! Tools (ergonomic single-tool form; the script returns one table
//! shaped like the argument to `panto.ext.register_tool`):
-//! 1. `$PANTO_HOME/agent/tools/` ("base")
+//! 1. `<data home>/agent/tools/` ("base")
//! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/tools/` ("user")
//! 3. `./.panto/tools/` ("project")
//!
@@ -124,7 +124,7 @@ pub const Kind = enum {
///
/// `base_agent_dir`, when non-null, is the path under which embedded
/// base tools/extensions have been staged — typically
-/// `$PANTO_HOME/agent/`. Pass `null` to skip the base layer entirely
+/// `<data home>/agent/`. Pass `null` to skip the base layer entirely
/// (mostly useful for tests).
///
/// `environ_map` is consulted for `HOME` and `XDG_CONFIG_HOME`. The
@@ -372,16 +372,19 @@ fn classifyFile(
if (!std.mem.endsWith(u8, entry_name, ".lua")) return null;
const base = entry_name[0 .. entry_name.len - ".lua".len];
if (base.len == 0) return null;
+ if (base[0] == '_') return null;
const script_path = try std.fs.path.join(allocator, &.{ dir_path, entry_name });
errdefer allocator.free(script_path);
const name = try allocator.dupe(u8, base);
errdefer allocator.free(name);
+ const package_root = try allocator.dupe(u8, dir_path);
+ errdefer allocator.free(package_root);
return Found{
.name = name,
.script_path = script_path,
- .package_root = null,
+ .package_root = package_root,
.source = source,
.kind = kind,
};
@@ -445,44 +448,42 @@ const ShadowKeyCtx = struct {
};
fn applyShadowing(allocator: Allocator, list: *std.array_list.Managed(Found)) !void {
- var latest: std.HashMap(ShadowKey, usize, ShadowKeyCtx, std.hash_map.default_max_load_percentage) = .init(allocator);
+ const keep = try allocator.alloc(bool, list.items.len);
+ defer allocator.free(keep);
+ @memset(keep, false);
- for (list.items, 0..) |f, i| {
- try latest.put(.{ .kind = f.kind, .name = f.name }, i);
- }
+ {
+ var latest: std.HashMap(ShadowKey, usize, ShadowKeyCtx, std.hash_map.default_max_load_percentage) = .init(allocator);
+ defer latest.deinit();
- var keep: std.array_list.Managed(Found) = .init(allocator);
- var drop: std.array_list.Managed(Found) = .init(allocator);
- errdefer {
- latest.deinit();
- for (keep.items) |*f| f.deinit(allocator);
- keep.deinit();
- for (drop.items) |*f| f.deinit(allocator);
- drop.deinit();
+ for (list.items, 0..) |f, i| {
+ try latest.put(.{ .kind = f.kind, .name = f.name }, i);
+ }
+
+ for (list.items, 0..) |f, i| {
+ const winner = latest.get(.{ .kind = f.kind, .name = f.name }).?;
+ if (winner == i) {
+ keep[i] = true;
+ } else {
+ std.log.debug(
+ "{s}: '{s}' from {s} shadowed by {s}",
+ .{ f.kind.label(), f.name, f.source.label(), list.items[winner].source.label() },
+ );
+ }
+ }
}
- try keep.ensureTotalCapacity(list.items.len);
- try drop.ensureTotalCapacity(list.items.len);
- for (list.items, 0..) |f, i| {
- const winner = latest.get(.{ .kind = f.kind, .name = f.name }).?;
- if (winner == i) {
- keep.appendAssumeCapacity(f);
+ var write: usize = 0;
+ for (list.items, keep) |f, k| {
+ if (k) {
+ list.items[write] = f;
+ write += 1;
} else {
- std.log.debug(
- "{s}: '{s}' from {s} shadowed by {s}",
- .{ f.kind.label(), f.name, f.source.label(), list.items[winner].source.label() },
- );
- drop.appendAssumeCapacity(f);
+ var dropped = f;
+ dropped.deinit(allocator);
}
}
-
- latest.deinit();
- for (drop.items) |*f| f.deinit(allocator);
- drop.deinit();
-
- list.clearRetainingCapacity();
- list.appendSlice(keep.items) catch unreachable;
- keep.deinit();
+ list.shrinkRetainingCapacity(write);
}
// ---------------------------------------------------------------------------
@@ -527,6 +528,7 @@ test "scanDir picks up single-file and directory-style extensions" {
try makeDir(tmp.dir, "ext_root");
try makeDir(tmp.dir, "ext_root/beta");
try writeFile(tmp.dir, "ext_root/alpha.lua", "-- alpha\n");
+ try writeFile(tmp.dir, "ext_root/_helper.lua", "-- helper module\n");
try writeFile(tmp.dir, "ext_root/beta/init.lua", "-- beta init\n");
try writeFile(tmp.dir, "ext_root/beta/helper.lua", "-- helper\n");
try writeFile(tmp.dir, "ext_root/.ignored.lua", "-- hidden\n");
@@ -552,7 +554,7 @@ test "scanDir picks up single-file and directory-style extensions" {
}.lt);
try testing.expectEqualStrings("alpha", list.items[0].name);
- try testing.expect(list.items[0].package_root == null);
+ try testing.expect(list.items[0].package_root != null);
try testing.expect(std.mem.endsWith(u8, list.items[0].script_path, "alpha.lua"));
try testing.expectEqualStrings("beta", list.items[1].name);
diff --git a/src/lua_event_bridge.zig b/src/lua_event_bridge.zig
index 26a4646..044dd55 100644
--- a/src/lua_event_bridge.zig
+++ b/src/lua_event_bridge.zig
@@ -670,13 +670,7 @@ fn cacheLines(cache: *RenderCache) []const []const u8 {
/// C thunk installed as `panto.ext.emit` by the runtime (`installEmit`),
/// carrying the `*EventBridge` as a light-userdata upvalue. Fires a custom
-/// event on the native bus so native AND Lua handlers run. The `data`
-/// argument is currently surfaced to handlers only as a `.custom` payload
-/// (opaque); structured custom-data marshalling can be added later. The
-/// chosen component (if any handler set one) is NOT auto-mounted here —
-/// imperative mounting from a bare `emit` is a future refinement; for now
-/// `emit` drives handler side effects and component selection for events
-/// the app fires at real component-creation boundaries.
+/// event on the native bus so native and Lua handlers run.
pub fn emitThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
const L = L_opt.?;
const self_ptr = c.lua_touserdata(L, c.lua_upvalueindex(1));
@@ -689,7 +683,7 @@ pub fn emitThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
if (nptr == null) return c.luaL_error(L, "emit: first argument must be an event name string");
const name = nptr[0..nlen];
- var ev = Event.init(name, null, .{ .custom = .{} });
+ var ev = Event.init(name, null, .{ .custom = {} });
_ = bus.emit(&ev);
return 0;
}
diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig
index 35247bb..56ce0db 100644
--- a/src/lua_runtime.zig
+++ b/src/lua_runtime.zig
@@ -30,6 +30,7 @@ const Allocator = std.mem.Allocator;
const panto = @import("panto");
const lua_bridge = @import("lua_bridge.zig");
const lua_event_bridge = @import("lua_event_bridge.zig");
+const panto_home = @import("panto_home.zig");
const ui_event = @import("tui_event.zig");
const c = lua_bridge.c;
@@ -1630,18 +1631,24 @@ test "yielding handler with no event loop surfaces LuaHandlerYielded" {
try testing.expect(std.mem.indexOf(u8, okText(results[0]), "LuaHandlerYielded") != null);
}
-// Integration test: requires a `$PANTO_HOME` with luv already
-// installed. Skipped if luv isn't on disk — unit tests stay offline.
+// Integration test: requires a data home with luv already installed.
+// Skipped if luv isn't on disk — unit tests stay offline.
test "scheduler: yielding handler is resumed by libuv" {
- const home_z = std.c.getenv("PANTO_HOME") orelse return error.SkipZigTest;
- const panto_home_env = std.mem.sliceTo(home_z, 0);
+ var env = try processDataHomeEnv(testing.allocator);
+ defer env.deinit();
+ const data_home = panto_home.homePath(testing.allocator, &env) catch |err| switch (err) {
+ error.NoHomeDirectory => return error.SkipZigTest,
+ else => return err,
+ };
+ defer testing.allocator.free(data_home);
+
// Check for `<home>/rocks/lua-<version>/lib/lua/5.4/luv.so`.
const manifest = @import("manifest.zig");
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const so_path = try std.fmt.bufPrint(
&path_buf,
"{s}/rocks/lua-{s}/lib/lua/{s}/luv.so",
- .{ panto_home_env, manifest.lua_version, manifest.lua_short_version },
+ .{ data_home, manifest.lua_version, manifest.lua_short_version },
);
std.Io.Dir.cwd().access(testing.io, so_path, .{}) catch return error.SkipZigTest;
@@ -1673,12 +1680,7 @@ test "scheduler: yielding handler is resumed by libuv" {
defer rt.deinit();
// Bootstrap luarocks (so `require("luv")` works), then install
- // the scheduler. We use the real environment so the test picks
- // up the same PANTO_HOME the developer's machine has.
- var env: std.process.Environ.Map = .init(testing.allocator);
- defer env.deinit();
- try env.put("PANTO_HOME", panto_home_env);
-
+ // the scheduler from the normal data-home location.
const luarocks_runtime = @import("luarocks_runtime.zig");
// The bootstrap needs a panto executable path for the wrapper
// script; tests don't actually invoke it, so a placeholder is
@@ -1777,20 +1779,23 @@ fn findAgentToolsDir() ![]const u8 {
}
fn bootstrapRealRuntime(rt: *LuaRuntime) !*@import("luarocks_runtime.zig").LuarocksRuntime {
- const home_z = std.c.getenv("PANTO_HOME") orelse return error.SkipZigTest;
- const panto_home_env = std.mem.sliceTo(home_z, 0);
+ var env = try processDataHomeEnv(testing.allocator);
+ errdefer env.deinit();
+ const data_home = panto_home.homePath(testing.allocator, &env) catch |err| switch (err) {
+ error.NoHomeDirectory => return error.SkipZigTest,
+ else => return err,
+ };
+ defer testing.allocator.free(data_home);
+
const manifest = @import("manifest.zig");
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const so_path = try std.fmt.bufPrint(
&path_buf,
"{s}/rocks/lua-{s}/lib/lua/{s}/luv.so",
- .{ panto_home_env, manifest.lua_version, manifest.lua_short_version },
+ .{ data_home, manifest.lua_version, manifest.lua_short_version },
);
std.Io.Dir.cwd().access(testing.io, so_path, .{}) catch return error.SkipZigTest;
- var env: std.process.Environ.Map = .init(testing.allocator);
- defer env.deinit();
- try env.put("PANTO_HOME", panto_home_env);
const luarocks_runtime = @import("luarocks_runtime.zig");
const luarocks_rt = try luarocks_runtime.bootstrap(
testing.allocator,
@@ -1799,10 +1804,24 @@ fn bootstrapRealRuntime(rt: *LuaRuntime) !*@import("luarocks_runtime.zig").Luaro
rt.L,
"/usr/bin/true",
);
+ env.deinit();
try rt.installScheduler();
return luarocks_rt;
}
+fn processDataHomeEnv(allocator: Allocator) !std.process.Environ.Map {
+ var env = std.process.Environ.Map.init(allocator);
+ errdefer env.deinit();
+
+ if (std.c.getenv("XDG_DATA_HOME")) |value| {
+ try env.put("XDG_DATA_HOME", std.mem.sliceTo(value, 0));
+ }
+ if (std.c.getenv("HOME")) |value| {
+ try env.put("HOME", std.mem.sliceTo(value, 0));
+ }
+ return env;
+}
+
// Reproduction: two REAL `std.shell` calls in one batch. shell.lua
// uses spawn + two pipes + a timeout timer (3+ libuv events per call)
// and resumes its own coroutine from a libuv callback. This exercises
diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig
index 7991e4e..1505b8f 100644
--- a/src/luarocks_runtime.zig
+++ b/src/luarocks_runtime.zig
@@ -2,7 +2,7 @@
//!
//! Responsibilities at startup (per LUA_MAKEOVER.md steps 3-5 and Q1-Q5):
//!
-//! 1. Resolve `$PANTO_HOME` and the per-Lua-version rocks tree
+//! 1. Resolve the panto data home and the per-Lua-version rocks tree
//! (`panto_home.zig`). Create the directory layout if missing.
//! 2. Stage Lua headers under `<tree>/include/` (from `@embedFile`)
//! so luarocks can compile C rocks against them. Idempotent: a
@@ -21,7 +21,7 @@
//! 7. Reconcile the batteries manifest: for each pinned rock, check
//! `<tree>/lib/luarocks/rocks-<short>/<name>/<version>/` and invoke
//! `luarocks install` for anything missing. (Slow path; only the
-//! first run after a fresh `$PANTO_HOME` actually downloads.)
+//! first run after a fresh data home actually downloads.)
//!
//! Step 7 needs a usable `lua` executable on PATH from luarocks's point
//! of view \u2014 it shells out for rockspec build scripts. We satisfy
@@ -330,7 +330,7 @@ fn ensurePantoModuleSignature(
// ---------------------------------------------------------------------------
/// Materialize the binary-embedded `agent/` tree under
-/// `$PANTO_HOME/agent/`. Each entry's `path` is relative to the agent
+/// `<data home>/agent/`. Each entry's `path` is relative to the agent
/// root and may include subdirectories (`tools/read.lua` etc.); the
/// parent directory is created lazily as we encounter files inside it.
///
@@ -360,7 +360,7 @@ fn stageAgentTree(
}
}
-/// The default base `config.toml`, materialized at `$PANTO_HOME/config.toml`
+/// The default base `config.toml`, materialized at `<data home>/config.toml`
/// on first run if absent. It declares OpenAI and Anthropic providers, each
/// naming an `[auth.<name>]` session whose key comes from the conventional
/// env var — so a provider's first request fails with a clear auth error
@@ -463,7 +463,7 @@ const default_base_config =
\\
;
-/// Materialize the default base config at `$PANTO_HOME/config.toml`,
+/// Materialize the default base config at `<data home>/config.toml`,
/// but only if no file exists there yet. We never overwrite — the base
/// config is user-editable, and a stale-but-edited file must win over the
/// shipped default.
@@ -579,7 +579,7 @@ fn writeShellQuoted(w: anytype, s: []const u8) !void {
/// Materialize a luarocks config-<short>.lua under `layout.sysconfdir`.
/// This is the file luarocks reads from `SYSCONFDIR`; we point every
/// path-typed variable at the in-tree directories so installs land in
-/// `$PANTO_HOME/rocks/lua-X.Y.Z/`.
+/// `<data home>/rocks/lua-X.Y.Z/`.
///
/// Format reference: docs/config_file_format.md in the luarocks repo.
fn writeLuarocksConfig(
diff --git a/src/main.zig b/src/main.zig
index 4b26a22..2c5b414 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -8,7 +8,6 @@ const lua_event_bridge = @import("lua_event_bridge.zig");
const extension_loader = @import("extension_loader.zig");
const panto_home = @import("panto_home.zig");
const luarocks_runtime = @import("luarocks_runtime.zig");
-const self_exe = @import("self_exe.zig");
const subcommand = @import("subcommand.zig");
const session_paths = @import("session_paths.zig");
const models_toml = @import("models_toml.zig");
@@ -20,9 +19,8 @@ const command = @import("command.zig");
const command_compaction = @import("compaction.zig");
const debug_log = @import("debug_log.zig");
-/// Route the process-wide log stream. In Debug builds this redirects every
-/// `std.log` call to a per-session file (keeping the TUI clean); other builds
-/// keep the stderr default. See `debug_log.zig`.
+/// Route the process-wide log stream. `PANTO_DEBUG!=0` redirects it to a
+/// per-session file; otherwise this keeps the stderr default.
pub const std_options: std.Options = .{ .logFn = debug_log.logFn };
// TUI foundation layer (Phase 1, sub-phase 1). Not yet wired into the REPL;
@@ -59,7 +57,6 @@ test {
_ = extension_loader;
_ = panto_home;
_ = luarocks_runtime;
- _ = self_exe;
_ = subcommand;
_ = models_toml;
_ = config_file;
@@ -169,7 +166,7 @@ pub fn main(init: std.process.Init) !void {
// Resolve the absolute path of the running panto binary. Needed
// both by `panto lua` (we re-exec ourselves through a wrapper
// luarocks invokes) and by the agent's bootstrap.
- const panto_path = try self_exe.selfExePathAlloc(alloc);
+ const panto_path = try std.process.executablePathAlloc(io, alloc);
defer alloc.free(panto_path);
// Subcommand dispatch: `panto lua` and `panto bootstrap` short
@@ -289,10 +286,10 @@ pub fn main(init: std.process.Init) !void {
// `session.info` is adopted by the agent below (`Agent.init` can't fail)
// and freed in the agent's `deinit`; no separate cleanup here.
- // Arm the per-session debug log now that we know the session id. In Debug
- // builds this opens `$PANTO_HOME/debug/<id>.log` and redirects all
- // `std.log` output there; no-op in release. Best-effort — failures leave
- // logging disabled rather than aborting startup.
+ // Arm the per-session debug log now that we know the session id.
+ // PANTO_DEBUG!=0 redirects `std.log` output to
+ // `<data home>/debug/<id>.log`. Best-effort: failures leave normal
+ // logging in place.
debug_log.init(alloc, io, init.environ_map, session.info.id);
const is_resume = cli_flags.resume_kind != .none and session.info.message_count > 0;
@@ -341,7 +338,7 @@ pub fn main(init: std.process.Init) !void {
// Bootstrap luarocks against the Lua runtime's lua_State — same
// pipeline as `panto lua` and `panto bootstrap`. After this,
// `require("luarocks.*")` works and any pinned batteries from the
- // manifest are installed under $PANTO_HOME.
+ // manifest are installed under the panto data home.
const luarocks_rt = try luarocks_runtime.bootstrap(
alloc,
io,
@@ -352,7 +349,7 @@ pub fn main(init: std.process.Init) !void {
defer luarocks_rt.deinit();
// Source the system prompt now that the base agent tree has been
- // staged to `$PANTO_HOME/agent` (the bootstrap above writes the
+ // staged to `<data home>/agent` (the bootstrap above writes the
// bundled `SYSTEM.md` there). The prompt is sourced by convention
// from SYSTEM.md / APPEND_SYSTEM.md across the base/user/project
// layers; the base layer is `luarocks_rt.layout.agent_dir`.
@@ -392,7 +389,7 @@ pub fn main(init: std.process.Init) !void {
try rt.installScheduler();
// Discover Lua extensions across three layers — base
- // ($PANTO_HOME/agent), user ($XDG_CONFIG_HOME/panto or
+ // (<data home>/agent), user ($XDG_CONFIG_HOME/panto or
// $HOME/.config/panto), and project (./.panto). Project shadows
// user shadows base; tool-name collisions across surviving
// entries abort startup.
diff --git a/src/models_toml.zig b/src/models_toml.zig
index 6527292..9b1b4f0 100644
--- a/src/models_toml.zig
+++ b/src/models_toml.zig
@@ -2,8 +2,7 @@
//!
//! Four files are read and merged, lowest precedence first:
//!
-//! 1. base — `$PANTO_HOME/models.toml`
-//! (or `${XDG_DATA_HOME:-$HOME/.local/share}/panto/models.toml`)
+//! 1. base — `${XDG_DATA_HOME:-$HOME/.local/share}/panto/models.toml`
//! 2. user — `${XDG_CONFIG_HOME:-$HOME/.config}/panto/models.toml`
//! 3. project — `./.panto/models.toml`
//! 4. local — `./.panto/models.local.toml`
@@ -79,6 +78,8 @@ const Io = std.Io;
const toml = @import("toml");
const panto = @import("panto");
const tvalue = toml.value_mod;
+const panto_home = @import("panto_home.zig");
+const toml_layer = @import("toml_layer.zig");
pub const Pricing = panto.Pricing;
pub const Registry = panto.PricingRegistry;
@@ -161,20 +162,13 @@ pub const Models = struct {
}
};
-/// Resolve the base-layer path to `models.toml`: `$PANTO_HOME/models.toml`,
-/// falling back to `${XDG_DATA_HOME:-$HOME/.local/share}/panto/models.toml`.
+/// Resolve the base-layer path to `models.toml`:
+/// `${XDG_DATA_HOME:-$HOME/.local/share}/panto/models.toml`.
/// Caller owns the returned slice.
pub fn basePath(allocator: Allocator, environ_map: *const std.process.Environ.Map) ![]u8 {
- if (environ_map.get("PANTO_HOME")) |home| {
- return try std.fs.path.join(allocator, &.{ home, "models.toml" });
- }
- if (environ_map.get("XDG_DATA_HOME")) |xdg| {
- return try std.fs.path.join(allocator, &.{ xdg, "panto", "models.toml" });
- }
- if (environ_map.get("HOME")) |home| {
- return try std.fs.path.join(allocator, &.{ home, ".local", "share", "panto", "models.toml" });
- }
- return error.NoHomeDirectory;
+ const home = try panto_home.homePath(allocator, environ_map);
+ defer allocator.free(home);
+ return try std.fs.path.join(allocator, &.{ home, "models.toml" });
}
/// Resolve the user-layer path to `models.toml`. Caller owns the returned
@@ -243,15 +237,17 @@ pub fn loadFromPaths(allocator: Allocator, io: Io, paths: []const []const u8) !M
defer merged.deinit();
for (paths) |path| {
- const bytes = readFileAlloc(allocator, io, path) catch |err| switch (err) {
+ const bytes = toml_layer.readFileAlloc(allocator, io, path) catch |err| switch (err) {
error.FileNotFound => continue,
- else => return err,
+ error.ReadFailed => return error.ModelsReadFailed,
+ error.Canceled => return error.Canceled,
+ error.OutOfMemory => return error.OutOfMemory,
};
defer allocator.free(bytes);
const doc = parseDoc(allocator, bytes) catch return error.InvalidModelsToml;
defer doc.deinit();
- try mergeTable(merged.allocator(), merged.root, doc.root);
+ try toml_layer.mergeTable(merged.allocator(), merged.root, doc.root);
}
var models: Models = .{
@@ -269,20 +265,6 @@ pub fn loadFromPath(allocator: Allocator, io: Io, path: []const u8) !Models {
return loadFromPaths(allocator, io, &.{path});
}
-fn readFileAlloc(allocator: Allocator, io: Io, path: []const u8) ![]u8 {
- const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) {
- error.FileNotFound => return error.FileNotFound,
- else => return error.ModelsReadFailed,
- };
- defer file.close(io);
-
- const len = try file.length(io);
- const bytes = try allocator.alloc(u8, @intCast(len));
- errdefer allocator.free(bytes);
- _ = try file.readPositionalAll(io, bytes, 0);
- return bytes;
-}
-
fn parseDoc(allocator: Allocator, source: []const u8) !*toml.Document {
const result = toml.parseWithError(allocator, source, .{});
return switch (result) {
@@ -299,48 +281,6 @@ fn parseDoc(allocator: Allocator, source: []const u8) !*toml.Document {
};
}
-fn mergeTable(alloc: Allocator, dst: *toml.Value, src: *const toml.Value) Allocator.Error!void {
- std.debug.assert(dst.* == .table);
- if (src.* != .table) return;
-
- var it = toml.tableIterator(src);
- while (it.next()) |entry| {
- const existing = dst.get(entry.key);
- if (existing != null and existing.?.* == .table and entry.value.* == .table) {
- try mergeTable(alloc, @constCast(existing.?), entry.value);
- } else {
- const copy = try cloneValue(alloc, entry.value);
- const key_copy = try alloc.dupe(u8, entry.key);
- try tvalue.tableSet(alloc, dst, key_copy, copy);
- }
- }
-}
-
-fn cloneValue(alloc: Allocator, src: *const toml.Value) Allocator.Error!*toml.Value {
- const out = try alloc.create(toml.Value);
- switch (src.*) {
- .table => {
- out.* = .{ .table = .{} };
- var it = toml.tableIterator(src);
- while (it.next()) |entry| {
- const child = try cloneValue(alloc, entry.value);
- const key_copy = try alloc.dupe(u8, entry.key);
- try tvalue.tableSet(alloc, out, key_copy, child);
- }
- },
- .array => |*a| {
- out.* = .{ .array = .{} };
- for (a.items.items) |*item| {
- const child = try cloneValue(alloc, item);
- try tvalue.arrayAppend(alloc, out, child.*);
- }
- },
- .string => |s| out.* = .{ .string = try alloc.dupe(u8, s) },
- else => out.* = src.*,
- }
- return out;
-}
-
/// Parse a TOML string into the given registries. Useful for tests.
pub fn parseInto(models: *Models, source: []const u8) !void {
const doc = try parseDoc(models.defs.allocator, source);
@@ -623,17 +563,6 @@ test "ModelRegistry.get: returns null for unknown alias" {
try testing.expect(models.defs.get("openai", "sonnet") == null);
}
-test "basePath: PANTO_HOME wins" {
- const a = testing.allocator;
- var env: std.process.Environ.Map = .init(a);
- defer env.deinit();
- try env.put("PANTO_HOME", "/tmp/panto-home");
- try env.put("XDG_DATA_HOME", "/ignored");
- const got = try basePath(a, &env);
- defer a.free(got);
- try testing.expectEqualStrings("/tmp/panto-home/models.toml", got);
-}
-
test "basePath: XDG_DATA_HOME falls back before HOME" {
const a = testing.allocator;
var env: std.process.Environ.Map = .init(a);
diff --git a/src/panto_home.zig b/src/panto_home.zig
index 5d111d6..bc715f0 100644
--- a/src/panto_home.zig
+++ b/src/panto_home.zig
@@ -1,10 +1,10 @@
-//! Filesystem layout resolution for `$PANTO_HOME` and the per-Lua
+//! Filesystem layout resolution for the panto data home and the per-Lua
//! version rocks tree.
//!
-//! $PANTO_HOME = $XDG_DATA_HOME/panto
-//! (or $HOME/.local/share/panto if XDG_DATA_HOME unset)
+//! data home = $XDG_DATA_HOME/panto
+//! (or $HOME/.local/share/panto if XDG_DATA_HOME unset)
//!
-//! $PANTO_HOME/
+//! data home/
//! rocks/
//! lua-5.4.7/ ← current tree
//! include/ ← Lua headers staged by bootstrap
@@ -28,18 +28,18 @@ const Io = std.Io;
/// fields are owned by the same allocator passed to `resolve`.
pub const Layout = struct {
allocator: Allocator,
- /// `$PANTO_HOME` itself.
+ /// The panto data home.
home: []u8,
- /// `$PANTO_HOME/agent/` — the "base" extension/tool tree,
+ /// `<data home>/agent/` — the "base" extension/tool tree,
/// populated at bootstrap from files embedded into the panto
/// binary. Searched after user/project layers for tools and
/// extensions; project shadows user shadows base.
agent_dir: []u8,
- /// `$PANTO_HOME/auth/` — persisted provider auth tokens, one
+ /// `<data home>/auth/` — persisted provider auth tokens, one
/// `<auth-name>.json` per OAuth session. Files are written owner-only;
/// treat them like passwords.
auth_dir: []u8,
- /// `$PANTO_HOME/rocks/lua-<lua_version>/` — the versioned tree.
+ /// `<data home>/rocks/lua-<lua_version>/` — the versioned tree.
tree: []u8,
/// `<tree>/include/` — where Lua headers are staged.
include_dir: []u8,
@@ -74,17 +74,15 @@ pub const Layout = struct {
};
/// Resolve every path the runtime cares about. Environment-driven:
-/// - `PANTO_HOME` (explicit override)
/// - `XDG_DATA_HOME` (XDG default)
/// - `HOME` (fallback)
///
-/// Returns `error.NoHomeDirectory` if none of those are available and
-/// no `PANTO_HOME` was set.
+/// Returns `error.NoHomeDirectory` if neither is available.
pub fn resolve(
allocator: Allocator,
environ_map: *const std.process.Environ.Map,
) !Layout {
- const home = try resolveHome(allocator, environ_map);
+ const home = try homePath(allocator, environ_map);
errdefer allocator.free(home);
const agent_dir = try std.fs.path.join(allocator, &.{ home, "agent" });
@@ -140,15 +138,12 @@ pub fn resolve(
};
}
-/// Resolve `$PANTO_HOME` honoring overrides in the documented order.
+/// Resolve the panto data home. Returns owned bytes.
/// Returns owned bytes.
-fn resolveHome(
+pub fn homePath(
allocator: Allocator,
environ_map: *const std.process.Environ.Map,
) ![]u8 {
- if (environ_map.get("PANTO_HOME")) |explicit| {
- return allocator.dupe(u8, explicit);
- }
if (environ_map.get("XDG_DATA_HOME")) |xdg| {
return std.fs.path.join(allocator, &.{ xdg, "panto" });
}
@@ -184,20 +179,6 @@ fn makePathRecursive(io: Io, path: []const u8) !void {
const testing = std.testing;
-test "resolve: PANTO_HOME explicit override wins" {
- var env: std.process.Environ.Map = .init(testing.allocator);
- defer env.deinit();
- try env.put("PANTO_HOME", "/tmp/some/home");
- try env.put("XDG_DATA_HOME", "/should/be/ignored");
-
- var layout = try resolve(testing.allocator, &env);
- defer layout.deinit();
-
- try testing.expectEqualStrings("/tmp/some/home", layout.home);
- try testing.expect(std.mem.indexOf(u8, layout.tree, "/tmp/some/home/rocks/lua-") != null);
- try testing.expect(std.mem.endsWith(u8, layout.share_lua_dir, "/share/lua/" ++ manifest.lua_short_version));
-}
-
test "resolve: XDG_DATA_HOME is honored before HOME" {
var env: std.process.Environ.Map = .init(testing.allocator);
defer env.deinit();
diff --git a/src/self_exe.zig b/src/self_exe.zig
deleted file mode 100644
index 6eda92d..0000000
--- a/src/self_exe.zig
+++ /dev/null
@@ -1,90 +0,0 @@
-//! Resolve the absolute path of the currently running executable.
-//!
-//! Used to find the panto binary so we can launch ourselves as `panto
-//! lua` from inside the bootstrap (the embedded luarocks shells out to
-//! its configured `LUA` interpreter, which we point at a wrapper script
-//! that exec's panto's `lua` subcommand).
-//!
-//! Zig's standard library doesn't expose a portable `selfExePath` in
-//! the current API, so this module wraps the per-OS syscall.
-//!
-//! Supported:
-//! - macOS / iOS / tvOS / watchOS: `_NSGetExecutablePath` + realpath
-//! - Linux: `readlink("/proc/self/exe")`
-//! - FreeBSD / NetBSD / DragonFly: `readlink("/proc/curproc/file")`
-//!
-//! Other targets currently return `error.SelfExePathUnavailable`.
-
-const std = @import("std");
-const builtin = @import("builtin");
-
-const Allocator = std.mem.Allocator;
-
-pub const ResolveError = error{
- SelfExePathUnavailable,
- SelfExePathTooLong,
- SelfExePathReadFailed,
- OutOfMemory,
-};
-
-/// Return the absolute, symlink-resolved path of the current process's
-/// executable. Caller owns the returned slice.
-pub fn selfExePathAlloc(allocator: Allocator) ResolveError![]u8 {
- var buf: [std.fs.max_path_bytes]u8 = undefined;
- const path = try selfExePath(&buf);
- return allocator.dupe(u8, path);
-}
-
-/// Fill `buf` with the absolute path. Returns the slice that was used.
-pub fn selfExePath(buf: []u8) ResolveError![]u8 {
- return switch (builtin.os.tag) {
- .macos, .ios, .tvos, .watchos => try macosSelfExePath(buf),
- .linux => try readLinkSelfExe(buf, "/proc/self/exe"),
- .freebsd, .netbsd, .dragonfly => try readLinkSelfExe(buf, "/proc/curproc/file"),
- else => return ResolveError.SelfExePathUnavailable,
- };
-}
-
-fn readLinkSelfExe(buf: []u8, link_path: []const u8) ResolveError![]u8 {
- const link_z = std.posix.toPosixPath(link_path) catch return ResolveError.SelfExePathTooLong;
- const n = posixReadlink(&link_z, buf) catch return ResolveError.SelfExePathReadFailed;
- return buf[0..n];
-}
-
-/// Thin wrapper around the POSIX `readlink` syscall using libc directly,
-/// since std.posix's surface here has been in flux. libc is linked into
-/// panto already (Lua needs it), so this stays cheap.
-extern "c" fn readlink(path: [*:0]const u8, buf: [*]u8, bufsize: usize) isize;
-
-fn posixReadlink(path: [*:0]const u8, buf: []u8) !usize {
- const n = readlink(path, buf.ptr, buf.len);
- if (n < 0) return error.SelfExePathReadFailed;
- return @intCast(n);
-}
-
-extern "c" fn _NSGetExecutablePath(buf: [*]u8, bufsize: *u32) c_int;
-
-fn macosSelfExePath(buf: []u8) ResolveError![]u8 {
- var stage: [std.fs.max_path_bytes]u8 = undefined;
- var size: u32 = @intCast(stage.len);
- if (_NSGetExecutablePath(&stage, &size) != 0) return ResolveError.SelfExePathTooLong;
- // _NSGetExecutablePath populates `size` on overflow; on success
- // we need to find the NUL ourselves.
- const len = std.mem.indexOfScalar(u8, &stage, 0) orelse return ResolveError.SelfExePathTooLong;
-
- // The returned path may include `..` and symlinks; canonicalize so
- // downstream consumers don't have to. realpath(3) on POSIX, both
- // libc-resident on macOS and Linux.
- const path_z = std.posix.toPosixPath(stage[0..len]) catch return ResolveError.SelfExePathTooLong;
-
- // libc realpath: NULL second arg => malloc; we want stack buffer.
- var real_buf: [std.fs.max_path_bytes]u8 = undefined;
- if (libc_realpath(&path_z, &real_buf) == null) return ResolveError.SelfExePathReadFailed;
- const real_len = std.mem.indexOfScalar(u8, &real_buf, 0) orelse return ResolveError.SelfExePathTooLong;
- if (real_len > buf.len) return ResolveError.SelfExePathTooLong;
- @memcpy(buf[0..real_len], real_buf[0..real_len]);
- return buf[0..real_len];
-}
-
-extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*]u8;
-const libc_realpath = realpath;
diff --git a/src/session_paths.zig b/src/session_paths.zig
index 162085e..170c8fb 100644
--- a/src/session_paths.zig
+++ b/src/session_paths.zig
@@ -11,6 +11,7 @@
//! gives a flat directory name per project, easy to spot in `ls`.
const std = @import("std");
+const panto_home = @import("panto_home.zig");
const Allocator = std.mem.Allocator;
/// Resolve the absolute sessions directory for the given cwd. Caller owns
@@ -46,13 +47,9 @@ pub fn resolveSessionsBase(
if (environ_map.get("PANTO_SESSION_DIR")) |explicit| {
return try allocator.dupe(u8, explicit);
}
- if (environ_map.get("XDG_DATA_HOME")) |xdg| {
- return try std.fs.path.join(allocator, &.{ xdg, "panto", "sessions" });
- }
- if (environ_map.get("HOME")) |home| {
- return try std.fs.path.join(allocator, &.{ home, ".local", "share", "panto", "sessions" });
- }
- return error.NoHomeDirectory;
+ const home = try panto_home.homePath(allocator, environ_map);
+ defer allocator.free(home);
+ return try std.fs.path.join(allocator, &.{ home, "sessions" });
}
/// Encode a working directory into a flat directory name. Caller owns.
diff --git a/src/subcommand.zig b/src/subcommand.zig
index 9fdcc4c..0c04ce8 100644
--- a/src/subcommand.zig
+++ b/src/subcommand.zig
@@ -22,7 +22,6 @@ const Io = std.Io;
const lua_bridge = @import("lua_bridge.zig");
const luarocks_runtime = @import("luarocks_runtime.zig");
-const self_exe = @import("self_exe.zig");
const session_paths = @import("session_paths.zig");
const config_file = @import("config_file.zig");
const auth_manager = @import("auth_manager.zig");
@@ -131,9 +130,9 @@ fn printHelp(io: Io) !void {
\\
\\Environment:
\\ OPENAI_API_KEY, ANTHROPIC_API_KEY Consumed by the default providers.
+ \\ PANTO_DEBUG Write std.log output to <data home>/debug/<session>.log.
\\ PANTO_SESSION_DIR Override the base sessions directory. Defaults to
\\ $XDG_DATA_HOME/panto/sessions or ~/.local/share/panto/sessions.
- \\ PANTO_HOME Override the runtime/rocks tree location.
\\
);
try stdout_file.flush();
@@ -142,7 +141,7 @@ fn printHelp(io: Io) !void {
pub const BootstrapOptions = struct {
/// Wipe the per-Lua-version tree before reinstalling everything.
/// Surfaced as `panto bootstrap --force`. Equivalent to deleting
- /// `$PANTO_HOME/rocks/lua-X.Y.Z/` by hand and then running
+ /// the data-home `rocks/lua-X.Y.Z/` tree by hand and then running
/// `panto bootstrap`.
force: bool = false,
};
@@ -155,7 +154,7 @@ extern "c" fn panto_lua_pmain(L: *c.lua_State, argc: c_int, argv: [*]?[*:0]u8) c
/// Drop into the embedded Lua standalone interpreter, with the
/// luarocks runtime bootstrap completed so `require("luarocks.*")`
-/// and rocks installed under `$PANTO_HOME` are visible.
+/// and rocks installed under the panto data home are visible.
///
/// argv is rewritten so the interpreter sees `lua [...args]` rather
/// than `panto lua [...args]` — matching upstream behavior. The first
@@ -1178,16 +1177,6 @@ fn authLogin(
}
// ---------------------------------------------------------------------------
-// `panto lua` argv plumbing — sketched against the older Args API for
-// reference (kept here so the design notes survive the implementation).
-// ---------------------------------------------------------------------------
-//
-// Because we own the `lua_State` end-to-end, the subcommand can also
-// expose extra panto-specific globals to user code (e.g. surface the
-// resolved $PANTO_HOME) without disturbing upstream `lua.c` behavior.
-// Step out of scope for the current makeover; add when needed.
-
-// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -1197,8 +1186,6 @@ const testing = std.testing;
// controllable from a unit test. The behavior is exercised by
// integration runs of the panto binary. We test the smaller pieces.
//
-// Suppress dead-code warnings for `self_exe` (it's used by main, not
-// by tests in this module).
test "providerModelsURL appends /models once" {
const url = try providerModelsURL(testing.allocator, "https://api.example.com/v1/");
defer testing.allocator.free(url);
@@ -1255,7 +1242,3 @@ test "parseProviderModelsFilter: keyed models object" {
test "parseProviderModelsFilter: unrecognized json returns null" {
try testing.expect((try parseProviderModelsFilter(testing.allocator, "{\"ok\":true}")) == null);
}
-
-test {
- _ = self_exe;
-}
diff --git a/src/system_prompt.zig b/src/system_prompt.zig
index e2b2907..b2b01b1 100644
--- a/src/system_prompt.zig
+++ b/src/system_prompt.zig
@@ -6,7 +6,7 @@
//! CLI already uses, in precedence order base → user → project (project
//! highest):
//!
-//! base = $PANTO_HOME/agent/ (= $XDG_DATA_HOME/panto/agent/)
+//! base = <data home>/agent/ (= $XDG_DATA_HOME/panto/agent/)
//! user = ${XDG_CONFIG_HOME:-$HOME/.config}/panto/
//! project = ./.panto/
//!
@@ -30,7 +30,7 @@ const Io = std.Io;
///
/// In normal operation this is never used: the base layer ships a bundled
/// `agent/SYSTEM.md` (embedded in the binary and staged to
-/// `$PANTO_HOME/agent/SYSTEM.md` at bootstrap), so a seed is always found.
+/// `<data home>/agent/SYSTEM.md` at bootstrap), so a seed is always found.
/// This constant only matters if that staged file is missing or
/// unreadable.
pub const default_seed = "You are a helpful assistant.";
@@ -83,7 +83,7 @@ pub const Resolved = struct {
/// Resolve the system-prompt blocks from the three config layers.
///
-/// `base_dir` is the base layer directory (`$PANTO_HOME/agent`). The user
+/// `base_dir` is the base layer directory (`<data home>/agent`). The user
/// layer is derived from `XDG_CONFIG_HOME`/`HOME`; the project layer is
/// `cwd()/.panto`. Allocations are made in `arena` (caller owns it).
pub fn resolve(
diff --git a/src/toml_layer.zig b/src/toml_layer.zig
new file mode 100644
index 0000000..a2c1942
--- /dev/null
+++ b/src/toml_layer.zig
@@ -0,0 +1,68 @@
+const std = @import("std");
+const toml = @import("toml");
+
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+const tvalue = toml.value_mod;
+
+pub const ReadError = Allocator.Error || Io.Cancelable || error{ FileNotFound, ReadFailed };
+
+pub fn readFileAlloc(allocator: Allocator, io: Io, path: []const u8) ReadError![]u8 {
+ const file = Io.Dir.cwd().openFile(io, path, .{ .mode = .read_only }) catch |err| switch (err) {
+ error.FileNotFound => return error.FileNotFound,
+ error.Canceled => return error.Canceled,
+ else => return error.ReadFailed,
+ };
+ defer file.close(io);
+
+ const len = file.length(io) catch return error.ReadFailed;
+ const bytes = try allocator.alloc(u8, @intCast(len));
+ errdefer allocator.free(bytes);
+ _ = file.readPositionalAll(io, bytes, 0) catch |err| switch (err) {
+ error.Canceled => return error.Canceled,
+ else => return error.ReadFailed,
+ };
+ return bytes;
+}
+
+pub fn mergeTable(alloc: Allocator, dst: *toml.Value, src: *const toml.Value) Allocator.Error!void {
+ std.debug.assert(dst.* == .table);
+ if (src.* != .table) return;
+
+ var it = toml.tableIterator(src);
+ while (it.next()) |entry| {
+ const existing = dst.get(entry.key);
+ if (existing != null and existing.?.* == .table and entry.value.* == .table) {
+ try mergeTable(alloc, @constCast(existing.?), entry.value);
+ } else {
+ const copy = try cloneValue(alloc, entry.value);
+ const key_copy = try alloc.dupe(u8, entry.key);
+ try tvalue.tableSet(alloc, dst, key_copy, copy);
+ }
+ }
+}
+
+fn cloneValue(alloc: Allocator, src: *const toml.Value) Allocator.Error!*toml.Value {
+ const out = try alloc.create(toml.Value);
+ switch (src.*) {
+ .table => {
+ out.* = .{ .table = .{} };
+ var it = toml.tableIterator(src);
+ while (it.next()) |entry| {
+ const child = try cloneValue(alloc, entry.value);
+ const key_copy = try alloc.dupe(u8, entry.key);
+ try tvalue.tableSet(alloc, out, key_copy, child);
+ }
+ },
+ .array => |*a| {
+ out.* = .{ .array = .{} };
+ for (a.items.items) |*item| {
+ const child = try cloneValue(alloc, item);
+ try tvalue.arrayAppend(alloc, out, child.*);
+ }
+ },
+ .string => |s| out.* = .{ .string = try alloc.dupe(u8, s) },
+ else => out.* = src.*,
+ }
+ return out;
+}
diff --git a/src/tui_component.zig b/src/tui_component.zig
index b213e3f..2a2aaa0 100644
--- a/src/tui_component.zig
+++ b/src/tui_component.zig
@@ -1,14 +1,9 @@
//! The core component contract for the TUI.
//!
-//! Dispatch is a vtable of function pointers over `*anyopaque` (decided in
-//! plan §4.5; a tagged union was rejected so out-of-tree extensions can define
-//! their own components later without editing a central enum).
+//! Dispatch is a vtable of function pointers over `*anyopaque`.
//!
-//! The render engine (later sub-phase) holds a list of `Component`s, asks each
-//! to `render(width)` into lines, and uses `firstLineChanged` to do a
-//! differential repaint. This file defines only the interface plus a small
-//! reusable cache/dirty mixin; it implements no concrete components and no
-//! engine.
+//! The render engine holds a list of `Component`s, asks each to render, and
+//! uses `firstLineChanged` to do a differential repaint.
const std = @import("std");
diff --git a/src/tui_components.zig b/src/tui_components.zig
index 3944ffa..837db0d 100644
--- a/src/tui_components.zig
+++ b/src/tui_components.zig
@@ -2307,10 +2307,8 @@ pub const CompactionSummary = struct {
// ===========================================================================
/// Format the header line for a tool call, given the tool's name and
-/// Render the framework-default tool header. The Zig core intentionally does
-/// not special-case extension tool names here: extension-provided tools own
-/// their display by registering `panto.ext.on("tool"/...)` handlers and
-/// calling `event:setComponent(...)` for their own names.
+/// Render the framework-default tool header. Extension-provided tools own
+/// their display by registering event handlers and setting their component.
///
/// Allocation: caller-owned; the returned slice is from `buf`.
fn formatToolHeader(name: []const u8, input_json: []const u8, buf: []u8) []const u8 {
diff --git a/src/tui_event.zig b/src/tui_event.zig
index c32db39..3f59ba2 100644
--- a/src/tui_event.zig
+++ b/src/tui_event.zig
@@ -73,16 +73,6 @@
//! own default and returns that boundary's own chosen component. Parallel tool
//! calls each get their own.
//!
-//! ## Bridge friendliness (§7.6)
-//!
-//! Dispatch is a vtable of function pointers over `*anyopaque`, matching the
-//! `Component` vtable in `tui_component.zig`. A Lua-backed (or future C-ABI)
-//! handler implements the same `Handler` callback shape; a Lua-defined
-//! component implements the same `Component` vtable across the bridge. Nothing
-//! here knows or cares whether a handler/component is native or bridged. The
-//! Lua side is implemented in a LATER sub-phase; this module is Zig-only and
-//! must not depend on the Lua machinery.
-
const std = @import("std");
const component = @import("tui_component.zig");
@@ -93,8 +83,7 @@ const Component = component.Component;
// ===========================================================================
/// A registered event handler. Vtable-style: a `callback` function pointer over
-/// an opaque `ctx`, so a native closure, a Lua-backed handler, or a future
-/// C-ABI handler all plug into the same shape (§7.6).
+/// an opaque `ctx`.
///
/// The callback receives the live `*Event`; it inspects `payload`, reads the
/// current component with `event.getComponent()`, and optionally replaces it
@@ -114,16 +103,8 @@ pub const Handler = struct {
// Payload — structured per-event data (§7.2)
// ===========================================================================
-/// Structured data carried by an event, surfaced to handlers as typed fields
-/// (the §7.2 `event.tool_name`, `event.args`, … shape). A tagged union keeps
-/// the per-event fields explicit and bridge-friendly (the Lua bridge maps each
-/// variant's fields onto the `event` object's properties).
-///
-/// New built-in event types add a variant here; extension-defined events use
-/// `.custom` with an opaque pointer the emitter and handler agree on. Borrowed
-/// slices are valid only for the duration of the `emit` call (handlers must
-/// copy anything they retain), mirroring the streaming-event borrow contract
-/// elsewhere in panto.
+/// Structured data carried by an event, surfaced to handlers as typed fields.
+/// Borrowed slices are valid only for the duration of the `emit` call.
pub const Payload = union(enum) {
/// `session_start`: the welcome/banner boundary.
session_start: SessionStart,
@@ -146,9 +127,8 @@ pub const Payload = union(enum) {
tool: Tool,
/// `compaction`: a compaction-summary boundary.
compaction: Compaction,
- /// An extension-defined event. The emitter and handler agree on the
- /// meaning of `data`; panto does not interpret it.
- custom: Custom,
+ /// An extension-defined event with no structured payload.
+ custom: void,
pub const SessionStart = struct {
version: []const u8 = "",
@@ -217,9 +197,6 @@ pub const Payload = union(enum) {
pub const Compaction = struct {
summary: []const u8 = "",
};
- pub const Custom = struct {
- data: ?*anyopaque = null,
- };
};
// ===========================================================================
@@ -399,7 +376,7 @@ test "emit with zero handlers returns the seeded default unchanged" {
try testing.expectEqual(@as(*anyopaque, def.comp().ptr), out.?.ptr);
// And a null default passes through as null.
- const none = bus.fire("nope", null, .{ .custom = .{} });
+ const none = bus.fire("nope", null, .{ .custom = {} });
try testing.expect(none == null);
}