summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-05-27 16:34:32 -0600
committerT <t@tjp.lol>2026-05-27 16:35:09 -0600
commitf72b5793a5c7e7891e4904be73c0bc6bc038fa18 (patch)
tree5201aa7c6648d96b401dcbcb5343d4510b1c34e5
parent6545cdfd8f2bc865aa06a2b5515056daf58ba111 (diff)
system agent definition scaffolding
-rw-r--r--agent/README.md28
-rw-r--r--build.zig42
-rw-r--r--build/gen_agent_embed.zig126
-rw-r--r--src/extension_loader.zig165
-rw-r--r--src/luarocks_runtime.zig37
-rw-r--r--src/main.zig10
-rw-r--r--src/panto_home.zig11
7 files changed, 406 insertions, 13 deletions
diff --git a/agent/README.md b/agent/README.md
new file mode 100644
index 0000000..00e6e60
--- /dev/null
+++ b/agent/README.md
@@ -0,0 +1,28 @@
+# panto system agent tree
+
+Contents of this directory are embedded into the `panto` binary at
+build time and staged onto disk at bootstrap into
+`$PANTO_HOME/agent/` (i.e. `$XDG_DATA_HOME/panto/agent/`, or
+`~/.local/share/panto/agent/` if `XDG_DATA_HOME` is unset).
+
+The staged tree is the "system" layer of panto's extension/tool
+search path — sitting underneath the user layer
+(`$XDG_CONFIG_HOME/panto/`) and the project layer
+(`./.panto/`). Project shadows user shadows system.
+
+Files are written with `writeIfDifferent` semantics: on each
+bootstrap, embedded contents are compared against what's on disk and
+only rewritten when they differ. This keeps mtimes stable across
+reruns.
+
+The staged tree is **panto's territory** — local edits will be
+overwritten on the next bootstrap. To customize a system tool,
+override it at the user or project layer (a file with the same
+basename under `$XDG_CONFIG_HOME/panto/tools/` or `./.panto/tools/`
+shadows the system copy).
+
+## Layout
+
+ agent/
+ tools/ single-file tools, one per `.lua`
+ extensions/ (future) multi-tool extensions
diff --git a/build.zig b/build.zig
index 4e4e862..bcfe1be 100644
--- a/build.zig
+++ b/build.zig
@@ -41,6 +41,11 @@ pub fn build(b: *std.Build) void {
// for luarocks to find when compiling C rocks.
const lua_headers_embed_path = generateLuaHeadersEmbed(b, lua_src);
+ // And the in-repo `agent/` tree: bundled into the binary and
+ // staged at $PANTO_HOME/agent/ on first run. This is panto's
+ // "system" extension/tool layer (read/write/edit/bash etc.).
+ const agent_embed_path = generateAgentEmbed(b);
+
// Constants module: makes Zig know the Lua and luarocks versions
// without duplicating literals everywhere.
const versions_mod = b.addOptions();
@@ -64,6 +69,9 @@ pub fn build(b: *std.Build) void {
exe_mod.addImport("embedded_lua_headers", b.createModule(.{
.root_source_file = lua_headers_embed_path,
}));
+ exe_mod.addImport("embedded_agent", b.createModule(.{
+ .root_source_file = agent_embed_path,
+ }));
// Link Lua. We can't use a static library here because the
// standard archive-linking behavior drops object files whose
// symbols nothing in our code references — e.g. `lua_settable`,
@@ -121,6 +129,9 @@ pub fn build(b: *std.Build) void {
test_mod.addImport("embedded_lua_headers", b.createModule(.{
.root_source_file = lua_headers_embed_path,
}));
+ test_mod.addImport("embedded_agent", b.createModule(.{
+ .root_source_file = agent_embed_path,
+ }));
addLuaSources(test_mod, lua_src, lua_anchor_path);
test_mod.addIncludePath(lua_src.path("src"));
test_mod.linkLibrary(lua_repl);
@@ -300,6 +311,37 @@ fn generateLuarocksEmbed(
return out_zig;
}
+/// Codegen step: walk the in-repo `agent/` tree and emit a Zig module
+/// embedding every file. Bootstrap stages the result under
+/// `$PANTO_HOME/agent/` on first run, where the runtime's extension
+/// loader finds it as the "system" layer (below user and project).
+///
+/// Mirrors `generateLuarocksEmbed`'s addCopyDirectory trick so the
+/// emitted `@embedFile("agent_src/<rel>")` references resolve relative
+/// to the generated zig file.
+fn generateAgentEmbed(b: *std.Build) std.Build.LazyPath {
+ const tool = b.addExecutable(.{
+ .name = "gen-agent-embed",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("build/gen_agent_embed.zig"),
+ .target = b.graph.host,
+ .optimize = .Debug,
+ }),
+ });
+
+ const run = b.addRunArtifact(tool);
+ // Marker subpath: each emitted `@embedFile` reference is
+ // `agent_src/<rel>`, and we stage the tree under that name.
+ run.addArg("agent_src");
+ run.addDirectoryArg(b.path("agent"));
+ const gen_file = run.addOutputFileArg("embedded_agent.zig");
+
+ const wf = b.addWriteFiles();
+ const out_zig = wf.addCopyFile(gen_file, "embedded_agent.zig");
+ _ = wf.addCopyDirectory(b.path("agent"), "agent_src", .{});
+ return out_zig;
+}
+
/// Codegen step: emit a Zig module that exposes every Lua public header
/// from the Lua source tarball as embedded bytes. The bootstrap stages
/// these under `$PANTO_HOME/rocks/lua-X.Y.Z/include/` so luarocks can
diff --git a/build/gen_agent_embed.zig b/build/gen_agent_embed.zig
new file mode 100644
index 0000000..b705863
--- /dev/null
+++ b/build/gen_agent_embed.zig
@@ -0,0 +1,126 @@
+//! Build-time codegen: walk the in-repo `agent/` tree and emit a Zig
+//! module that exposes every file as an `@embedFile` entry.
+//!
+//! Invoked from `build.zig`:
+//!
+//! gen-agent-embed <embed-prefix> <src-dir> <out-file>
+//!
+//! `<src-dir>` is the repo's `agent/` directory. `<embed-prefix>` is
+//! the subpath the generated zig file's neighboring `addCopyDirectory`
+//! stages the tree under; every `@embedFile` reference uses this
+//! prefix so Zig can resolve it relative to the generated module's
+//! directory.
+//!
+//! The generated file looks like:
+//!
+//! pub const Entry = struct {
+//! /// Path relative to the agent tree root (e.g. "tools/read.lua").
+//! path: []const u8,
+//! /// File contents.
+//! contents: []const u8,
+//! };
+//! pub const files: []const Entry = &.{
+//! .{ .path = "tools/read.lua", .contents = @embedFile("...") },
+//! ...
+//! };
+//!
+//! Bootstrap walks `files` and stages each entry at
+//! `$PANTO_HOME/agent/<path>` using the same `writeIfDifferent`
+//! discipline as `stageLuaHeaders`.
+
+const std = @import("std");
+
+pub fn main(init: std.process.Init) !void {
+ const arena = init.arena.allocator();
+ const io = init.io;
+
+ var args = init.minimal.args.iterate();
+ defer args.deinit();
+ _ = args.next(); // skip program name
+ const embed_prefix = args.next() orelse return error.MissingEmbedPrefix;
+ const src_dir_arg = args.next() orelse return error.MissingSrcDir;
+ const out_path_arg = args.next() orelse return error.MissingOutPath;
+
+ var entries: std.array_list.Managed(Entry) = .init(arena);
+ try collect(arena, io, src_dir_arg, embed_prefix, &entries);
+
+ // Sort for deterministic output across builds.
+ std.mem.sort(Entry, entries.items, {}, Entry.lessThan);
+
+ var out_file = try std.Io.Dir.cwd().createFile(io, out_path_arg, .{});
+ defer out_file.close(io);
+ var buf: [4096]u8 = undefined;
+ var writer = out_file.writer(io, &buf);
+ const w = &writer.interface;
+
+ try w.writeAll(
+ \\//! Auto-generated. Do not edit. See build/gen_agent_embed.zig.
+ \\
+ \\pub const Entry = struct {
+ \\ /// Path relative to the agent tree root (e.g. "tools/read.lua").
+ \\ path: []const u8,
+ \\ /// File contents (raw bytes).
+ \\ contents: []const u8,
+ \\};
+ \\
+ \\pub const files: []const Entry = &.{
+ \\
+ );
+
+ for (entries.items) |e| {
+ try w.print(
+ " .{{ .path = \"{s}\", .contents = @embedFile(\"{s}\") }},\n",
+ .{ e.rel_path, e.embed_path },
+ );
+ }
+
+ try w.writeAll("};\n");
+ try w.flush();
+}
+
+const Entry = struct {
+ /// Path inside the agent tree, forward-slash separated.
+ rel_path: []const u8,
+ /// Path the generated `@embedFile` references (under embed prefix).
+ embed_path: []const u8,
+
+ fn lessThan(_: void, a: Entry, b: Entry) bool {
+ return std.mem.lessThan(u8, a.rel_path, b.rel_path);
+ }
+};
+
+/// Walk `<root>` recursively, collecting every file. Dotfiles
+/// (entries whose basename starts with `.`) are skipped at every
+/// level so we don't accidentally embed editor backups, `.DS_Store`,
+/// or VCS metadata.
+fn collect(
+ arena: std.mem.Allocator,
+ io: std.Io,
+ root_abs: []const u8,
+ embed_prefix: []const u8,
+ out: *std.array_list.Managed(Entry),
+) !void {
+ var dir = std.Io.Dir.cwd().openDir(io, root_abs, .{ .iterate = true }) catch |err| switch (err) {
+ error.FileNotFound => return,
+ else => return err,
+ };
+ defer dir.close(io);
+
+ var walker = try dir.walk(arena);
+ defer walker.deinit();
+
+ while (try walker.next(io)) |entry| {
+ if (entry.kind != .file) continue;
+ if (entry.basename.len == 0 or entry.basename[0] == '.') continue;
+ // Skip anything along the path that looks like a dotfile dir.
+ if (std.mem.indexOf(u8, entry.path, "/.") != null) continue;
+
+ const rel_path = try arena.dupe(u8, entry.path);
+ const embed_path = try std.fs.path.join(arena, &.{ embed_prefix, entry.path });
+
+ try out.append(.{
+ .rel_path = rel_path,
+ .embed_path = embed_path,
+ });
+ }
+}
diff --git a/src/extension_loader.zig b/src/extension_loader.zig
index 8a871c9..2fd86ae 100644
--- a/src/extension_loader.zig
+++ b/src/extension_loader.zig
@@ -1,17 +1,26 @@
//! Extension and tool discovery: walk well-known directories, locate Lua
//! files, and load each into a long-lived `LuaRuntime`.
//!
-//! Two parallel namespaces are scanned, each at user and project scope:
+//! Two parallel namespaces are scanned, each at three scopes — system,
+//! user, and project. Project shadows user shadows system.
//!
//! Extensions (full-featured; call `panto.register_tool` from a script
//! that may register many tools):
-//! 1. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/extensions/` ("user")
-//! 2. `./.panto/extensions/` ("project")
+//! 1. `$PANTO_HOME/agent/extensions/` ("system")
+//! 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.register_tool`):
-//! 1. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/tools/` ("user")
-//! 2. `./.panto/tools/` ("project")
+//! 1. `$PANTO_HOME/agent/tools/` ("system")
+//! 2. `${XDG_CONFIG_HOME:-$HOME/.config}/panto/tools/` ("user")
+//! 3. `./.panto/tools/` ("project")
+//!
+//! The `system` layer is populated at bootstrap from files embedded
+//! into the panto binary (see `build/gen_agent_embed.zig` and
+//! `luarocks_runtime.stageAgentTree`). It is panto's "batteries" tier:
+//! ships in the binary, but every entry is individually shadowable
+//! from the user or project layer.
//!
//! Layout per directory (identical for extensions and tools):
//! - `<name>.lua` -- single-file entry; the logical name is the
@@ -24,10 +33,11 @@
//! Conflict rules:
//! - Within one directory, two entries with the same logical name
//! are an error.
-//! - Project shadows user *within the same kind* (extension or tool).
-//! Extensions and tools live in distinct namespaces for shadowing;
-//! the registered *tool names* still share one global namespace
-//! across both, and collisions there are still an error.
+//! - Project shadows user shadows system *within the same kind*
+//! (extension or tool). Extensions and tools live in distinct
+//! namespaces for shadowing; the registered *tool names* still
+//! share one global namespace across both, and collisions there
+//! are still an error.
//!
//! Symlinks: followed normally. Dotfiles: skipped.
@@ -60,11 +70,15 @@ const Found = struct {
};
pub const Source = enum {
+ /// Staged at bootstrap from files embedded in the panto binary.
+ /// Lowest priority — shadowed by user and project.
+ system,
user,
project,
pub fn label(self: Source) []const u8 {
return switch (self) {
+ .system => "system",
.user => "user",
.project => "project",
};
@@ -87,14 +101,31 @@ pub const Kind = enum {
/// paths into `runtime`. Returns the number of registered tools added
/// to the runtime by this call.
///
+/// `system_agent_dir`, when non-null, is the path under which embedded
+/// system tools/extensions have been staged — typically
+/// `$PANTO_HOME/agent/`. Pass `null` to skip the system layer entirely
+/// (mostly useful for tests).
+///
/// `environ_map` is consulted for `HOME` and `XDG_CONFIG_HOME`. The
/// project directories are always `cwd()/.panto/{extensions,tools}`.
pub fn discoverAndLoad(
allocator: Allocator,
io: Io,
environ_map: *const std.process.Environ.Map,
+ system_agent_dir: ?[]const u8,
runtime: *LuaRuntime,
) !usize {
+ const sys_ext = if (system_agent_dir) |d|
+ try std.fs.path.join(allocator, &.{ d, kindSubdir(.extension) })
+ else
+ null;
+ defer if (sys_ext) |d| allocator.free(d);
+ const sys_tool = if (system_agent_dir) |d|
+ try std.fs.path.join(allocator, &.{ d, kindSubdir(.tool) })
+ else
+ null;
+ defer if (sys_tool) |d| allocator.free(d);
+
const user_ext = try userKindDir(allocator, environ_map, .extension);
defer if (user_ext) |d| allocator.free(d);
const user_tool = try userKindDir(allocator, environ_map, .tool);
@@ -110,8 +141,10 @@ pub fn discoverAndLoad(
io,
runtime,
.{
+ .system_extensions = sys_ext,
.user_extensions = user_ext,
.project_extensions = project_ext,
+ .system_tools = sys_tool,
.user_tools = user_tool,
.project_tools = project_tool,
},
@@ -120,9 +153,15 @@ pub fn discoverAndLoad(
/// Set of search paths consumed by `loadFromDirs`. Any field may be
/// null; missing directories on disk are silently skipped.
+///
+/// Scan order is system → user → project. `applyShadowing` keeps the
+/// *last* occurrence of each (kind, name), so project entries win,
+/// then user, then system.
pub const DirSet = struct {
+ system_extensions: ?[]const u8 = null,
user_extensions: ?[]const u8 = null,
project_extensions: ?[]const u8 = null,
+ system_tools: ?[]const u8 = null,
user_tools: ?[]const u8 = null,
project_tools: ?[]const u8 = null,
};
@@ -141,8 +180,10 @@ pub fn loadFromDirs(
found.deinit();
}
+ if (dirs.system_extensions) |d| try scanDir(allocator, io, d, .system, .extension, &found);
if (dirs.user_extensions) |d| try scanDir(allocator, io, d, .user, .extension, &found);
if (dirs.project_extensions) |d| try scanDir(allocator, io, d, .project, .extension, &found);
+ if (dirs.system_tools) |d| try scanDir(allocator, io, d, .system, .tool, &found);
if (dirs.user_tools) |d| try scanDir(allocator, io, d, .user, .tool, &found);
if (dirs.project_tools) |d| try scanDir(allocator, io, d, .project, .tool, &found);
@@ -708,6 +749,112 @@ test "loadFromDirs: project tool shadows user tool of the same name" {
try testing.expectEqualStrings("PROJECT", results[0].ok);
}
+test "loadFromDirs: project tool shadows user shadows system" {
+ var tmp = testing.tmpDir(.{ .iterate = true });
+ defer tmp.cleanup();
+
+ try makeDir(tmp.dir, "system_tools");
+ try makeDir(tmp.dir, "user_tools");
+ try makeDir(tmp.dir, "project_tools");
+
+ try writeFile(tmp.dir, "system_tools/greet.lua",
+ \\return {
+ \\ name = "greet", description = "system version",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "SYSTEM" end,
+ \\}
+ );
+ try writeFile(tmp.dir, "user_tools/greet.lua",
+ \\return {
+ \\ name = "greet", description = "user version",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "USER" end,
+ \\}
+ );
+ try writeFile(tmp.dir, "project_tools/greet.lua",
+ \\return {
+ \\ name = "greet", description = "project version",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "PROJECT" end,
+ \\}
+ );
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const sys_len = try tmp.dir.realPathFile(testing.io, "system_tools", &path_buf);
+ const sys_path = try testing.allocator.dupe(u8, path_buf[0..sys_len]);
+ defer testing.allocator.free(sys_path);
+ const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf);
+ const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]);
+ defer testing.allocator.free(user_path);
+ const proj_len = try tmp.dir.realPathFile(testing.io, "project_tools", &path_buf);
+ const proj_path = try testing.allocator.dupe(u8, path_buf[0..proj_len]);
+ defer testing.allocator.free(proj_path);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+
+ const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
+ .system_tools = sys_path,
+ .user_tools = user_path,
+ .project_tools = proj_path,
+ });
+ try testing.expectEqual(@as(usize, 1), n_tools);
+
+ var src = rt.toolSource();
+ const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }};
+ var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
+ try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
+ defer testing.allocator.free(results[0].ok);
+ try testing.expectEqualStrings("PROJECT", results[0].ok);
+}
+
+test "loadFromDirs: user tool shadows system tool when no project entry" {
+ var tmp = testing.tmpDir(.{ .iterate = true });
+ defer tmp.cleanup();
+
+ try makeDir(tmp.dir, "system_tools");
+ try makeDir(tmp.dir, "user_tools");
+
+ try writeFile(tmp.dir, "system_tools/greet.lua",
+ \\return {
+ \\ name = "greet", description = "system",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "SYSTEM" end,
+ \\}
+ );
+ try writeFile(tmp.dir, "user_tools/greet.lua",
+ \\return {
+ \\ name = "greet", description = "user",
+ \\ schema = { type = "object" },
+ \\ handler = function(input) return "USER" end,
+ \\}
+ );
+
+ var path_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const sys_len = try tmp.dir.realPathFile(testing.io, "system_tools", &path_buf);
+ const sys_path = try testing.allocator.dupe(u8, path_buf[0..sys_len]);
+ defer testing.allocator.free(sys_path);
+ const user_len = try tmp.dir.realPathFile(testing.io, "user_tools", &path_buf);
+ const user_path = try testing.allocator.dupe(u8, path_buf[0..user_len]);
+ defer testing.allocator.free(user_path);
+
+ var rt = try LuaRuntime.create(testing.allocator);
+ defer rt.deinit();
+
+ const n_tools = try loadFromDirs(testing.allocator, testing.io, rt, .{
+ .system_tools = sys_path,
+ .user_tools = user_path,
+ });
+ try testing.expectEqual(@as(usize, 1), n_tools);
+
+ var src = rt.toolSource();
+ const calls = [_]panto.ToolCall{.{ .tool_name = "greet", .input = "{}" }};
+ var results: [1]panto.ToolCallResult = .{.{ .err = error.SourceDroppedCall }};
+ try src.vtable.invoke_batch(src.ctx, &calls, &results, testing.allocator);
+ defer testing.allocator.free(results[0].ok);
+ try testing.expectEqualStrings("USER", results[0].ok);
+}
+
test "loadFromDirs: extension and tool share a *file* name independently" {
// The shadow-key is (kind, name) so an extension named `foo` and a
// tool named `foo` coexist — as long as their *registered* tool names
diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig
index b67a8ad..0114b64 100644
--- a/src/luarocks_runtime.zig
+++ b/src/luarocks_runtime.zig
@@ -37,6 +37,7 @@ const manifest = @import("manifest.zig");
const panto_home = @import("panto_home.zig");
const embedded_luarocks = @import("embedded_luarocks");
const embedded_lua_headers = @import("embedded_lua_headers");
+const embedded_agent = @import("embedded_agent");
const lua_bridge = @import("lua_bridge.zig");
const c = lua_bridge.c;
@@ -135,6 +136,7 @@ pub fn bootstrap(
try panto_home.ensureDirsExist(layout, io);
try stageLuaHeaders(allocator, io, layout);
+ try stageAgentTree(allocator, io, layout);
const lua_wrapper_path = try writeLuaWrapper(allocator, io, layout, panto_executable_path);
defer allocator.free(lua_wrapper_path);
try writeLuarocksConfig(allocator, io, layout, lua_wrapper_path);
@@ -199,6 +201,41 @@ fn stageLuaHeaders(
}
}
+// ---------------------------------------------------------------------------
+// Stage the embedded `agent/` tree
+// ---------------------------------------------------------------------------
+
+/// Materialize the binary-embedded `agent/` tree under
+/// `$PANTO_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.
+///
+/// Uses the same `writeIfDifferent` discipline as `stageLuaHeaders`:
+/// unchanged files keep their on-disk mtime so downstream consumers
+/// (the extension loader's stat-cached searches, future-rocks builds
+/// against staged headers) don't see spurious invalidations.
+fn stageAgentTree(
+ allocator: Allocator,
+ io: Io,
+ layout: panto_home.Layout,
+) !void {
+ var root = try Io.Dir.cwd().openDir(io, layout.agent_dir, .{});
+ defer root.close(io);
+
+ for (embedded_agent.files) |entry| {
+ // Ensure the file's parent directory exists. `path` is
+ // forward-slash separated (the generator normalizes that),
+ // so we can split off the dirname directly.
+ if (std.fs.path.dirname(entry.path)) |parent| {
+ root.createDirPath(io, parent) catch |err| switch (err) {
+ error.PathAlreadyExists => {},
+ else => return err,
+ };
+ }
+ try writeIfDifferent(allocator, io, root, entry.path, entry.contents);
+ }
+}
+
/// Write `contents` into `dir/name` only if the existing file differs.
/// Creates the file if absent.
fn writeIfDifferent(
diff --git a/src/main.zig b/src/main.zig
index 3bc24e3..f95812d 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -399,14 +399,16 @@ pub fn main(init: std.process.Init) !void {
// chance to register tools that might want to yield.
try rt.installScheduler();
- // Discover Lua extensions from $XDG_CONFIG_HOME/panto/extensions (or
- // $HOME/.config/panto/extensions) and ./.panto/extensions. Project
- // entries shadow user entries with the same name; tool-name collisions
- // between extensions abort startup.
+ // Discover Lua extensions across three layers — system
+ // ($PANTO_HOME/agent), user ($XDG_CONFIG_HOME/panto or
+ // $HOME/.config/panto), and project (./.panto). Project shadows
+ // user shadows system; tool-name collisions across surviving
+ // entries abort startup.
const n_ext_tools = extension_loader.discoverAndLoad(
alloc,
io,
init.environ_map,
+ luarocks_rt.layout.agent_dir,
rt,
) catch |err| {
std.log.err("extension discovery failed: {t}", .{err});
diff --git a/src/panto_home.zig b/src/panto_home.zig
index b7c1799..353e54b 100644
--- a/src/panto_home.zig
+++ b/src/panto_home.zig
@@ -30,6 +30,11 @@ pub const Layout = struct {
allocator: Allocator,
/// `$PANTO_HOME` itself.
home: []u8,
+ /// `$PANTO_HOME/agent/` — the "system" 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 system.
+ agent_dir: []u8,
/// `$PANTO_HOME/rocks/lua-<lua_version>/` — the versioned tree.
tree: []u8,
/// `<tree>/include/` — where Lua headers are staged.
@@ -52,6 +57,7 @@ pub const Layout = struct {
pub fn deinit(self: Layout) void {
const a = self.allocator;
a.free(self.home);
+ a.free(self.agent_dir);
a.free(self.tree);
a.free(self.include_dir);
a.free(self.share_lua_dir);
@@ -76,6 +82,9 @@ pub fn resolve(
const home = try resolveHome(allocator, environ_map);
errdefer allocator.free(home);
+ const agent_dir = try std.fs.path.join(allocator, &.{ home, "agent" });
+ errdefer allocator.free(agent_dir);
+
// `<home>/rocks/lua-<lua_version>`
const tree_subdir = try std.fmt.allocPrint(
allocator,
@@ -111,6 +120,7 @@ pub fn resolve(
return .{
.allocator = allocator,
.home = home,
+ .agent_dir = agent_dir,
.tree = tree,
.include_dir = include_dir,
.share_lua_dir = share_lua_dir,
@@ -143,6 +153,7 @@ fn resolveHome(
/// — existing directories are left alone.
pub fn ensureDirsExist(layout: Layout, io: Io) !void {
try makePathRecursive(io, layout.home);
+ try makePathRecursive(io, layout.agent_dir);
try makePathRecursive(io, layout.tree);
try makePathRecursive(io, layout.include_dir);
try makePathRecursive(io, layout.share_lua_dir);