summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/extension_loader.zig14
-rw-r--r--src/lua_bridge.zig322
-rw-r--r--src/lua_event_bridge.zig275
-rw-r--r--src/lua_runtime.zig220
-rw-r--r--src/main.zig4
-rw-r--r--src/tui_app.zig174
-rw-r--r--src/tui_event.zig50
7 files changed, 858 insertions, 201 deletions
diff --git a/src/extension_loader.zig b/src/extension_loader.zig
index 4620a2e..1c29ceb 100644
--- a/src/extension_loader.zig
+++ b/src/extension_loader.zig
@@ -220,7 +220,11 @@ pub fn loadFromDirs(
for (found.items) |f| {
var entries: std.array_list.Managed(Entry) = .init(allocator);
defer entries.deinit();
- try runtime.evalEntries(f.script_path, f.package_root, &entries);
+ runtime.evalEntries(f.script_path, f.package_root, &entries) catch |err| {
+ // Entries collected before the failure hold Lua refs + names.
+ for (entries.items) |e| runtime.dropEntry(e);
+ return err;
+ };
for (entries.items) |e| {
try cands.append(.{ .entry = e, .source = f.source, .script_path = f.script_path });
}
@@ -254,10 +258,13 @@ pub fn loadFromDirs(
runtime.dropEntry(cnd.entry);
continue;
}
+ // Log before activating: `activateEntry` consumes the entry
+ // (frees its name), so `cnd.entry.name` is dangling afterwards.
+ std.log.debug("extension: activating '{s}' ({s})", .{ cnd.entry.name, cnd.source.label() });
runtime.activateEntry(cnd.entry) catch |err| {
logConflict(
- "extension '{s}' ({s}: {s}) failed to activate: {t}",
- .{ cnd.entry.name, cnd.source.label(), cnd.script_path, err },
+ "extension ({s}: {s}) failed to activate: {t}",
+ .{ cnd.source.label(), cnd.script_path, err },
);
// activateEntry consumed the entry already. Drop the rest.
var j = i + 1;
@@ -265,7 +272,6 @@ pub fn loadFromDirs(
cands.clearRetainingCapacity();
return err;
};
- std.log.debug("extension: activated '{s}' ({s})", .{ cnd.entry.name, cnd.source.label() });
}
cands.clearRetainingCapacity();
diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig
index 6395293..6bb318d 100644
--- a/src/lua_bridge.zig
+++ b/src/lua_bridge.zig
@@ -237,10 +237,43 @@ pub fn install(L: *c.lua_State) void {
// Default `emit`: a no-op until the runtime installs the real one.
c.lua_pushcclosure(L, emitNoopThunk, 0);
c.lua_setfield(L, -2, "emit");
+ // `panto.ext.json`: the JSON codec the platform already uses for tool
+ // inputs and schemas, exposed so extensions can decode event payloads
+ // (e.g. `tool_result.input`) and build `tool_call_complete.input`
+ // overrides without a rock dependency.
+ c.lua_createtable(L, 0, 2);
+ c.lua_pushcclosure(L, jsonDecodeThunk, 0);
+ c.lua_setfield(L, -2, "decode");
+ c.lua_pushcclosure(L, jsonEncodeThunk, 0);
+ c.lua_setfield(L, -2, "encode");
+ c.lua_setfield(L, -2, "json");
// Stash `ext` under its registry key, consuming it.
c.lua_rawsetp(L, LUA_REGISTRYINDEX, &ext_table_key);
}
+/// `panto.ext.json.decode(str)` -> Lua value. Accepts any top-level JSON
+/// value; JSON `null` decodes to Lua `nil` (documented lossiness for null
+/// object values).
+fn jsonDecodeThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ var len: usize = 0;
+ const ptr = c.luaL_checklstring(L, 1, &len);
+ var parsed = std.json.parseFromSlice(std.json.Value, std.heap.c_allocator, ptr[0..len], .{}) catch {
+ return c.luaL_error(L, "json.decode: invalid JSON");
+ };
+ defer parsed.deinit();
+ pushJsonValue(L, parsed.value) catch return c.luaL_error(L, "json.decode: out of memory");
+ return 1;
+}
+
+/// `panto.ext.json.encode(value)` -> JSON string. Same value domain as tool
+/// schemas: strings, numbers, booleans, nil, array- or object-shaped tables.
+fn jsonEncodeThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ pushValueAsJson(L, 1) catch return c.luaL_error(L, "json.encode: value is not JSON-serializable");
+ return 1;
+}
+
/// Override `panto.ext.emit` with a closure that carries `ctx` as a
/// light-userdata upvalue and dispatches into `emit_fn`. The runtime calls
/// this after `install` so a Lua `emit(name, data)` reaches the native
@@ -268,6 +301,285 @@ fn emitNoopThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
return 0;
}
+// ---------------------------------------------------------------------------
+// User-Lua entrypoints: "you are always in a panto-managed uv loop"
+// ---------------------------------------------------------------------------
+
+/// True while a panto-driven `uv.run` is executing on the shared state —
+/// set by the tool scheduler's batch driver and by `runEntrypoint`'s own
+/// pump. Consulted by nested dispatches (e.g. `panto.ext.emit` fired from
+/// inside already-running Lua): the loop is not re-entrant, so a nested
+/// entrypoint must complete without awaiting.
+pub var uv_loop_active: bool = false;
+
+/// Registry key for the memoized stash-wrapper function (see
+/// `pushStashWrapper`).
+var entry_wrapper_key: u8 = 0;
+/// Registry key for the result stash: a weak-keyed table mapping a
+/// coroutine thread -> `table.pack(...)` of its entrypoint's results.
+var entry_results_key: u8 = 0;
+
+/// Run a user-provided Lua entrypoint — an extension `activate()`, an
+/// `on` event handler, a slash-command handler, a component method —
+/// under the uniform contract: the code runs inside a coroutine, and the
+/// libuv loop is drained before returning. User Lua may therefore always
+/// await callback-form uv operations (arm, yield, resume-from-callback),
+/// exactly as in tool handlers, and any fire-and-forget coroutines it
+/// forks are also run to completion; no entrypoint needs to know whether
+/// a loop "is already there".
+///
+/// Expects the function and its `nargs` arguments on top of `L`;
+/// consumes them. Return values are discarded (use `runEntrypointResult`
+/// to keep one). Synchronous from the caller's view: returns only once
+/// the entrypoint has terminated, so registration-ordered handler chains
+/// and the component get/set pattern keep their sequencing.
+///
+/// On failure the error text (with traceback where available) is copied
+/// into `err_buf` when provided (`err_len.*` = bytes written, truncated)
+/// and logged otherwise.
+pub fn runEntrypoint(
+ L: *c.lua_State,
+ nargs: c_int,
+ label: []const u8,
+ err_buf: ?[]u8,
+ err_len: ?*usize,
+) error{LuaRunFailed}!void {
+ return runEntrypointInner(L, nargs, false, label, err_buf, err_len);
+}
+
+/// `runEntrypoint`, keeping the entrypoint's first return value: on
+/// success exactly one value (the result, or nil if it returned nothing)
+/// is left on top of `L`. Results are captured *inside* the coroutine (a
+/// wrapper stashes them keyed by thread), so they survive completion via
+/// a uv-callback resume — where a coroutine's return values flow to the
+/// resuming callback, not to us.
+pub fn runEntrypointResult(
+ L: *c.lua_State,
+ nargs: c_int,
+ label: []const u8,
+ err_buf: ?[]u8,
+ err_len: ?*usize,
+) error{LuaRunFailed}!void {
+ return runEntrypointInner(L, nargs, true, label, err_buf, err_len);
+}
+
+fn runEntrypointInner(
+ L: *c.lua_State,
+ nargs: c_int,
+ want_result: bool,
+ label: []const u8,
+ err_buf: ?[]u8,
+ err_len: ?*usize,
+) error{LuaRunFailed}!void {
+ const base = c.lua_gettop(L) - nargs - 1; // index below the fn
+ errdefer c.lua_settop(L, base);
+
+ // With a result requested, run `wrapper(fn, args...)` instead of
+ // `fn(args...)`; the wrapper stashes `table.pack(fn(...))` under the
+ // running thread before the coroutine terminates.
+ var total_args = nargs;
+ if (want_result) {
+ pushStashWrapper(L) catch {
+ reportEntrypointFailure(label, "failed to build the result wrapper", err_buf, err_len);
+ return error.LuaRunFailed;
+ };
+ c.lua_rotate(L, base + 1, 1); // wrapper beneath fn: wrapper, fn, args...
+ total_args += 1;
+ }
+
+ const co_ptr = c.lua_newthread(L) orelse {
+ reportEntrypointFailure(label, "out of memory creating coroutine", err_buf, err_len);
+ return error.LuaRunFailed;
+ };
+ const co: *c.lua_State = @ptrCast(co_ptr);
+ // Stack: [wrapper,] fn, args..., co. Rotate the thread to the bottom so
+ // it stays anchored on `L` (GC-safe) while the callable+args move over.
+ c.lua_rotate(L, base + 1, 1);
+ c.lua_xmove(L, co, total_args + 1);
+
+ var nres: c_int = 0;
+ const first = c.lua_resume(co, L, total_args, &nres);
+
+ if (uv_loop_active) {
+ // An outer frame is already driving the loop and uv.run is not
+ // re-entrant, so a nested entrypoint (e.g. one reached via `emit`
+ // from running Lua) cannot wait here. It must complete on its
+ // first resume; anything it armed is picked up by the outer drain.
+ if (first == c.LUA_YIELD) {
+ reportEntrypointFailure(
+ label,
+ "yielded during nested dispatch (the uv loop is already " ++
+ "running); an entrypoint invoked from running Lua " ++
+ "(e.g. via emit) must not await",
+ err_buf,
+ err_len,
+ );
+ return error.LuaRunFailed;
+ }
+ } else {
+ // Drain the loop completely — unconditionally, even when the
+ // first resume already finished. The entrypoint may have forked
+ // fire-and-forget coroutines whose armed callbacks would
+ // otherwise never run; nothing user Lua started may be left
+ // hanging when we return. (Corollary of the contract: an
+ // entrypoint must not leave long-lived handles armed — a
+ // repeating timer would hold this drain open forever, exactly as
+ // it would the tool batch's.)
+ drainLoop(L) catch {
+ // A handler error surfacing through a uv callback propagates
+ // out of uv.run; its message is on top of L.
+ reportErrorAtTop(L, label, err_buf, err_len);
+ return error.LuaRunFailed;
+ };
+ }
+
+ const status = c.lua_status(co);
+ if (status == c.LUA_YIELD) {
+ reportEntrypointFailure(
+ label,
+ "suspended after the uv loop drained: it yielded without a " ++
+ "pending libuv operation to resume it",
+ err_buf,
+ err_len,
+ );
+ return error.LuaRunFailed;
+ }
+ if (status != c.LUA_OK) {
+ // Error object on the coroutine's stack; render with traceback.
+ var mlen: usize = 0;
+ const mptr = c.lua_tolstring(co, -1, &mlen);
+ c.luaL_traceback(L, co, mptr, 0);
+ reportErrorAtTop(L, label, err_buf, err_len);
+ return error.LuaRunFailed;
+ }
+
+ if (!want_result) {
+ c.lua_settop(L, base);
+ return;
+ }
+
+ // Fetch (and clear) this thread's stashed result pack.
+ _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &entry_results_key);
+ if (c.lua_type(L, -1) != T_TABLE) {
+ reportEntrypointFailure(label, "no result was recorded", err_buf, err_len);
+ return error.LuaRunFailed;
+ }
+ c.lua_pushvalue(L, base + 1); // the thread (anchored at base+1)
+ _ = c.lua_rawget(L, -2); // Stack: ..., stash, packed|nil
+ c.lua_pushvalue(L, base + 1);
+ c.lua_pushnil(L);
+ c.lua_rawset(L, -4); // stash[thread] = nil
+ if (c.lua_type(L, -1) != T_TABLE) {
+ reportEntrypointFailure(label, "no result was recorded", err_buf, err_len);
+ return error.LuaRunFailed;
+ }
+ _ = c.lua_rawgeti(L, -1, 1); // first result (nil if it returned none)
+ // Stack: base..., co, stash, packed, result → keep only the result.
+ c.lua_rotate(L, base + 1, 1);
+ c.lua_settop(L, base + 1);
+}
+
+/// Push the memoized stash wrapper `function(fn, ...)` that runs `fn` and
+/// records `table.pack(fn(...))` in the result stash keyed by the running
+/// thread. The stash has weak keys so an abandoned thread's entry does
+/// not pin it.
+fn pushStashWrapper(L: *c.lua_State) !void {
+ _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &entry_wrapper_key);
+ if (c.lua_type(L, -1) == T_FUNCTION) return;
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop nil
+
+ // Ensure the stash table exists.
+ _ = c.lua_rawgetp(L, LUA_REGISTRYINDEX, &entry_results_key);
+ if (c.lua_type(L, -1) != T_TABLE) {
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop nil
+ c.lua_createtable(L, 0, 2);
+ c.lua_createtable(L, 0, 1); // its metatable
+ _ = c.lua_pushlstring(L, "k", 1);
+ c.lua_setfield(L, -2, "__mode"); // weak keys
+ _ = c.lua_setmetatable(L, -2);
+ c.lua_pushvalue(L, -1);
+ c.lua_rawsetp(L, LUA_REGISTRYINDEX, &entry_results_key);
+ }
+ // Stack: ..., stash. Compile the wrapper factory and apply it.
+ const chunk =
+ "local stash = ...\n" ++
+ "return function(fn, ...)\n" ++
+ " stash[coroutine.running()] = table.pack(fn(...))\n" ++
+ "end\n";
+ if (c.luaL_loadstring(L, chunk) != 0) {
+ c.lua_settop(L, c.lua_gettop(L) - 2); // pop error + stash
+ return error.LuaLoadFailed;
+ }
+ c.lua_rotate(L, -2, 1); // chunk beneath stash
+ if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) {
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop error
+ return error.LuaRunFailed;
+ }
+ // Stack: ..., wrapper. Memoize and leave it on top.
+ c.lua_pushvalue(L, -1);
+ c.lua_rawsetp(L, LUA_REGISTRYINDEX, &entry_wrapper_key);
+}
+
+/// `require("luv").run("default")`: run the loop until no active handles
+/// or requests remain, marking it panto-driven for the duration (nested
+/// entrypoints consult `uv_loop_active`). Returns immediately on an idle
+/// loop. When luv is not available (unit-test builds don't link it) there
+/// is no loop and nothing could have armed work on one — the drain is a
+/// no-op. On a `uv.run` error the Lua message is left on top of `L`.
+/// Shared by `runEntrypoint` and the tool scheduler's batch driver.
+pub fn drainLoop(L: *c.lua_State) !void {
+ uv_loop_active = true;
+ defer uv_loop_active = false;
+ _ = c.lua_getglobal(L, "require");
+ _ = c.lua_pushlstring(L, "luv", 3);
+ if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) {
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop the require error
+ return;
+ }
+ _ = c.lua_getfield(L, -1, "run");
+ c.lua_copy(L, -1, -2);
+ c.lua_settop(L, c.lua_gettop(L) - 1); // Stack: ..., run
+ _ = c.lua_pushlstring(L, "default", 7);
+ if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) return error.UvRunFailed;
+ c.lua_settop(L, c.lua_gettop(L) - 1); // discard the boolean
+}
+
+/// Deliver an entrypoint failure: copy into the caller's buffer when one
+/// was provided, else log (warn under test — the test runner fails any
+/// test that logs at error level, and several tests exercise failures).
+fn reportEntrypointFailure(
+ label: []const u8,
+ msg: []const u8,
+ err_buf: ?[]u8,
+ err_len: ?*usize,
+) void {
+ if (err_buf) |buf| {
+ const n = @min(msg.len, buf.len);
+ @memcpy(buf[0..n], msg[0..n]);
+ if (err_len) |lp| lp.* = n;
+ return;
+ }
+ if (@import("builtin").is_test) {
+ std.log.warn("lua: '{s}' entrypoint failed: {s}", .{ label, msg });
+ } else {
+ std.log.err("lua: '{s}' entrypoint failed: {s}", .{ label, msg });
+ }
+}
+
+/// Like `reportEntrypointFailure`, with the message taken from the value
+/// at the top of `L` (not popped; the caller's stack cleanup handles it).
+fn reportErrorAtTop(
+ L: *c.lua_State,
+ label: []const u8,
+ err_buf: ?[]u8,
+ err_len: ?*usize,
+) void {
+ var len: usize = 0;
+ const ptr = c.lua_tolstring(L, -1, &len);
+ const msg: []const u8 = if (ptr != null) ptr[0..len] else "(no error message)";
+ reportEntrypointFailure(label, msg, err_buf, err_len);
+}
+
/// Replace the registrations table with a fresh empty one. Used by the
/// long-lived runtime between loading distinct extension scripts so it
/// can harvest only the registrations made by the script just loaded
@@ -627,7 +939,7 @@ fn registerToolThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
expectField(L, 1, "handler", T_FUNCTION);
// Serialize the schema table to JSON, leaving the string on top.
- pushSchemaAsJson(L, 4) catch |err| {
+ pushValueAsJson(L, 4) catch |err| {
const msg = switch (err) {
BridgeError.OutOfMemory => "register_tool: out of memory serializing schema",
else => "register_tool: schema is not JSON-serializable",
@@ -768,9 +1080,11 @@ fn expectFieldNamed(
// JSON <-> Lua conversion
// ---------------------------------------------------------------------------
-/// Serialize the Lua table at stack index `idx` to a JSON string and push
-/// that string onto the stack.
-fn pushSchemaAsJson(L: *c.lua_State, idx: c_int) BridgeError!void {
+/// Serialize the Lua value at stack index `idx` to a JSON string and push
+/// that string onto the stack. Accepts strings, numbers, booleans, nil,
+/// and (array- or object-shaped) tables. Used for tool schemas at
+/// registration and for `tool_call_complete.input` event overrides.
+pub fn pushValueAsJson(L: *c.lua_State, idx: c_int) BridgeError!void {
var aw: std.Io.Writer.Allocating = .init(std.heap.c_allocator);
defer aw.deinit();
diff --git a/src/lua_event_bridge.zig b/src/lua_event_bridge.zig
index 044dd55..06a5ddf 100644
--- a/src/lua_event_bridge.zig
+++ b/src/lua_event_bridge.zig
@@ -10,16 +10,18 @@
//! It is owned by the long-lived `LuaRuntime` (one `lua_State` for the
//! whole session) and shares that state. It never opens its own state.
//!
-//! ## Synchronous rendering (NOT the coroutine scheduler)
+//! ## Rendering runs through the shared entrypoint machinery
//!
//! A Lua-defined component's `render(width)` is on the engine's hot path:
//! the differential renderer calls it and needs the produced lines back
-//! IMMEDIATELY. So the bridged vtable calls into Lua **synchronously** via
-//! `lua_pcallk` (the same model as `LuaRuntime.runCommand`), NOT through
-//! the libuv coroutine scheduler used for async tool batches. A render
-//! callback may not yield; if a Lua component blocks, that is a bug in the
-//! extension, and we surface a safe fallback line rather than hanging or
-//! corrupting the frame.
+//! before the frame can complete. Like every user-Lua entrypoint it runs
+//! via `lua_bridge.runEntrypointResult` — inside a coroutine, with the uv
+//! loop drained before returning — so a renderer *may* await libuv work,
+//! at the cost of putting that I/O latency inside the paint. The call is
+//! still synchronous from the engine's view. On any Lua error (including
+//! yielding inside a nested dispatch, where the loop cannot be driven) we
+//! surface a safe fallback line rather than hanging or corrupting the
+//! frame.
//!
//! ## Cache-derived `firstLineChanged`
//!
@@ -216,15 +218,19 @@ pub const EventBridge = struct {
// -- bridged component construction ------------------------------------
/// Build a native `Component` backed by the Lua component table at
- /// stack index `table_idx`. `luaL_ref`s the table, allocates a tracked
- /// `BridgedComponent`, and returns its `comp()`. The component lives
- /// until `invalidate` (which frees it) or runtime teardown.
+ /// stack index `table_idx` of `L` — the *calling* state, which since
+ /// entrypoints run as coroutines is usually a handler's thread, not
+ /// `self.L`. The registry is shared across threads, so the ref taken
+ /// here stays usable from `self.L` for rendering and teardown.
+ /// Allocates a tracked `BridgedComponent` and returns its `comp()`;
+ /// the component lives until `invalidate` (which frees it) or runtime
+ /// teardown.
///
/// No "active component": each call mints a fresh instance.
- fn makeBridgedComponent(self: *EventBridge, table_idx: c_int) !Component {
- c.lua_pushvalue(self.L, table_idx);
- const ref = c.luaL_ref(self.L, lua_bridge.LUA_REGISTRYINDEX);
- errdefer c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, ref);
+ fn makeBridgedComponent(self: *EventBridge, L: *c.lua_State, table_idx: c_int) !Component {
+ c.lua_pushvalue(L, table_idx);
+ const ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX);
+ errdefer c.luaL_unref(L, lua_bridge.LUA_REGISTRYINDEX, ref);
const bc = try self.alloc.create(BridgedComponent);
errdefer self.alloc.destroy(bc);
@@ -285,15 +291,19 @@ pub const EventBridge = struct {
self.releaseOverride(old);
}
- // -- bridged render / input (SYNCHRONOUS pcall) ------------------------
+ // -- bridged render / input (shared entrypoint machinery) --------------
- /// Render a Lua-defined component synchronously: push its table +
- /// `render` method + width, `lua_pcallk`, marshal the returned
- /// array-of-strings into the component's `RenderCache`, and return the
- /// cached lines. On ANY Lua error or bad return, return a single dim
- /// error line instead of crashing the render loop (the frame must
- /// always complete). Every returned line is truncated to `width` so
- /// the engine's width contract (§3.1) is never violated.
+ /// Render a Lua-defined component: push its table + `render` method +
+ /// width, run it as a user-Lua entrypoint (`runEntrypointResult` —
+ /// coroutine + loop drain, so a renderer may await uv work like any
+ /// other user Lua), marshal the returned array-of-strings into the
+ /// component's `RenderCache`, and return the cached lines. The call is
+ /// synchronous from the engine's view — the frame waits for the lines
+ /// (an awaiting renderer puts its I/O latency inside that paint). On
+ /// ANY Lua error or bad return, return a single dim error line instead
+ /// of crashing the render loop (the frame must always complete). Every
+ /// returned line is truncated to `width` so the engine's width
+ /// contract (§3.1) is never violated.
fn renderBridged(self: *EventBridge, bc: *BridgedComponent, width: usize) anyerror![]const []const u8 {
const L = self.L;
if (bc.dead) return &.{};
@@ -308,12 +318,14 @@ pub const EventBridge = struct {
if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) {
return self.fallbackLines(bc, width, "component has no render()");
}
- // Stack: ..., table, render. Call render(table, width) -> lines.
+ // Stack: ..., table, render. Run render(table, width) -> lines.
c.lua_pushvalue(L, -2); // self (the table)
c.lua_pushinteger(L, @intCast(width));
- if (c.lua_pcallk(L, 2, 1, 0, 0, null) != 0) {
- return self.fallbackLines(bc, width, luaErrText(L));
- }
+ var err_buf: [256]u8 = undefined;
+ var err_len: usize = 0;
+ lua_bridge.runEntrypointResult(L, 2, "component render()", &err_buf, &err_len) catch {
+ return self.fallbackLines(bc, width, err_buf[0..err_len]);
+ };
// Stack top: the returned lines table.
const raw = lua_bridge.readLinesArray(L, -1, self.alloc) catch {
return self.fallbackLines(bc, width, "render() must return an array of strings");
@@ -363,7 +375,8 @@ pub const EventBridge = struct {
}
/// Feed input to a Lua component's optional `handleInput(self, data)`
- /// method, synchronously. Errors are swallowed (input handling must
+ /// method, run as a user-Lua entrypoint (coroutine + loop drain, like
+ /// every other user Lua). Errors are swallowed (input handling must
/// never crash the loop); a component without the method ignores input.
fn handleInputBridged(self: *EventBridge, bc: *BridgedComponent, data: []const u8) void {
const L = self.L;
@@ -377,8 +390,8 @@ pub const EventBridge = struct {
if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) return;
c.lua_pushvalue(L, -2); // self
_ = c.lua_pushlstring(L, data.ptr, data.len);
- // Any error: ignore (best-effort input).
- _ = c.lua_pcallk(L, 2, 0, 0, 0, null);
+ // Any error: already reported by the helper; best-effort input.
+ lua_bridge.runEntrypoint(L, 2, "component handleInput()", null, null) catch {};
// Mark the cache dirty after input. The engine reads
// `firstLineChanged()` BEFORE calling `render`, so a clean cache means
@@ -399,11 +412,15 @@ pub const EventBridge = struct {
// ===========================================================================
/// The native `Handler.callback` for a bridged Lua handler. Builds the
-/// `event` userdata, calls the Lua function with it (synchronously, under
-/// a traceback errfunc), and lets the Lua side read/replace the component
-/// via `event:get_component()` / `event:set_component()`. Errors in the Lua
-/// handler are logged and swallowed — a broken handler must not abort the
-/// event dispatch or the render loop.
+/// `event` userdata and runs the Lua function with it as a user-Lua
+/// entrypoint — inside a coroutine, with the uv loop driven until the
+/// handler completes — so handlers may await libuv work exactly like
+/// tool handlers. The call is synchronous from the dispatcher's view:
+/// it returns only once the handler has terminated, preserving
+/// registration-ordered chaining and the `get_component()` → wrap →
+/// `set_component()` pattern (`active_event` stays pinned for the whole
+/// run). Errors in the Lua handler are logged and swallowed — a broken
+/// handler must not abort the event dispatch or the render loop.
fn nativeHandlerCallback(ctx: *anyopaque, event: *Event) void {
const h: *LuaHandler = @ptrCast(@alignCast(ctx));
const self = h.bridge;
@@ -417,25 +434,13 @@ fn nativeHandlerCallback(ctx: *anyopaque, event: *Event) void {
const base = c.lua_gettop(L);
defer c.lua_settop(L, base);
- // Traceback errfunc beneath the call.
- _ = c.lua_getglobal(L, "debug");
- _ = c.lua_getfield(L, -1, "traceback");
- c.lua_copy(L, -1, -2);
- c.lua_settop(L, c.lua_gettop(L) - 1); // pop `debug` table
- const errfunc_idx = c.lua_gettop(L);
-
_ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(h.fn_ref));
pushEventObject(self) catch {
c.lua_settop(L, base);
return;
};
- if (c.lua_pcallk(L, 1, 0, errfunc_idx, 0, null) != 0) {
- var len: usize = 0;
- const msg = c.lua_tolstring(L, -1, &len);
- if (msg != null) {
- std.log.err("lua event handler '{s}' failed: {s}", .{ h.event_name, msg[0..len] });
- }
- }
+ // Failure is reported (with traceback) inside runEntrypoint.
+ lua_bridge.runEntrypoint(L, 1, h.event_name, null, null) catch {};
}
// ===========================================================================
@@ -463,7 +468,64 @@ fn ensureEventMetatable(L: *c.lua_State) void {
if (c.luaL_newmetatable(L, eventMtName) != 0) {
c.lua_pushcclosure(L, eventIndexThunk, 0);
c.lua_setfield(L, -2, "__index");
+ c.lua_pushcclosure(L, eventNewindexThunk, 0);
+ c.lua_setfield(L, -2, "__newindex");
+ }
+}
+
+/// Raise a Lua error with a formatted message (never returns normally).
+fn luaErrFmt(L: *c.lua_State, comptime fmt: []const u8, args: anytype) c_int {
+ var buf: [256]u8 = undefined;
+ const msg = std.fmt.bufPrint(&buf, fmt, args) catch fmt;
+ _ = c.lua_pushlstring(L, msg.ptr, msg.len);
+ return c.lua_error(L);
+}
+
+/// `__newindex(event_ud, key, value)`: the "override via mutable attribute"
+/// paradigm. Assigning to an event's writable payload field (see
+/// `tui_event.writableField` — e.g. `ev.output = ...` on `tool_result`)
+/// records an override on the bus. Later handlers in the same dispatch read
+/// the overridden value (chaining, last writer wins), and the firer applies
+/// it to the real underlying state after dispatch. Any other assignment is
+/// an error — events are otherwise read-only.
+///
+/// `tool_call_complete.input` is assigned as a JSON-able table (tool inputs
+/// are JSON objects on the wire) and serialized here, so a handler gets an
+/// immediate error for unserializable values; `tool_result.output` and
+/// `user_message.text` are plain strings.
+fn eventNewindexThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ const ud: **EventBridge = @ptrCast(@alignCast(c.luaL_checkudata(L, 1, eventMtName) orelse return 0));
+ const bridge = ud.*;
+ var klen: usize = 0;
+ const kptr = c.luaL_checklstring(L, 2, &klen);
+ const key = kptr[0..klen];
+
+ const ev = bridge.active_event orelse
+ return luaErrFmt(L, "event fields can only be assigned inside a handler", .{});
+ const writable = ui_event.writableField(ev.name) orelse
+ return luaErrFmt(L, "event '{s}' has no writable fields", .{ev.name});
+ if (!std.mem.eql(u8, key, writable))
+ return luaErrFmt(L, "field '{s}' of event '{s}' is not writable (only '{s}')", .{ key, ev.name, writable });
+ const bus = bridge.bus orelse
+ return luaErrFmt(L, "event '{s}': no event bus attached", .{ev.name});
+
+ if (std.mem.eql(u8, ev.name, "tool_call_complete")) {
+ if (c.lua_type(L, 3) != lua_bridge.T_TABLE)
+ return luaErrFmt(L, "'input' override must be a table (tool input is a JSON object)", .{});
+ lua_bridge.pushValueAsJson(L, 3) catch
+ return luaErrFmt(L, "'input' override is not JSON-serializable", .{});
+ } else {
+ if (c.lua_type(L, 3) != lua_bridge.T_STRING)
+ return luaErrFmt(L, "'{s}' override must be a string", .{writable});
+ c.lua_pushvalue(L, 3);
}
+ var vlen: usize = 0;
+ const vptr = c.lua_tolstring(L, -1, &vlen);
+ bus.setOverride(vptr[0..vlen]) catch
+ return luaErrFmt(L, "out of memory recording event override", .{});
+ c.lua_settop(L, c.lua_gettop(L) - 1); // pop the value copy / JSON string
+ return 0;
}
/// `__index(event_ud, key)`: dispatch method names and payload fields.
@@ -493,6 +555,19 @@ fn eventIndexThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
_ = c.lua_pushlstring(L, ev.name.ptr, ev.name.len);
return 1;
}
+ // A pending override shadows the raw payload field, so handlers later
+ // in the dispatch chain read what earlier ones wrote (§ writable
+ // event fields).
+ if (bridge.bus) |bus| {
+ if (bus.override_value) |ov| {
+ if (ui_event.writableField(ev.name)) |wf| {
+ if (std.mem.eql(u8, key, wf)) {
+ _ = c.lua_pushlstring(L, ov.ptr, ov.len);
+ return 1;
+ }
+ }
+ }
+ }
pushPayloadField(L, ev, key);
return 1;
}
@@ -625,7 +700,7 @@ fn eventSetComponentThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
return c.luaL_error(L, "set_component: unknown userdata (expected a component)");
}
if (ty == lua_bridge.T_TABLE) {
- const comp = bridge.makeBridgedComponent(2) catch {
+ const comp = bridge.makeBridgedComponent(L, 2) catch {
return c.luaL_error(L, "set_component: failed to bridge Lua component");
};
ev.setComponent(comp);
@@ -644,19 +719,42 @@ fn pushNativeComponent(L: *c.lua_State, comp: Component) void {
}
fn ensureNativeCompMetatable(L: *c.lua_State) void {
- // A bare named metatable (no methods); identity-only so
- // `luaL_testudata` can recognize the passthrough handle.
- _ = c.luaL_newmetatable(L, nativeCompMtName);
+ if (c.luaL_newmetatable(L, nativeCompMtName) != 0) {
+ // One method: `handle:render(width) -> { line, ... }`, so a Lua
+ // wrapper component can render *through* the native default it
+ // wrapped (the §7.5 wrap pattern) and append its own lines below.
+ c.lua_createtable(L, 0, 1);
+ c.lua_pushcclosure(L, nativeCompRenderThunk, 0);
+ c.lua_setfield(L, -2, "render");
+ c.lua_setfield(L, -2, "__index");
+ }
}
-/// Read the top-of-stack Lua error as text (after a failed pcall).
-fn luaErrText(L: *c.lua_State) []const u8 {
- var len: usize = 0;
- const ptr = c.lua_tolstring(L, -1, &len);
- if (ptr == null) return "lua error";
- return ptr[0..len];
+/// `handle:render(width)`: invoke the wrapped native component's vtable
+/// render and return its lines as a Lua array of strings. The lines are
+/// copied onto the Lua stack, so the native component's cache ownership is
+/// untouched. Errors surface as Lua errors (a wrapper's render already runs
+/// under the entrypoint machinery, which reports and falls back safely).
+fn nativeCompRenderThunk(L_opt: ?*c.lua_State) callconv(.c) c_int {
+ const L = L_opt.?;
+ const ud: *Component = @ptrCast(@alignCast(c.luaL_checkudata(L, 1, nativeCompMtName) orelse return 0));
+ const width_i = c.luaL_checkinteger(L, 2);
+ if (width_i < 0) return c.luaL_error(L, "render: width must be non-negative");
+ const width: usize = @intCast(width_i);
+ // Native components render from their own caches; the allocator
+ // parameter is unused by them (mirrors the engine's call shape).
+ const lines = ud.vtable.render(ud.ptr, width, std.heap.c_allocator) catch {
+ return c.luaL_error(L, "render: native component render failed");
+ };
+ c.lua_createtable(L, @intCast(lines.len), 0);
+ for (lines, 1..) |line, i| {
+ _ = c.lua_pushlstring(L, line.ptr, line.len);
+ c.lua_rawseti(L, -2, @intCast(i));
+ }
+ return 1;
}
+/// Read the top-of-stack Lua error as text (after a failed pcall).
/// Re-type the cache's owned lines as `[]const []const u8` for the vtable
/// return (mirrors the native components' `cacheLines`).
fn cacheLines(cache: *RenderCache) []const []const u8 {
@@ -1463,6 +1561,67 @@ test "lifecycle payload fields marshal to Lua: tool id/delta/input/output, think
);
}
+test "writable event fields: overrides chain across handlers, land on the bus, and reject bad writes" {
+ const L = c.luaL_newstate() orelse return error.LuaInitFailed;
+ defer c.lua_close(L);
+ c.luaL_openlibs(L);
+ lua_bridge.install(L);
+ lua_bridge.installTestPantoTable(L);
+
+ var bridge = EventBridge.init(testing.allocator, L);
+ defer bridge.deinit();
+ var bus = EventBus.init(testing.allocator);
+ defer bus.deinit();
+
+ try runScript(L,
+ \\seen = {}
+ \\panto.ext.on("tool_result", function(e)
+ \\ e.output = e.output .. " [extra]"
+ \\end)
+ \\panto.ext.on("tool_result", function(e)
+ \\ -- Chaining: this handler must read the first handler's write.
+ \\ seen.chained = e.output
+ \\ -- Read-only fields still reject assignment.
+ \\ local ok, err = pcall(function() e.id = "nope" end)
+ \\ seen.readonly_err = not ok and err or nil
+ \\end)
+ \\panto.ext.on("tool_call_complete", function(e)
+ \\ -- input is assigned as a table and serialized to JSON.
+ \\ local ok = pcall(function() e.input = "raw string" end)
+ \\ seen.input_string_rejected = not ok
+ \\ e.input = { path = "redirected.txt" }
+ \\ seen.input_after = e.input
+ \\end)
+ \\panto.ext.on("session_start", function(e)
+ \\ local ok, err = pcall(function() e.cwd = "/elsewhere" end)
+ \\ seen.observe_only_err = not ok and err or nil
+ \\end)
+ );
+ try harvestOnInto(&bridge);
+ try bridge.attachBus(&bus);
+
+ _ = bus.fire("tool_result", null, .{ .tool = .{ .id = "c1", .output = "result" } });
+ const out = bus.takeOverride() orelse return error.NoOverride;
+ defer testing.allocator.free(out);
+ try testing.expectEqualStrings("result [extra]", out);
+
+ _ = bus.fire("tool_call_complete", null, .{ .tool = .{ .id = "c1", .input = "{\"path\":\"orig.txt\"}" } });
+ const inp = bus.takeOverride() orelse return error.NoOverride;
+ defer testing.allocator.free(inp);
+ try testing.expectEqualStrings("{\"path\":\"redirected.txt\"}", inp);
+
+ _ = bus.fire("session_start", null, .{ .session_start = .{ .version = "v", .cwd = "/", .model = "m" } });
+ try testing.expect(bus.takeOverride() == null);
+
+ try runScript(L,
+ \\assert(seen.chained == "result [extra]", "second handler sees first's write")
+ \\assert(seen.readonly_err and seen.readonly_err:find("not writable"), "read-only field rejects")
+ \\assert(seen.input_string_rejected, "input override must be a table")
+ \\assert(seen.input_after == '{"path":"redirected.txt"}', "chained input read is the JSON")
+ \\assert(seen.observe_only_err and seen.observe_only_err:find("no writable fields"), "observe-only event rejects")
+ );
+}
+
test "lifecycle handlers fire for every new event name" {
// Confirm dispatch is purely by event-name string: a handler on each new
// lifecycle event name actually runs. No new dispatch machinery exists;
diff --git a/src/lua_runtime.zig b/src/lua_runtime.zig
index 6b3a2f9..e2b7ec8 100644
--- a/src/lua_runtime.zig
+++ b/src/lua_runtime.zig
@@ -84,10 +84,6 @@ pub const LuaRuntime = struct {
/// `panto._record_result`. Allocated once at `create`; reused for
/// every call. `0` until `installScheduler` runs.
wrapper_ref: c_int = 0,
- /// Registry ref to `require("luv").run`, the function the scheduler
- /// calls to drive libuv to completion. `0` until
- /// `installScheduler` runs.
- uv_run_ref: c_int = 0,
/// Registry ref to `require("luv").update_time`. libuv caches the
/// loop's notion of "now" and only refreshes it while `uv.run`
/// executes; the loop is idle between batches (and during a slow
@@ -214,13 +210,45 @@ pub const LuaRuntime = struct {
c.lua_settop(L, c.lua_gettop(L) - 1); // pop the leftover `panto`
}
+ /// Expose the CLI's session agent to extensions as `panto.ext.agent`:
+ /// a *borrowed* native Agent userdata built by the module's
+ /// `_wrap_agent`, giving extensions the full agent method surface
+ /// (`add_system_message`, `compact`, …) on the live session. Must run
+ /// after `installPantoModule`. Borrowing is safe because the CLI
+ /// deinits this runtime (closing the Lua state) before the agent.
+ pub fn setExtAgent(self: *LuaRuntime, agent: *panto.Agent) !void {
+ const L = self.L;
+ lua_bridge.pushPantoTable(L);
+ if (c.lua_type(L, -1) != lua_bridge.T_TABLE) {
+ c.lua_settop(L, c.lua_gettop(L) - 1);
+ return RuntimeError.LuaInitFailed;
+ }
+ _ = c.lua_getfield(L, -1, "_wrap_agent");
+ if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) {
+ c.lua_settop(L, c.lua_gettop(L) - 2);
+ return RuntimeError.LuaInitFailed;
+ }
+ c.lua_pushlightuserdata(L, agent);
+ if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) {
+ logTopAsError(L, "panto-lua: _wrap_agent failed");
+ c.lua_settop(L, c.lua_gettop(L) - 2);
+ return RuntimeError.LuaInitFailed;
+ }
+ // Stack: ..., panto, agent_ud
+ lua_bridge.pushExtTable(L);
+ c.lua_pushvalue(L, -2); // copy agent_ud above ext
+ c.lua_setfield(L, -2, "agent"); // ext.agent = agent_ud
+ c.lua_settop(L, c.lua_gettop(L) - 3); // pop ext, agent_ud, panto
+ }
+
/// Install the libuv-driven coroutine scheduler:
/// - Register `panto._record_result` (C function with `self` as
/// light-userdata upvalue) so the wrapper closure can hand
/// results back to Zig.
/// - Create the wrapper closure that runs a user handler in
/// `pcall` and reports the result.
- /// - Cache `require("luv").run` for fast per-tick access.
+ /// - Cache `require("luv").update_time` for the per-batch clock
+ /// refresh.
///
/// Must be called after luarocks bootstrap has installed luv,
/// otherwise the `require("luv")` step will fail. Also after
@@ -229,7 +257,6 @@ pub const LuaRuntime = struct {
pub fn installScheduler(self: *LuaRuntime) !void {
try installRecordResult(self);
try installWrapperClosure(self);
- try cacheUvRun(self);
try cacheUvUpdateTime(self);
}
@@ -252,9 +279,6 @@ pub const LuaRuntime = struct {
if (self.wrapper_ref != 0) {
c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.wrapper_ref);
}
- if (self.uv_run_ref != 0) {
- c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.uv_run_ref);
- }
if (self.uv_update_time_ref != 0) {
c.luaL_unref(self.L, lua_bridge.LUA_REGISTRYINDEX, self.uv_update_time_ref);
}
@@ -317,11 +341,11 @@ pub const LuaRuntime = struct {
c.lua_settop(L, c.lua_gettop(L) - 1);
return error.LuaLoadFailed;
}
- if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) {
- logTopAsError(L, "lua: failed to evaluate extension");
- c.lua_settop(L, c.lua_gettop(L) - 1);
- return error.LuaRunFailed;
- }
+ // The chunk is user Lua: run it through the shared entrypoint
+ // machinery like everything else (eval must still be
+ // side-effect-free per the extension contract, but it gets the
+ // same execution environment).
+ try lua_bridge.runEntrypointResult(L, 0, "extension eval", null, null);
// Stack: ..., returned_value
defer c.lua_settop(L, c.lua_gettop(L) - 1); // pop returned value
try self.collectEntriesFromTop(script_path, out);
@@ -337,13 +361,11 @@ pub const LuaRuntime = struct {
out: *std.array_list.Managed(Entry),
) !void {
const L = self.L;
+ // `require` runs the rock's chunk — user Lua — so it goes through
+ // the shared entrypoint machinery like every other entrypoint.
_ = c.lua_getglobal(L, "require");
_ = c.lua_pushlstring(L, module_name.ptr, module_name.len);
- if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) {
- logTopAsError(L, "lua: failed to require rock");
- c.lua_settop(L, c.lua_gettop(L) - 1);
- return error.LuaRunFailed;
- }
+ try lua_bridge.runEntrypointResult(L, 1, "rock eval", null, null);
// Stack: ..., returned_value
defer c.lua_settop(L, c.lua_gettop(L) - 1);
try self.collectEntriesFromTop(module_name, out);
@@ -458,13 +480,14 @@ pub const LuaRuntime = struct {
return error.LuaRunFailed;
}
} else {
+ // Run activate() as a user-Lua entrypoint: inside a coroutine
+ // with the uv loop driven to completion, so activation code may
+ // await libuv work like any other user Lua.
_ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, entry.ref); // push activate fn
- if (c.lua_pcallk(L, 0, 0, 0, 0, null) != 0) {
- logTopAsError(L, "lua: extension activate() failed");
- c.lua_settop(L, c.lua_gettop(L) - 1);
+ lua_bridge.runEntrypoint(L, 0, "extension activate()", null, null) catch {
self.finishEntry(entry);
return error.LuaRunFailed;
- }
+ };
}
self.finishEntry(entry);
@@ -630,17 +653,18 @@ pub const LuaRuntime = struct {
}
}
- /// Run a Lua slash-command handler synchronously. `handler_ref` is a
- /// registry ref to a `function(args)` closure; `args` is the trimmed
- /// remainder of the command line. The handler runs under `pcall` with
- /// a `debug.traceback` error handler so failures surface a readable
- /// message instead of crashing the REPL.
+ /// Run a Lua slash-command handler. `handler_ref` is a registry ref
+ /// to a `function(args)` closure; `args` is the trimmed remainder of
+ /// the command line. Like every user-Lua entrypoint it runs inside a
+ /// coroutine with the uv loop driven to completion, so command
+ /// handlers may await libuv work; the call is synchronous from the
+ /// REPL's point of view.
///
/// Slash-command handlers act by side effect (printing, mutating
/// state) and their return value is ignored. On Lua error, returns
/// `RuntimeError.LuaHandlerCrashed` after copying the error message
- /// into `err_buf` (truncated to fit); `*err_len` is set to the number
- /// of bytes written.
+ /// (with traceback) into `err_buf` (truncated to fit); `*err_len` is
+ /// set to the number of bytes written.
pub fn runCommand(
self: *LuaRuntime,
handler_ref: c_int,
@@ -651,30 +675,10 @@ pub const LuaRuntime = struct {
const L = self.L;
err_len.* = 0;
- // Install a traceback error handler beneath the call frame.
- _ = c.lua_getglobal(L, "debug");
- _ = c.lua_getfield(L, -1, "traceback");
- c.lua_copy(L, -1, -2);
- c.lua_settop(L, c.lua_gettop(L) - 1); // pop the `debug` table
- const errfunc_idx = c.lua_gettop(L);
-
- // Push handler + args string.
_ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(handler_ref));
_ = c.lua_pushlstring(L, args.ptr, args.len);
-
- const rc = c.lua_pcallk(L, 1, 0, errfunc_idx, 0, null);
- if (rc != 0) {
- var len: usize = 0;
- const ptr = c.lua_tolstring(L, -1, &len);
- if (ptr != null) {
- const n = @min(len, err_buf.len);
- @memcpy(err_buf[0..n], ptr[0..n]);
- err_len.* = n;
- }
- c.lua_settop(L, c.lua_gettop(L) - 2); // pop error + errfunc
+ lua_bridge.runEntrypoint(L, 1, "slash command", err_buf, err_len) catch
return RuntimeError.LuaHandlerCrashed;
- }
- c.lua_settop(L, c.lua_gettop(L) - 1); // pop errfunc
}
/// Slash commands declared by loaded extensions, in registration
@@ -825,7 +829,7 @@ fn runBatch(
results: []panto.ToolCallResult,
allocator: Allocator,
) !void {
- if (self.wrapper_ref == 0 or self.uv_run_ref == 0) {
+ if (self.wrapper_ref == 0) {
// Scheduler not installed. We can still run synchronous
// handlers — use the legacy path that drives one coroutine at
// a time without an event loop.
@@ -1035,22 +1039,18 @@ fn startCoroutine(
};
}
-/// Call `uv.run("default")`, which runs the event loop until there are
-/// no active handles or requests left. Under the async-tool contract
-/// (a handler may only block by yielding on a pending libuv operation
-/// whose callback resumes it), this returns exactly when every handler
-/// coroutine has finished. The boolean return value is discarded.
+/// Drain the uv loop (`lua_bridge.drainLoop` — the same drain every
+/// single-fn entrypoint uses). Under the async-tool contract (a handler
+/// may only block by yielding on a pending libuv operation whose callback
+/// resumes it), this returns exactly when every handler coroutine has
+/// finished.
fn driveUvToCompletion(self: *LuaRuntime) !void {
const L = self.L;
- _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(self.uv_run_ref));
- _ = c.lua_pushlstring(L, "default", 7);
- if (c.lua_pcallk(L, 1, 1, 0, 0, null) != 0) {
+ lua_bridge.drainLoop(L) catch {
logTopAsError(L, "panto-lua: uv.run failed");
c.lua_settop(L, c.lua_gettop(L) - 1);
return error.UvRunFailed;
- }
- // Discard the boolean return value.
- c.lua_settop(L, c.lua_gettop(L) - 1);
+ };
}
/// Pre-scheduler fallback (used in unit tests and during early
@@ -1092,10 +1092,11 @@ fn runLegacySync(
return .{ .ok = out_parts };
}
-/// Run a handler synchronously, with no event loop. On Lua-level
-/// failure the error message string is duped into `*err_msg_out`
-/// (caller owns) before returning the typed error. `err_msg_out` is
-/// only written on the error path; on success it is left untouched.
+/// Run a handler through the shared entrypoint machinery (coroutine +
+/// loop drain), keeping its return value. On Lua-level failure the error
+/// message is duped into `*err_msg_out` (caller owns) before returning
+/// the typed error. `err_msg_out` is only written on the error path; on
+/// success it is left untouched.
fn invokeCoroutineSync(
L: *c.lua_State,
handler_ref: c_int,
@@ -1103,49 +1104,26 @@ fn invokeCoroutineSync(
allocator: Allocator,
err_msg_out: *?[]u8,
) !panto.ResultParts {
- const co = c.lua_newthread(L) orelse return RuntimeError.LuaInitFailed;
- defer c.lua_settop(L, c.lua_gettop(L) - 1);
-
- _ = c.lua_rawgeti(co, lua_bridge.LUA_REGISTRYINDEX, @intCast(handler_ref));
- if (c.lua_type(co, -1) != lua_bridge.T_FUNCTION) {
+ _ = c.lua_rawgeti(L, lua_bridge.LUA_REGISTRYINDEX, @intCast(handler_ref));
+ if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) {
+ c.lua_settop(L, c.lua_gettop(L) - 1);
return RuntimeError.LuaHandlerNotFound;
}
var arena_state = std.heap.ArenaAllocator.init(allocator);
defer arena_state.deinit();
- try lua_bridge.pushJsonAsLua(co, arena_state.allocator(), input);
-
- var nresults: c_int = 0;
- const status = c.lua_resume(co, L, 1, &nresults);
+ try lua_bridge.pushJsonAsLua(L, arena_state.allocator(), input);
- switch (status) {
- c.LUA_OK => {
- if (nresults < 1) return RuntimeError.BadHandlerReturn;
- return try lua_bridge.readHandlerResult(co, -1, allocator);
- },
- c.LUA_YIELD => {
- const msg = "lua: tool handler yielded with no event loop installed; call installScheduler() before dispatching";
- if (@import("builtin").is_test) {
- std.log.warn("{s}", .{msg});
- } else {
- std.log.err("{s}", .{msg});
- }
- return RuntimeError.LuaHandlerYielded;
- },
- else => {
- // Capture the Lua error message into the caller's slot
- // *before* logging+returning, so the legacy sync path can
- // surface it to the model rather than just the typed
- // error name.
- var msg_len: usize = 0;
- const msg_ptr = c.lua_tolstring(co, -1, &msg_len);
- if (msg_ptr != null) {
- err_msg_out.* = allocator.dupe(u8, msg_ptr[0..msg_len]) catch null;
- }
- logTopAsError(co, "lua: handler crashed");
- return RuntimeError.LuaHandlerCrashed;
- },
- }
+ var err_buf: [1024]u8 = undefined;
+ var err_len: usize = 0;
+ lua_bridge.runEntrypointResult(L, 1, "tool handler", &err_buf, &err_len) catch {
+ err_msg_out.* = allocator.dupe(u8, err_buf[0..err_len]) catch null;
+ return RuntimeError.LuaHandlerCrashed;
+ };
+ // Success: the handler's result is on top of L.
+ defer c.lua_settop(L, c.lua_gettop(L) - 1);
+ if (c.lua_type(L, -1) == lua_bridge.T_NIL) return RuntimeError.BadHandlerReturn;
+ return try lua_bridge.readHandlerResult(L, -1, allocator);
}
// ---------------------------------------------------------------------------
@@ -1249,30 +1227,6 @@ fn installWrapperClosure(self: *LuaRuntime) !void {
/// Cache `require("luv").run` in the registry so the scheduler can
/// invoke it cheaply without re-resolving `require` each batch.
-fn cacheUvRun(self: *LuaRuntime) !void {
- const L = self.L;
- const snippet =
- \\local uv = require("luv")
- \\return uv.run
- ;
- if (c.luaL_loadstring(L, snippet) != 0) {
- logTopAsError(L, "panto-lua: failed to compile luv lookup");
- c.lua_settop(L, c.lua_gettop(L) - 1);
- return RuntimeError.LuaInitFailed;
- }
- if (c.lua_pcallk(L, 0, 1, 0, 0, null) != 0) {
- logTopAsError(L, "panto-lua: require('luv') failed (was the bootstrap successful?)");
- c.lua_settop(L, c.lua_gettop(L) - 1);
- return RuntimeError.LuaInitFailed;
- }
- // Stack: [..., uv.run]. luaL_ref pops the top.
- if (c.lua_type(L, -1) != lua_bridge.T_FUNCTION) {
- c.lua_settop(L, c.lua_gettop(L) - 1);
- return RuntimeError.LuaInitFailed;
- }
- self.uv_run_ref = c.luaL_ref(L, lua_bridge.LUA_REGISTRYINDEX);
-}
-
/// Cache `require("luv").update_time` so the scheduler can refresh the
/// loop clock at the start of each batch. See `uv_update_time_ref`.
fn cacheUvUpdateTime(self: *LuaRuntime) !void {
@@ -1743,7 +1697,7 @@ test "directory-style extension can require sibling modules" {
try testing.expectEqualStrings("HI!", okText(results[0]));
}
-test "yielding handler with no event loop surfaces LuaHandlerYielded" {
+test "yielding handler with no event loop surfaces a suspension error" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
@@ -1768,15 +1722,15 @@ test "yielding handler with no event loop surfaces LuaHandlerYielded" {
defer freeResults(&results);
// Same policy as the crash test: the failure is surfaced as `.ok`
- // text so libpanto doesn't abort the turn. The error type name
- // (`LuaHandlerYielded`) is included via `@errorName` in the legacy
- // sync path's formatToolError call.
+ // text so libpanto doesn't abort the turn. The message is the shared
+ // entrypoint machinery's post-drain suspension diagnosis (without luv
+ // the drain is a no-op, so the yield can never be resumed).
try testing.expect(std.mem.startsWith(
u8,
okText(results[0]),
"panto-lua: tool 'sleeper' failed:",
));
- try testing.expect(std.mem.indexOf(u8, okText(results[0]), "LuaHandlerYielded") != null);
+ try testing.expect(std.mem.indexOf(u8, okText(results[0]), "suspended after the uv loop drained") != null);
}
// Integration test: requires a data home with luv already installed.
diff --git a/src/main.zig b/src/main.zig
index 86f1cb1..02c0045 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -383,6 +383,10 @@ pub fn main(init: std.process.Init) !void {
// any extension loads (which `require('panto')`).
try rt.installPantoModule();
+ // Hand extensions the live session agent (`panto.ext.agent`) so an
+ // `activate()` can act on the session directly (add_system_message…).
+ try rt.setExtAgent(agent);
+
// luv is installed (or already present) at this point; wire the
// libuv-driven coroutine scheduler before any extensions get a
// chance to register tools that might want to yield.
diff --git a/src/tui_app.zig b/src/tui_app.zig
index 88c2c66..d097225 100644
--- a/src/tui_app.zig
+++ b/src/tui_app.zig
@@ -245,6 +245,14 @@ const Entry = struct {
}
};
+/// A staged `tool_result` output override: the replacement text a handler
+/// assigned to `ev.output`, keyed by the call it belongs to. Both slices
+/// owned by the App's allocator.
+const PendingOutputOverride = struct {
+ id: []u8,
+ text: []u8,
+};
+
// ===========================================================================
// App
// ===========================================================================
@@ -279,6 +287,19 @@ pub const App = struct {
/// tool output starts collapsed to its last few lines.
tools_collapsed: bool = true,
+ /// Staged writable-event-field overrides (taken off the bus right after
+ /// the corresponding fire), waiting for the turn driver — which owns the
+ /// agent — to apply them to the conversation:
+ /// - input overrides: tool_use_id → replacement input JSON, staged at
+ /// `tool_call_complete` (mid-stream), applied at `tool_dispatch_start`
+ /// once the assistant message is committed but before dispatch;
+ /// - output overrides: staged at `tool_result`, applied right after
+ /// `tool_dispatch_complete` routing, before the next provider call.
+ /// Keys and values owned by `alloc`. Cleared as applied (and defensively
+ /// at turn start).
+ pending_input_overrides: std.StringHashMapUnmanaged([]u8) = .empty,
+ pending_output_overrides: std.ArrayListUnmanaged(PendingOutputOverride) = .empty,
+
/// Optional override-release hook. When a slot's `override` is REPLACED by
/// a newer override (a second handler swap on the same slot), the old
/// override is no longer referenced by panto and its OWNER must release it.
@@ -340,6 +361,24 @@ pub const App = struct {
self.transcript.deinit(self.alloc);
self.router.deinit();
self.bus.deinit();
+ self.clearPendingOverrides();
+ self.pending_input_overrides.deinit(self.alloc);
+ self.pending_output_overrides.deinit(self.alloc);
+ }
+
+ /// Drop all staged (unapplied) writable-field overrides.
+ fn clearPendingOverrides(self: *App) void {
+ var it = self.pending_input_overrides.iterator();
+ while (it.next()) |entry| {
+ self.alloc.free(entry.key_ptr.*);
+ self.alloc.free(entry.value_ptr.*);
+ }
+ self.pending_input_overrides.clearRetainingCapacity();
+ for (self.pending_output_overrides.items) |po| {
+ self.alloc.free(po.id);
+ self.alloc.free(po.text);
+ }
+ self.pending_output_overrides.clearRetainingCapacity();
}
/// Access the event bus so the embedder (and, later, the Lua bridge) can
@@ -641,6 +680,42 @@ pub const App = struct {
}
}
_ = try self.fireForEntry(entry, lc, name, enriched);
+
+ // Writable-field overrides: take what a handler wrote during this
+ // fire and stage it by call id for the turn driver (which owns the
+ // agent) to apply at the effective boundary. An override without a
+ // resolved call id has nothing to attach to and is dropped.
+ const is_result = std.mem.eql(u8, name, "tool_result");
+ const is_call_complete = std.mem.eql(u8, name, "tool_call_complete");
+ if (!is_result and !is_call_complete) return;
+ const value = self.bus.takeOverride() orelse return;
+ const id = enriched.tool.id;
+ if (id.len == 0) {
+ self.alloc.free(value);
+ return;
+ }
+ if (is_result) {
+ const id_copy = self.alloc.dupe(u8, id) catch |e| {
+ self.alloc.free(value);
+ return e;
+ };
+ try self.pending_output_overrides.append(self.alloc, .{ .id = id_copy, .text = value });
+ } else {
+ const gop = self.pending_input_overrides.getOrPut(self.alloc, id) catch |e| {
+ self.alloc.free(value);
+ return e;
+ };
+ if (gop.found_existing) {
+ self.alloc.free(gop.value_ptr.*);
+ } else {
+ gop.key_ptr.* = self.alloc.dupe(u8, id) catch |e| {
+ _ = self.pending_input_overrides.remove(id);
+ self.alloc.free(value);
+ return e;
+ };
+ }
+ gop.value_ptr.* = value;
+ }
}
/// Fire a thinking/assistant lifecycle event for the entry backing a
@@ -1029,6 +1104,7 @@ pub const App = struct {
try self.fireToolLifecycle(box, .tool_result, "tool_result", .{ .tool = .{
.tool_name = if (box.name) |n| n.items else "",
.id = tr.tool_use_id,
+ .input = box.input.items,
.output = text.items,
} });
any = true;
@@ -1043,6 +1119,9 @@ pub const App = struct {
/// the chat history); only the block-index map is cleared.
pub fn beginTurn(self: *App) void {
self.router.reset();
+ // Defensive: an interrupted turn may have staged overrides it never
+ // applied; they must not leak into this turn's calls.
+ self.clearPendingOverrides();
}
/// Surface a turn error as a dim status line in the transcript.
@@ -1934,7 +2013,13 @@ fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOp
}
// Model turn. Echo the user message, then pump the stream into components.
- try app.spawnUser(line);
+ try app.spawnUser(line); // fires `user_message`
+ // A `user_message` handler may have overridden `ev.text`: the echo above
+ // keeps what the user typed; the override is what the turn sends. (The
+ // fire itself clears any stale override a replayed user_message left.)
+ const text_override = app.bus.takeOverride();
+ defer if (text_override) |t| app.alloc.free(t);
+ const turn_text = text_override orelse line;
app.beginTurn();
try app.renderNow();
@@ -1947,7 +2032,7 @@ fn handleSubmittedLine(app: *App, term: *Terminal, line: []const u8, opts: RunOp
return;
};
- driveTurn(app, term, opts, line) catch |err| {
+ driveTurn(app, term, opts, turn_text) catch |err| {
try app.routeError(err);
};
try app.renderNow();
@@ -2069,6 +2154,7 @@ fn driveTurn(app: *App, term: *Terminal, opts: RunOptions, message_text: []const
};
const e = ev orelse break;
try app.routeEvent(e);
+ try applyPendingOverrides(app, opts.agent, e);
_ = try app.maybeRender();
if (try pumpTurnKeys(app, term, &turn_input_tail)) {
interrupted = true;
@@ -2084,6 +2170,36 @@ fn driveTurn(app: *App, term: *Terminal, opts: RunOptions, message_text: []const
}
}
+/// Apply staged writable-event-field overrides at their effective stream
+/// boundaries. Runs right after `routeEvent` (which is where the fires that
+/// stage them happen), still before the next `stream.next()` — i.e. before
+/// the agent advances — so the mutation is authoritative for dispatch, the
+/// next provider request, and turn persistence:
+/// - `tool_dispatch_start`: the assistant message is committed but tools
+/// have not run — rewrite ToolUse inputs staged at `tool_call_complete`.
+/// - `tool_dispatch_complete`: results are in the conversation tail —
+/// rewrite ToolResult text staged at `tool_result`.
+/// An override whose call id no longer matches (e.g. a stale stage after a
+/// reopen) is dropped silently — the conversation stays untouched.
+fn applyPendingOverrides(app: *App, agent: *panto.Agent, ev: Event) !void {
+ switch (ev) {
+ .tool_dispatch_start => {
+ defer app.clearPendingOverrides();
+ var it = app.pending_input_overrides.iterator();
+ while (it.next()) |entry| {
+ _ = try agent.overrideToolUseInput(entry.key_ptr.*, entry.value_ptr.*);
+ }
+ },
+ .tool_dispatch_complete => {
+ defer app.clearPendingOverrides();
+ for (app.pending_output_overrides.items) |po| {
+ _ = try agent.overrideToolResultOutput(po.id, po.text);
+ }
+ },
+ else => {},
+ }
+}
+
/// Service app-level keybindings while a turn is in flight. Returns true when
/// the user requested an interrupt (Escape). Text-editing keys are ignored: the
/// input box is restored only after the interrupted turn is back in user-input
@@ -2967,6 +3083,60 @@ test "event wiring: tool lifecycle events each fire EXACTLY ONCE at their bounda
try testing.expectEqual(@as(usize, 1), counter.result);
}
+test "writable-field overrides are staged by call id at tool_call_complete and tool_result" {
+ const alloc = testing.allocator;
+ const h = try Harness.make(alloc);
+ defer h.teardown(alloc);
+
+ // A native handler standing in for a Lua `ev.input = ...` / `ev.output
+ // = ...` assignment (the bridge routes those to the same bus slot).
+ const Writer = struct {
+ bus: *EventBus,
+ fn cb(ctx: *anyopaque, ev: *ui_event.Event) void {
+ const w: *@This() = @ptrCast(@alignCast(ctx));
+ if (std.mem.eql(u8, ev.name, "tool_call_complete")) {
+ w.bus.setOverride("{\"a\":2}") catch {};
+ } else if (std.mem.eql(u8, ev.name, "tool_result")) {
+ w.bus.setOverride("redacted") catch {};
+ }
+ }
+ };
+ var w = Writer{ .bus = &h.app.bus };
+ try h.app.bus.on("tool_call_complete", .{ .ctx = &w, .callback = Writer.cb });
+ try h.app.bus.on("tool_result", .{ .ctx = &w, .callback = Writer.cb });
+
+ try h.app.routeEvent(.{ .block_start = .{ .block_type = .ToolUse, .index = 0 } });
+ try h.app.routeEvent(.{ .tool_details = .{ .index = 0, .id = "a", .name = "read" } });
+ var tu = panto.ToolUseBlock{
+ .id = try alloc.dupe(u8, "a"),
+ .name = try alloc.dupe(u8, "read"),
+ };
+ defer tu.deinit(alloc);
+ try tu.input.appendSlice(alloc, "{\"a\":1}");
+ try h.app.routeEvent(.{ .block_complete = .{ .index = 0, .block = .{ .ToolUse = tu } } });
+
+ // The input override is staged, keyed by the resolved call id.
+ try testing.expectEqual(@as(usize, 1), h.app.pending_input_overrides.count());
+ try testing.expectEqualStrings("{\"a\":2}", h.app.pending_input_overrides.get("a").?);
+
+ var msg: panto.Message = .{ .role = .user };
+ defer msg.deinit(alloc);
+ var parts: std.ArrayList(panto.ResultPartStored) = .empty;
+ var text: panto.TextualBlock = .empty;
+ try text.appendSlice(alloc, "out");
+ try parts.append(alloc, .{ .text = text });
+ try msg.content.append(alloc, .{ .ToolResult = .{ .tool_use_id = try alloc.dupe(u8, "a"), .parts = parts } });
+ try h.app.routeEvent(.{ .tool_dispatch_complete = .{ .message = msg } });
+
+ // The output override is staged with the id it belongs to.
+ try testing.expectEqual(@as(usize, 1), h.app.pending_output_overrides.items.len);
+ try testing.expectEqualStrings("a", h.app.pending_output_overrides.items[0].id);
+ try testing.expectEqualStrings("redacted", h.app.pending_output_overrides.items[0].text);
+
+ // Nothing left on the bus once staged.
+ try testing.expect(h.app.bus.takeOverride() == null);
+}
+
test "event wiring: thinking lifecycle fires start + per-delta + complete" {
const alloc = testing.allocator;
const h = try Harness.make(alloc);
diff --git a/src/tui_event.zig b/src/tui_event.zig
index 3f59ba2..ec860cb 100644
--- a/src/tui_event.zig
+++ b/src/tui_event.zig
@@ -238,6 +238,21 @@ pub const Event = struct {
}
};
+/// The writable payload field for an event type, or null when the event is
+/// observe-only. This is the single authority for the "override via mutable
+/// attribute" paradigm: a Lua handler assigning `ev.<field> = value` on one
+/// of these events records an override on the bus; the firer takes it after
+/// dispatch and applies it to the real underlying state (conversation
+/// block, turn text). Values are strings on the Lua side except
+/// `tool_call_complete.input`, which is assigned as a JSON-able table and
+/// serialized at write time.
+pub fn writableField(event_name: []const u8) ?[]const u8 {
+ if (std.mem.eql(u8, event_name, "tool_result")) return "output";
+ if (std.mem.eql(u8, event_name, "user_message")) return "text";
+ if (std.mem.eql(u8, event_name, "tool_call_complete")) return "input";
+ return null;
+}
+
// ===========================================================================
// EventBus
// ===========================================================================
@@ -259,6 +274,14 @@ pub const EventBus = struct {
handlers: std.StringHashMapUnmanaged(std.ArrayListUnmanaged(Handler)) = .empty,
/// Owned copies of the event-name keys (the map borrows these).
keys: std.ArrayListUnmanaged([]u8) = .empty,
+ /// Dispatch-scoped payload-field override written by a handler during
+ /// the current fire (the Lua bridge's writable event fields, e.g.
+ /// `ev.output = ...` on `tool_result`). One slot suffices: each
+ /// writable event type has exactly one writable field (see
+ /// `writableField`), `emit` clears the slot at entry for writable
+ /// events, and the firer takes it immediately after the fire returns.
+ /// Owned by `alloc`.
+ override_value: ?[]u8 = null,
pub fn init(alloc: std.mem.Allocator) EventBus {
return .{ .alloc = alloc };
@@ -270,6 +293,27 @@ pub const EventBus = struct {
self.handlers.deinit(self.alloc);
for (self.keys.items) |k| self.alloc.free(k);
self.keys.deinit(self.alloc);
+ if (self.override_value) |v| self.alloc.free(v);
+ }
+
+ /// Record a handler's override of the current event's writable field
+ /// (last writer wins — matches `setComponent` semantics). Readers of
+ /// that field during the same dispatch see this value, so handlers
+ /// chain in registration order.
+ pub fn setOverride(self: *EventBus, value: []const u8) !void {
+ const copy = try self.alloc.dupe(u8, value);
+ if (self.override_value) |old| self.alloc.free(old);
+ self.override_value = copy;
+ }
+
+ /// Take ownership of the pending override (null if no handler wrote
+ /// one). The caller frees the returned slice with `bus.alloc` and is
+ /// responsible for applying it to the real underlying state — an
+ /// untaken override is simply dropped at the next writable fire.
+ pub fn takeOverride(self: *EventBus) ?[]u8 {
+ const v = self.override_value;
+ self.override_value = null;
+ return v;
}
/// Register `handler` for `name`. Handlers fire in registration order on
@@ -303,6 +347,12 @@ pub const EventBus = struct {
/// concurrent `tool` boundaries each pass their own `event` (with their own
/// default) and get back their own chosen component.
pub fn emit(self: *EventBus, event: *Event) ?Component {
+ // Writable events start with a clean override slot, so a stale
+ // value from a fire nobody took (e.g. a replayed `user_message`)
+ // can never leak into this dispatch.
+ if (writableField(event.name) != null) {
+ if (self.takeOverride()) |stale| self.alloc.free(stale);
+ }
if (self.handlers.getPtr(event.name)) |list| {
for (list.items) |h| h.call(event);
}