summaryrefslogtreecommitdiff
path: root/src/main.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/main.zig')
-rw-r--r--src/main.zig870
1 files changed, 646 insertions, 224 deletions
diff --git a/src/main.zig b/src/main.zig
index 02c0045..09b1fad 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -17,6 +17,7 @@ const glob = @import("glob.zig");
const system_prompt = @import("system_prompt.zig");
const command = @import("command.zig");
const command_compaction = @import("compaction.zig");
+const builtin_commands = @import("builtin_commands.zig");
const debug_log = @import("debug_log.zig");
/// Route the process-wide log stream. `PANTO_DEBUG!=0` redirects it to a
@@ -40,6 +41,10 @@ const tui_selectors = @import("tui_selectors.zig");
// `@cImport`; we re-use it here so the smoke check uses identical types.
const lua = lua_bridge.c;
+// `fflush(NULL)` flushes every open C-stdio stream; used to drain buffered
+// luarocks output before restoring fd 1 after the print-mode bootstrap.
+extern "c" fn fflush(stream: ?*anyopaque) c_int;
+
test {
// Test contract: deliberate error-path tests should not produce visible
// log output. Code logs at `.err` for genuine production failures and
@@ -65,6 +70,7 @@ test {
_ = system_prompt;
_ = command;
_ = command_compaction;
+ _ = builtin_commands;
_ = debug_log;
_ = pricing_format;
_ = markdown;
@@ -151,6 +157,14 @@ fn reportModelError(err: anyerror, cfg: *const config_file.Config) void {
"error: the selected model names an unconfigured provider.\n",
.{},
),
+ error.UnknownModelAlias => std.debug.print(
+ "error: --model matches no alias in models.toml. Use \"provider:alias\" or a known alias.\n",
+ .{},
+ ),
+ error.AmbiguousModelAlias => std.debug.print(
+ "error: --model alias exists under multiple providers; use \"provider:alias\".\n",
+ .{},
+ ),
else => std.debug.print("error: model selection failed: {s}\n", .{@errorName(err)}),
}
}
@@ -183,7 +197,9 @@ pub fn main(init: std.process.Init) !void {
.agent => {},
}
- // Parse the agent-mode flags. Currently only `--resume [<id>]`.
+ // Parse the agent-mode flags (`--resume [<id>]`, `-c/--continue`,
+ // `-m/--model`, `--effort`). Unknown flags are fatal — a typo must not
+ // launch the TUI.
const cli_flags = try parseAgentFlags(alloc, init.minimal.args);
defer cli_flags.deinit(alloc);
@@ -195,6 +211,27 @@ pub fn main(init: std.process.Init) !void {
// need the stdin file handle, not a buffered reader.
const stdin_handle = std.Io.File.stdin().handle;
+ // Resolve the print-mode prompt up front so a missing prompt fails fast,
+ // before any bootstrap work: flag argument first, else piped stdin.
+ var print_prompt_owned: ?[]u8 = null;
+ defer if (print_prompt_owned) |p| alloc.free(p);
+ const print_prompt: ?[]const u8 = if (!cli_flags.print_mode) null else blk: {
+ if (cli_flags.print_prompt) |p| break :blk p;
+ if (std.c.isatty(stdin_handle) != 0) {
+ std.debug.print("error: -p/--print needs a prompt argument or piped stdin\n", .{});
+ std.process.exit(1);
+ }
+ var stdin_buf: [4096]u8 = undefined;
+ var stdin_reader = std.Io.File.stdin().readerStreaming(io, &stdin_buf);
+ const raw = try stdin_reader.interface.allocRemaining(alloc, .unlimited);
+ print_prompt_owned = raw;
+ if (std.mem.trim(u8, raw, " \t\r\n").len == 0) {
+ std.debug.print("error: -p/--print got an empty prompt on stdin\n", .{});
+ std.process.exit(1);
+ }
+ break :blk raw;
+ };
+
// Resolve where this project's sessions live.
var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
const cwd_n = try std.process.currentPath(io, &cwd_buf);
@@ -224,15 +261,40 @@ pub fn main(init: std.process.Init) !void {
// surface per-turn cost yet (that lands with the TUI frontend).
// Choose the active model and assemble the provider config.
- const model_ref = app_config.selectModel(&models.defs, null) catch |err| {
+ const model_ref = app_config.selectModel(&models.defs, cli_flags.model) catch |err| {
reportModelError(err, &app_config);
std.process.exit(1);
};
- const provider_config = config_file.buildProviderConfig(&app_config, &models.defs, model_ref) catch |err| {
+ var provider_config = config_file.buildProviderConfig(&app_config, &models.defs, model_ref) catch |err| {
reportModelError(err, &app_config);
std.process.exit(1);
};
+ // Reasoning-level precedence: `--effort` beats everything; otherwise a
+ // `[defaults] reasoning` session default applies unless models.toml set
+ // an explicit per-alias knob (which `buildProviderConfig` already baked
+ // in). Levels are the Ctrl+R picker's labels for the provider's style.
+ const effort_label: ?[]const u8 = cli_flags.effort orelse blk: {
+ if (models.defs.get(model_ref.provider, model_ref.model)) |d| {
+ if (d.reasoning_explicit) break :blk null;
+ }
+ break :blk app_config.default_reasoning;
+ };
+ if (effort_label) |lvl| {
+ if (tui_selectors.ReasoningOption.byLabel(provider_config.style(), lvl)) |opt| {
+ opt.apply(&provider_config);
+ } else if (cli_flags.effort != null) {
+ std.debug.print("error: --effort '{s}' is not a reasoning level for this provider (available:", .{lvl});
+ for (tui_selectors.ReasoningOption.forStyle(provider_config.style())) |opt| {
+ std.debug.print(" {s}", .{opt.label});
+ }
+ std.debug.print(")\n", .{});
+ std.process.exit(1);
+ } else {
+ std.log.warn("[defaults] reasoning '{s}' has no such level on this provider; ignored", .{lvl});
+ }
+ }
+
// Resolve the optional compaction-model override into a ProviderConfig.
// On any resolution failure we log and fall back to no override (the
// active chat model is used for compaction).
@@ -277,8 +339,6 @@ pub fn main(init: std.process.Init) !void {
session_store,
cli_flags,
session_dir,
- stdout,
- &stdout_file,
) catch |err| switch (err) {
error.SessionNotFound, error.AmbiguousSessionId => std.process.exit(1),
else => return err,
@@ -300,168 +360,120 @@ pub fn main(init: std.process.Init) !void {
var adopted_conversation: ?panto.Conversation = null;
if (is_resume) {
adopted_conversation = try session.load();
- const sid = session.info.id;
- try stdout.print(
- "resumed session {s} ({d} messages)\n",
- .{ sid[0..@min(8, sid.len)], session.info.message_count },
- );
+ if (!cli_flags.print_mode) {
+ const sid = session.info.id;
+ try stdout.print(
+ "resumed session {s} ({d} messages)\n",
+ .{ sid[0..@min(8, sid.len)], session.info.message_count },
+ );
+ }
}
- // Assemble the active configuration snapshot and build the agent now,
- // before luarocks bootstrap and extension loading. The agent adopts the
- // session handle (for free persistence) and the resumed conversation
- // (or starts fresh). The agent owns its own tool registry (post-R1);
- // extensions register their tool source onto it *after* the agent is
- // built but before the first turn, so in-place registration is visible.
- // System-prompt seeding/reconciliation below runs *through* the agent
- // so those entries persist.
+ // The active configuration snapshot the agent (re-)reads each turn.
var active_config: panto.Config = .{
.provider = provider_config,
.compaction = compaction_cfg,
};
- const agent = try panto.Agent.init(
- alloc,
- io,
- &active_config,
- session,
- adopted_conversation,
- );
- defer agent.deinit();
- // Spin up the long-lived Lua runtime. All Lua extensions load into
- // one `lua_State`; module-global state survives across calls. The
- // runtime registers with the agent as a single `ToolSource` named
- // `panto-lua`.
- var rt = try lua_runtime.LuaRuntime.create(alloc);
- defer rt.deinit();
+ // Resolve the panto data home layout up front: the agent-tree dir feeds
+ // system-prompt sourcing (below) and the auth dir feeds the AuthManager.
+ var home_layout = try panto_home.resolve(alloc, init.environ_map);
+ defer home_layout.deinit();
- // 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 the panto data home.
- const luarocks_rt = try luarocks_runtime.bootstrap(
- alloc,
- io,
- init.environ_map,
- rt.L,
- panto_path,
- );
- defer luarocks_rt.deinit();
+ // Process-level luarocks runtime: stage the rocks/agent trees once and
+ // keep a dedicated driver `lua_State` for luarocks work (battery
+ // reconcile, `extensions.rocks` installs). Session interpreters are
+ // disposable (/new, /resume rebuild them), so the driver state must not
+ // ride on one of them. `--no-extensions` skips all of it: built-ins
+ // only, and since even the shipped tools are extensions the agent runs
+ // with no tools — the escape hatch when an extension breaks startup.
+ var luarocks_L: ?*lua.lua_State = null;
+ defer if (luarocks_L) |L| lua.lua_close(L);
+ var luarocks_rt: ?*luarocks_runtime.LuarocksRuntime = null;
+ defer if (luarocks_rt) |lr| lr.deinit();
+ // In print mode, shunt fd 1 to stderr for the whole extension bootstrap:
+ // luarocks (first-run bootstrap, rock installs) prints compile/progress
+ // output to stdout via C stdio, which would pollute the scripted `-p`
+ // output. Restored below, before the print turn writes assistant text.
+ const saved_stdout: ?std.c.fd_t = if (cli_flags.print_mode and !cli_flags.no_extensions) blk: {
+ try stdout_file.flush();
+ const saved = std.c.dup(std.posix.STDOUT_FILENO);
+ if (saved == -1) break :blk null;
+ if (std.c.dup2(std.posix.STDERR_FILENO, std.posix.STDOUT_FILENO) == -1) {
+ _ = std.c.close(saved);
+ break :blk null;
+ }
+ break :blk saved;
+ } else null;
+ if (!cli_flags.no_extensions) {
+ const L = lua.luaL_newstate() orelse return error.OutOfMemory;
+ luarocks_L = L;
+ lua.luaL_openlibs(L);
- // Source the system prompt now that the base agent tree has been
- // 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`.
- var sp_arena = std.heap.ArenaAllocator.init(alloc);
- defer sp_arena.deinit();
- if (is_resume) {
- // SYSTEM.md / APPEND_SYSTEM.md may have changed since this log was
- // created; reconcile without rewriting history.
- try system_prompt.reconcileResume(
- sp_arena.allocator(),
- io,
- init.environ_map,
- luarocks_rt.layout.agent_dir,
- agent,
- );
- } else {
- // Fresh session — source and install the system prompt.
- try system_prompt.seedFresh(
- sp_arena.allocator(),
+ // Bootstrap luarocks — same pipeline as `panto lua` and `panto
+ // bootstrap`. After this, `require("luarocks.*")` works on the
+ // driver state and any pinned batteries from the manifest are
+ // installed under the panto data home.
+ luarocks_rt = try luarocks_runtime.bootstrap(
+ alloc,
io,
init.environ_map,
- luarocks_rt.layout.agent_dir,
- agent,
+ L,
+ panto_path,
);
- }
-
- // Bootstrap staged `panto.so` onto the embedded VM's cpath and
- // configured `package.path`/`cpath`; now wire `require('panto')` to
- // the native module + the CLI's `ext` subtable. Must run before
- // `installScheduler` (which writes onto the module table) and before
- // 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.
- try rt.installScheduler();
-
- // Install any user rocks (`extensions.rocks`) into the shared tree so the
- // loader can `require` them below. Best-effort: a rock that fails to
- // install is logged and skipped — the REPL still starts. This is where
- // registry code enters, so it is the code-execution trust boundary; the
- // allow/deny policy is a feature toggle, not a security gate.
- for (app_config.ext_rocks) |spec| {
- _ = luarocks_runtime.installRockIfMissing(luarocks_rt, alloc, io, spec.value) catch |err| {
- std.log.err("extensions.rocks: failed to install '{s}': {t}", .{ spec.value, err });
- };
- }
-
- // Discover Lua extensions across three layers — base
- // (<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.
- const n_ext_tools = extension_loader.discoverAndLoad(
- alloc,
- io,
- init.environ_map,
- luarocks_rt.layout.agent_dir,
- rt,
- &app_config.extensions,
- app_config.ext_paths,
- app_config.ext_rocks,
- ) catch |err| {
- std.log.err("extension discovery failed: {t}", .{err});
- return err;
- };
- std.log.debug("extensions: {d} tool(s) registered", .{n_ext_tools});
-
- if (n_ext_tools > 0) {
- try agent.registerToolSource(rt.toolSource());
+ // Install any user rocks (`extensions.rocks`) into the shared tree
+ // so extension loading can `require` them. Best-effort: a rock that
+ // fails to install is logged and skipped — the REPL still starts.
+ // This is where registry code enters, so it is the code-execution
+ // trust boundary; the allow/deny policy is a feature toggle, not a
+ // security gate.
+ for (app_config.ext_rocks) |spec| {
+ _ = luarocks_runtime.installRockIfMissing(luarocks_rt.?, alloc, io, spec.value) catch |err| {
+ std.log.err("extensions.rocks: failed to install '{s}': {t}", .{ spec.value, err });
+ };
+ }
}
- // Resolve the compaction system prompt (COMPACTION.md across layers,
- // last wins; built-in default otherwise) and arm automatic compaction.
- // Owned by `sp_arena`, which lives for the whole REPL.
- const compaction_prompt = try system_prompt.resolveCompaction(
- sp_arena.allocator(),
- io,
- init.environ_map,
- luarocks_rt.layout.agent_dir,
- );
- // The compaction prompt is resolved after extension load; set it on the
- // config the agent re-reads each turn (visible before the first turn).
- active_config.compaction.compaction_prompt = compaction_prompt;
+ // Base layer for system-prompt sourcing. With extensions on, the
+ // bootstrap above staged the bundled agent tree (incl. SYSTEM.md) there;
+ // with `--no-extensions` the path may not exist — missing prompt files
+ // are simply skipped, and user/project layers still apply.
+ const agent_dir = if (luarocks_rt) |lr| lr.layout.agent_dir else home_layout.agent_dir;
- // Build the slash-command registry and register builtins, then append
- // any commands declared by Lua extensions (harvested at load time).
+ // Slash-command registry: process-lifetime. Builtins register once
+ // here; Lua-extension commands are harvested into it by each world
+ // build (and removed again at world teardown).
var cmd_registry = command.Registry.init(alloc);
defer cmd_registry.deinit();
try command_compaction.register(&cmd_registry);
+ try builtin_commands.register(&cmd_registry);
- // Append slash commands declared by Lua extensions via
- // `panto.ext.register_command`. A name collision with a builtin (or
- // between two extensions) surfaces as `error.DuplicateCommand` and
- // aborts startup, matching the tool-name collision policy.
- for (rt.commandList()) |lua_cmd| {
- cmd_registry.registerLua(
- lua_cmd.name,
- lua_cmd.description,
- lua_cmd.handler_ref,
- ) catch |err| {
- std.log.err(
- "lua: failed to register command '/{s}': {t}",
- .{ lua_cmd.name, err },
- );
- return err;
- };
+ // Build the per-session world — Agent + Lua interpreter + derived
+ // system/compaction prompts — through the exact code path `/new` and
+ // `/resume` rebuild it (see `SessionWorld`). Declared before the TUI so
+ // the App tears down first (the teardown ordering contract below).
+ var world: SessionWorld = .{
+ .alloc = alloc,
+ .io = io,
+ .environ = init.environ_map,
+ .app_config = &app_config,
+ .active_config = &active_config,
+ .agent_dir = agent_dir,
+ .luarocks_rt = luarocks_rt,
+ .registry = &cmd_registry,
+ };
+ defer world.deinit();
+ try world.build(session, adopted_conversation);
+ const agent = world.agent.?;
+
+ // Point fd 1 back at the real stdout (see the dup above). Drain the C
+ // stdio buffer first so buffered luarocks output flushes to stderr, not
+ // into the restored stdout at exit.
+ if (saved_stdout) |fd| {
+ _ = fflush(null);
+ _ = std.c.dup2(fd, std.posix.STDOUT_FILENO);
+ _ = std.c.close(fd);
}
// Command output is captured into an in-memory buffer (the command
@@ -477,12 +489,47 @@ pub fn main(init: std.process.Init) !void {
.agent = agent,
.stdout = &cmd_capture.writer,
.stdout_file = &stdout_file,
- .compaction_prompt = compaction_prompt,
+ .compaction_prompt = world.compaction_prompt,
.provider_name = banner_provider_initial,
.model_name = banner_model_initial,
- .lua_rt = rt,
+ .lua_rt = world.rt,
+ .session_store = session_store,
+ // `.tui` is installed after TUI bring-up below; null in print mode.
};
+ // Turn-time auth resolution. The manager resolves the active provider's
+ // named auth session (api_key: no-op; oauth_device: refresh/exchange or an
+ // interactive device login) into the live config before each turn.
+ var auth_mgr = auth_manager.AuthManager.init(
+ alloc,
+ io,
+ panto.httpClient(),
+ home_layout.auth_dir,
+ &app_config,
+ );
+ defer auth_mgr.deinit();
+
+ // -- Print mode (-p/--print) -------------------------------------------
+ //
+ // One agent turn (tools and all) without the TUI: assistant text to
+ // stdout, errors to stderr, exit 0/1. Session persistence is identical to
+ // the TUI path — the agent owns it.
+ if (cli_flags.print_mode) {
+ runPrintTurn(
+ alloc,
+ agent,
+ &active_config,
+ model_ref.provider,
+ &auth_mgr,
+ &stdout_file,
+ print_prompt.?,
+ ) catch |err| {
+ std.debug.print("error: turn failed: {t}\n", .{err});
+ std.process.exit(1);
+ };
+ return;
+ }
+
// -- TUI bring-up ------------------------------------------------------
//
// Full replacement of the print REPL (version control is the safety net).
@@ -531,37 +578,42 @@ pub fn main(init: std.process.Init) !void {
);
defer app.deinit();
- // Wire the Lua extension UI event bridge to the App's event bus: this
- // registers every `panto.ext.on(...)` handler (harvested at extension
- // load time) into the bus, in registration order, so extensions can
- // wrap/replace built-in components and a Lua `panto.ext.emit(...)` can
- // drive the same bus. With no Lua handlers this is a no-op.
- try rt.eventBridge().attachBus(app.eventBus());
+ // `[tui] tools_collapsed`: the Ctrl+O collapse state's starting value.
+ if (app_config.tui_tools_collapsed) |c| app.tools_collapsed = c;
- // Install the override-release hook so a Lua-backed override that is
- // SUPERSEDED by a later mid-stream swap (e.g. a `tool_details` handler
- // replacing the `tool (?)` default's prior override) has its luaL_ref +
- // RenderCache freed. Without this, each swapped Lua component would leak
- // for the life of the runtime. The hook recognizes a bridged component by
- // its vtable identity and ignores native components (see
- // `EventBridge.releaseOverride`).
+ // Footer: the model's context window (for the fractional context
+ // readout; null = raw count). The session short-id is stamped by
+ // `rebuild_host.wire()` below.
+ if (models.defs.get(model_ref.provider, model_ref.model)) |d| {
+ footer.setContextWindow(d.context_window);
+ }
+
+ // TUI-steering slash commands (/model, /reasoning, /new, /quit) reach
+ // the live App through the command Context (opaque, same idiom as
+ // `lua_rt`); the pointer is stable for the whole run loop.
+ cmd_ctx.tui = &app;
+
+ // The Lua extension UI event bridge (bus handlers + override-release
+ // hook) is wired to the App by `rebuild_host.wire()` below — the same
+ // call the mid-run /new//resume rebuild uses.
//
// TEARDOWN ORDERING CONTRACT (load-bearing — do not reorder these decls):
- // `app` is declared AFTER `rt`, so `defer app.deinit()` runs BEFORE
- // `defer rt.deinit()` (defers are LIFO). The bridge (owned by `rt`)
- // therefore outlives the App's teardown. This matters because the
- // release hook below points into the bridge: if the bridge were freed
- // first, any later hook invocation would be a use-after-free.
- // It is safe today because `App.deinit` frees only its own default
- // `kind` boxes and NEVER invokes `override_release_fn` — the surviving
- // (non-superseded) Lua overrides are freed by `EventBridge.deinit` when
- // `rt.deinit()` runs. The hook fires only during live mid-stream swaps,
- // while both App and bridge are alive. If you ever make `App.deinit`
- // call the release hook, or move `rt` to outlive `app`, revisit this.
- app.setOverrideRelease(
- @ptrCast(rt.eventBridge()),
- lua_event_bridge.EventBridge.releaseOverrideThunk,
- );
+ // `app` is declared AFTER `world`, so `defer app.deinit()` runs BEFORE
+ // `defer world.deinit()` (defers are LIFO). The bridge (owned by the
+ // world's Lua runtime) therefore outlives the App's teardown. This
+ // matters because the release hook points into the bridge: if the
+ // bridge were freed first, any later hook invocation would be a
+ // use-after-free. It is safe today because `App.deinit` frees only its
+ // own default `kind` boxes and NEVER invokes `override_release_fn` —
+ // the surviving (non-superseded) Lua overrides are freed by
+ // `EventBridge.deinit` when the world tears its runtime down. The hook
+ // fires only during live mid-stream swaps, while both App and bridge
+ // are alive. A mid-run `/new`//`/resume` rebuild inverts the order (the
+ // runtime dies while the App lives), which is why `RebuildHost.rebuild`
+ // calls `app.resetSessionUi()` — dropping every transcript entry, bus
+ // handler, and this hook — BEFORE `world.deinit()`. If you ever make
+ // `App.deinit` call the release hook, or move the world to outlive
+ // `app`, revisit this.
const Flusher = struct {
fn flush(ctx: *anyopaque) void {
@@ -578,7 +630,7 @@ pub fn main(init: std.process.Init) !void {
);
defer alloc.free(model_label);
- // Runtime model/reasoning selectors (Ctrl+M / Ctrl+R). Live-session only:
+ // Runtime model/reasoning selectors (Ctrl+T / Ctrl+R). Live-session only:
// a pick rebuilds the provider config and pushes it to the agent via
// `setConfig`; nothing is written back to config.toml. Borrows the
// long-lived `app_config`, `models.defs`, and `active_config`.
@@ -593,28 +645,24 @@ pub fn main(init: std.process.Init) !void {
model_label,
);
defer selector_ctrl.deinit();
+ // Command-completion popup (leading-`/` typeahead) and the `/resume`
+ // session picker read these; both are stable for the loop's lifetime.
+ selector_ctrl.registry = &cmd_registry;
+ selector_ctrl.session_store = session_store;
app.setSelectors(selector_ctrl);
+ // In-place session switching (/new, /resume): the host tears the world
+ // down, rebuilds it through the boot path above, and repoints every
+ // borrower of the old agent/runtime. Installed after all borrowers exist.
+ var rebuild_host = RebuildHost{ .world = &world, .app = &app, .cmd_ctx = &cmd_ctx };
+ try rebuild_host.wire();
+ app.setSessionRebuild(&rebuild_host, RebuildHost.rebuild);
+
// The render engine writes through a buffered file writer; flush after the
// loop so the final frame and teardown sequences reach the terminal.
defer tui_file.interface.flush() catch {};
- // Turn-time auth resolution. The manager resolves the active provider's
- // named auth session (api_key: no-op; oauth_device: refresh/exchange or an
- // interactive device login) into the live config before each turn.
- var home_layout = try panto_home.resolve(alloc, init.environ_map);
- defer home_layout.deinit();
- var auth_mgr = auth_manager.AuthManager.init(
- alloc,
- io,
- panto.httpClient(),
- home_layout.auth_dir,
- &app_config,
- );
- defer auth_mgr.deinit();
-
tui_app.runLoop(&app, &term, .{
- .agent = agent,
.cmd_registry = &cmd_registry,
.cmd_ctx = &cmd_ctx,
.cmd_capture = &cmd_capture,
@@ -622,6 +670,7 @@ pub fn main(init: std.process.Init) !void {
.cwd = cwd,
.io = io,
.environ = init.environ_map,
+ .editor_override = app_config.tui_editor,
.auth_mgr = &auth_mgr,
}) catch |err| switch (err) {
// Clean user-initiated exit (Ctrl+C / Ctrl+D). Not an error.
@@ -647,9 +696,23 @@ const AgentFlags = struct {
/// Not present: start a new session.
resume_kind: ResumeKind = .none,
resume_id: ?[]const u8 = null, // owned
+ /// `-m/--model <provider:alias>` (or a bare alias): model override.
+ model: ?[]const u8 = null, // owned
+ /// `--effort <level>`: reasoning-level override (the Ctrl+R picker's
+ /// labels). Beats models.toml per-alias knobs and `[defaults] reasoning`.
+ effort: ?[]const u8 = null, // owned
+ /// `-p/--print [<prompt>]`: one-shot non-interactive mode. The prompt
+ /// comes from the flag's argument, or from stdin when piped.
+ print_mode: bool = false,
+ print_prompt: ?[]const u8 = null, // owned
+ /// `--no-extensions`: skip the Lua/luarocks runtime entirely (see bootstrap in main()).
+ no_extensions: bool = false,
pub fn deinit(self: AgentFlags, alloc: std.mem.Allocator) void {
if (self.resume_id) |id| alloc.free(id);
+ if (self.model) |m| alloc.free(m);
+ if (self.effort) |e| alloc.free(e);
+ if (self.print_prompt) |p| alloc.free(p);
}
};
@@ -659,44 +722,289 @@ fn parseAgentFlags(alloc: std.mem.Allocator, args: std.process.Args) !AgentFlags
var flags: AgentFlags = .{};
errdefer flags.deinit(alloc);
+ // Materialize argv so `--resume`'s optional-id lookahead can decline a
+ // `-`-leading token without consuming it (the Args iterator can't rewind).
var it = args.iterate();
defer it.deinit();
_ = it.next(); // argv[0]
+ var argv: std.ArrayList([]const u8) = .empty;
+ defer argv.deinit(alloc);
+ while (it.next()) |a| try argv.append(alloc, a);
- while (it.next()) |a| {
+ var i: usize = 0;
+ while (i < argv.items.len) : (i += 1) {
+ const a = argv.items[i];
if (std.mem.eql(u8, a, "--resume")) {
- // Peek at the next arg. If it exists and doesn't start with `-`,
- // treat it as a session id (or prefix).
- const next = it.next();
- if (next) |id| {
- if (id.len > 0 and id[0] != '-') {
- flags.resume_kind = .by_id;
- flags.resume_id = try alloc.dupe(u8, id);
- continue;
- } else {
- // Not an id; rewind by treating it as a separate flag.
- // The Args API doesn't support rewind, so handle inline.
- flags.resume_kind = .most_recent;
- if (std.mem.eql(u8, id, "--resume")) {
- // back-to-back --resume; second resets, fine.
- continue;
- }
- // Otherwise, fall through and process this token as a flag.
- // (Currently we don't have other flags; ignore unknowns.)
- std.log.warn("panto: ignoring unknown argument '{s}'", .{id});
- continue;
- }
+ flags.resume_kind = .most_recent;
+ // An id follows only if the next token doesn't look like a flag.
+ if (i + 1 < argv.items.len and argv.items[i + 1].len > 0 and argv.items[i + 1][0] != '-') {
+ if (flags.resume_id) |old| alloc.free(old);
+ flags.resume_id = try alloc.dupe(u8, argv.items[i + 1]);
+ flags.resume_kind = .by_id;
+ i += 1;
}
+ continue;
+ }
+ if (std.mem.eql(u8, a, "-c") or std.mem.eql(u8, a, "--continue")) {
flags.resume_kind = .most_recent;
continue;
}
- // Future agent-mode flags would land here. Unknown args are tolerated
- // (the user might be passing something we don't recognize yet).
+ if (std.mem.eql(u8, a, "-m") or std.mem.eql(u8, a, "--model")) {
+ if (i + 1 >= argv.items.len) {
+ std.debug.print("error: {s} requires a <provider:alias> argument\n", .{a});
+ std.process.exit(1);
+ }
+ if (flags.model) |old| alloc.free(old);
+ flags.model = try alloc.dupe(u8, argv.items[i + 1]);
+ i += 1;
+ continue;
+ }
+ if (std.mem.eql(u8, a, "--effort")) {
+ if (i + 1 >= argv.items.len) {
+ std.debug.print("error: --effort requires a <level> argument\n", .{});
+ std.process.exit(1);
+ }
+ if (flags.effort) |old| alloc.free(old);
+ flags.effort = try alloc.dupe(u8, argv.items[i + 1]);
+ i += 1;
+ continue;
+ }
+ if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--print")) {
+ flags.print_mode = true;
+ // An inline prompt follows only if the next token doesn't look
+ // like a flag; otherwise the prompt comes from piped stdin.
+ if (i + 1 < argv.items.len and argv.items[i + 1].len > 0 and argv.items[i + 1][0] != '-') {
+ if (flags.print_prompt) |old| alloc.free(old);
+ flags.print_prompt = try alloc.dupe(u8, argv.items[i + 1]);
+ i += 1;
+ }
+ continue;
+ }
+ if (std.mem.eql(u8, a, "--no-extensions")) {
+ flags.no_extensions = true;
+ continue;
+ }
+ // Future agent-mode flags land here.
+ std.debug.print("error: unknown argument '{s}' (run 'panto --help')\n", .{a});
+ std.process.exit(1);
}
return flags;
}
// -----------------------------------------------------------------------------
+// SessionWorld — per-session boot wiring
+// -----------------------------------------------------------------------------
+
+/// Everything whose lifetime IS the session: the Agent, the Lua interpreter,
+/// and the arena feeding the derived system/compaction prompts. `/new` and
+/// `/resume` are "quit and relaunch, minus the process": tear the world down
+/// and `build` a fresh one through this exact code path — the same one boot
+/// uses. Process-level state (config/models, HTTP client, session store,
+/// luarocks tree + its driver `lua_State`, the TUI, auth) lives outside and
+/// is borrowed via the fields below.
+const SessionWorld = struct {
+ // Borrowed process-level dependencies (valid for the process lifetime).
+ alloc: std.mem.Allocator,
+ io: std.Io,
+ environ: *const std.process.Environ.Map,
+ app_config: *const config_file.Config,
+ /// The live config snapshot the agent reads; `build` stamps the
+ /// re-resolved compaction prompt into it.
+ active_config: *panto.Config,
+ /// Base layer for system-prompt sourcing and extension discovery.
+ agent_dir: []const u8,
+ /// Non-null iff extensions are on (`--no-extensions` leaves it null and
+ /// the world builds with a null Lua runtime). Supplies the per-state
+ /// luarocks wiring (`attachState`) for each fresh interpreter.
+ luarocks_rt: ?*luarocks_runtime.LuarocksRuntime,
+ /// The process-lifetime slash-command registry. Builtins register once
+ /// (in `main`); `build` harvests Lua commands into it and `deinit`
+ /// removes exactly those again, so a rebuild never double-registers.
+ registry: *command.Registry,
+
+ // Owned per-session state. Null/empty between `deinit` and `build`, so
+ // a failed rebuild leaves a world that is still safe to `deinit`.
+ agent: ?*panto.Agent = null,
+ rt: ?*lua_runtime.LuaRuntime = null,
+ sp_arena: ?std.heap.ArenaAllocator = null,
+ /// Resolved compaction prompt (owned by `sp_arena`).
+ compaction_prompt: []const u8 = "",
+
+ /// Build the world around `session`, adopting `conv` when resuming
+ /// (boot's `--resume` and the `/resume` command both load the
+ /// conversation from the store first and hand it here; `Agent.init`
+ /// marks adopted history as already persisted, so nothing is re-written
+ /// to disk). On error nothing is left allocated.
+ fn build(self: *SessionWorld, session: panto.Session, conv: ?panto.Conversation) !void {
+ std.debug.assert(self.agent == null and self.rt == null and self.sp_arena == null);
+ const is_resume = conv != null;
+
+ const agent = try panto.Agent.init(self.alloc, self.io, self.active_config, session, conv);
+ errdefer agent.deinit();
+
+ // One fresh interpreter per session: extensions may keep module-
+ // global state, and its lifetime contract is exactly the session's
+ // (see `extension_loader.zig`).
+ var rt: ?*lua_runtime.LuaRuntime = null;
+ errdefer if (rt) |r| r.deinit();
+ if (self.luarocks_rt) |lr| {
+ const r = try lua_runtime.LuaRuntime.create(self.alloc);
+ rt = r;
+ // Per-state luarocks wiring (embedded searcher, hardcoded
+ // config, package.path/cpath) so `require` resolves the staged
+ // `panto.so` and installed rocks. Disk staging happened once at
+ // process bootstrap.
+ try lr.attachState(r.L);
+ }
+
+ // Source the system prompt (SYSTEM.md / APPEND_SYSTEM.md across the
+ // base/user/project layers): seed a fresh session, or reconcile a
+ // resumed log without rewriting history.
+ var arena = std.heap.ArenaAllocator.init(self.alloc);
+ errdefer arena.deinit();
+ if (is_resume) {
+ try system_prompt.reconcileResume(arena.allocator(), self.io, self.environ, self.agent_dir, agent);
+ } else {
+ try system_prompt.seedFresh(arena.allocator(), self.io, self.environ, self.agent_dir, agent);
+ }
+
+ errdefer self.registry.clearLua();
+ if (rt) |r| {
+ // Wire `require('panto')` to the native module + `ext` subtable,
+ // hand extensions the live session agent, then install the luv
+ // scheduler — in that order (see each fn's doc).
+ try r.installPantoModule();
+ try r.setExtAgent(agent);
+ try r.installScheduler();
+
+ // Discover + activate Lua extensions across the base/user/
+ // project layers; register their tools as one source.
+ const n_ext_tools = extension_loader.discoverAndLoad(
+ self.alloc,
+ self.io,
+ self.environ,
+ self.agent_dir,
+ r,
+ &self.app_config.extensions,
+ self.app_config.ext_paths,
+ self.app_config.ext_rocks,
+ ) catch |err| {
+ std.log.err("extension discovery failed: {t}", .{err});
+ return err;
+ };
+ std.log.debug("extensions: {d} tool(s) registered", .{n_ext_tools});
+ if (n_ext_tools > 0) try agent.registerToolSource(r.toolSource());
+
+ // Slash commands declared via `panto.ext.register_command`. A
+ // name collision with a builtin (or between two extensions)
+ // aborts, matching the tool-name collision policy.
+ for (r.commandList()) |lua_cmd| {
+ self.registry.registerLua(
+ lua_cmd.name,
+ lua_cmd.description,
+ lua_cmd.handler_ref,
+ ) catch |err| {
+ std.log.err(
+ "lua: failed to register command '/{s}': {t}",
+ .{ lua_cmd.name, err },
+ );
+ return err;
+ };
+ }
+ }
+
+ // Resolve the compaction system prompt (COMPACTION.md across layers,
+ // last wins; built-in default otherwise) and stamp it into the
+ // config the agent re-reads each turn.
+ const compaction_prompt = try system_prompt.resolveCompaction(
+ arena.allocator(),
+ self.io,
+ self.environ,
+ self.agent_dir,
+ );
+ self.active_config.compaction.compaction_prompt = compaction_prompt;
+
+ self.agent = agent;
+ self.rt = rt;
+ self.sp_arena = arena;
+ self.compaction_prompt = compaction_prompt;
+ }
+
+ /// Tear the session down: Lua commands out of the registry, then the
+ /// Lua interpreter (extension state, tool source, event bridge), the
+ /// prompt arena, and finally the agent — the same relative order the
+ /// process teardown always used (runtime dies before agent). Safe on a
+ /// never-built or build-failed world.
+ fn deinit(self: *SessionWorld) void {
+ self.registry.clearLua();
+ if (self.rt) |r| r.deinit();
+ self.rt = null;
+ if (self.sp_arena) |*a| a.deinit();
+ self.sp_arena = null;
+ self.active_config.compaction.compaction_prompt = null;
+ self.compaction_prompt = "";
+ if (self.agent) |a| a.deinit();
+ self.agent = null;
+ }
+};
+
+/// The App's session-rebuild hook (/new, /resume): quit-and-relaunch minus
+/// the process. Drops all session-scoped UI state, tears down the old world,
+/// builds a fresh one through the boot path, and repoints every holder of
+/// the old agent/runtime pointers. Commands dispatch only between turns
+/// (`tui_app.handleSubmittedLine` is only reached from the idle input pump),
+/// so no stream is live across this.
+///
+/// A mid-rebuild failure leaves no half-dead session: whether it fails
+/// before the old world's teardown (`resetSessionUi`) or after,
+/// `tui_app.App.rebuildSession` notes the failure in the transcript and
+/// surfaces `error.SessionRebuildFailed`, which exits the TUI loop cleanly
+/// (process restart is the recovery — no partial rollback).
+const RebuildHost = struct {
+ world: *SessionWorld,
+ app: *tui_app.App,
+ cmd_ctx: *command.Context,
+
+ fn rebuild(ctx: *anyopaque, session: panto.Session, conv: ?panto.Conversation) anyerror!void {
+ const self: *RebuildHost = @ptrCast(@alignCast(ctx));
+ // Transcript entries and bus handlers may reference Lua-bridge
+ // memory owned by the outgoing runtime; drop them BEFORE that
+ // runtime dies (the mid-run mirror of the boot-time teardown
+ // ordering contract, which keeps the bridge alive through the
+ // App's teardown).
+ try self.app.resetSessionUi();
+ self.world.deinit();
+ try self.world.build(session, conv);
+ try self.wire();
+ }
+
+ /// Repoint every borrower of the world's agent/runtime at the CURRENT
+ /// world: command Context, Lua event bridge (bus handlers +
+ /// override-release hook), selector controller, footer. Called once at
+ /// boot (after all borrowers exist) and again after every in-place
+ /// rebuild — any new borrower gets wired here, in one place.
+ fn wire(self: *RebuildHost) !void {
+ const agent = self.world.agent.?;
+ self.cmd_ctx.agent = agent;
+ self.cmd_ctx.lua_rt = self.world.rt;
+ self.cmd_ctx.compaction_prompt = self.world.compaction_prompt;
+ if (self.world.rt) |r| {
+ try r.eventBridge().attachBus(self.app.eventBus());
+ self.app.setOverrideRelease(
+ @ptrCast(r.eventBridge()),
+ lua_event_bridge.EventBridge.releaseOverrideThunk,
+ );
+ }
+ if (self.app.selectors) |ctrl| {
+ ctrl.agent = agent;
+ ctrl.resetSessionUsage();
+ }
+ self.app.footer.setSessionId(agent.sessionId()) catch {};
+ self.app.footer.setContextTokens(null);
+ }
+};
+
+// -----------------------------------------------------------------------------
// Session bootstrap
// -----------------------------------------------------------------------------
@@ -704,27 +1012,141 @@ fn parseAgentFlags(alloc: std.mem.Allocator, args: std.process.Args) !AgentFlags
/// Fresh sessions are created on demand; resume resolves by id or picks the
/// most recent. The returned `Session` owns its `info` (freed via the
/// agent's `deinit`, which adopts it).
+/// Diagnostics go to stderr: in `-p` print mode stdout is the assistant
+/// text a script captures, and stderr is right interactively too.
fn openSession(
store: panto.SessionStore,
flags: AgentFlags,
session_dir: []const u8,
- stdout: *std.Io.Writer,
- stdout_file: *std.Io.File.Writer,
) !panto.Session {
switch (flags.resume_kind) {
.none => return store.create(),
.most_recent => {
if (try store.latest()) |sess| return sess;
- try stdout.print("no sessions to resume; starting fresh.\n", .{});
- try stdout_file.flush();
+ std.debug.print("no sessions to resume; starting fresh.\n", .{});
return store.create();
},
.by_id => {
const id = flags.resume_id.?;
- if (try store.resolve(id)) |sess| return sess;
- try stdout.print("error: no session matching '{s}' in {s}\n", .{ id, session_dir });
- try stdout_file.flush();
+ const resolved = store.resolve(id) catch |err| switch (err) {
+ error.AmbiguousSessionId => {
+ var buf: [256]u8 = undefined;
+ const stderr = std.debug.lockStderr(&buf);
+ defer std.debug.unlockStderr();
+ const w = &stderr.file_writer.interface;
+ w.writeAll("error: ") catch {};
+ builtin_commands.printAmbiguousSessions(store, id, w) catch {};
+ return error.AmbiguousSessionId;
+ },
+ else => return err,
+ };
+ if (resolved) |sess| return sess;
+ std.debug.print("error: no session matching '{s}' in {s}\n", .{ id, session_dir });
return error.SessionNotFound;
},
}
}
+
+// -----------------------------------------------------------------------------
+// Print mode (-p/--print): one-shot plain-text turn driver
+// -----------------------------------------------------------------------------
+
+/// Routes stream events to a writer, printing only assistant Text-block
+/// deltas (thinking and tool traffic are dropped) and ensuring the output
+/// ends each text block on a newline.
+const TextPrinter = struct {
+ out: *std.Io.Writer,
+ /// Whether the currently open block is a Text block.
+ in_text: bool = false,
+ last_byte: u8 = '\n',
+
+ fn onEvent(self: *TextPrinter, ev: panto.Event) !void {
+ switch (ev) {
+ .block_start => |bs| self.in_text = bs.block_type == .Text,
+ .content_delta => |cd| {
+ if (self.in_text) {
+ try self.out.writeAll(cd.delta);
+ if (cd.delta.len > 0) self.last_byte = cd.delta[cd.delta.len - 1];
+ }
+ },
+ .block_complete => {
+ if (self.in_text and self.last_byte != '\n') {
+ try self.out.writeAll("\n");
+ self.last_byte = '\n';
+ }
+ self.in_text = false;
+ },
+ else => {},
+ }
+ }
+};
+
+/// Drive one agent turn without the TUI: assistant text to `stdout`, errors
+/// to the caller. Mirrors `tui_app.driveTurnBlocks`' two one-shot fallbacks
+/// (adaptive-thinking rewrite, forced auth refresh). Persistence rides on the
+/// agent exactly as in TUI mode.
+fn runPrintTurn(
+ alloc: std.mem.Allocator,
+ agent: *panto.Agent,
+ active_config: *panto.Config,
+ provider_name: []const u8,
+ auth_mgr: *auth_manager.AuthManager,
+ stdout_file: *std.Io.File.Writer,
+ prompt: []const u8,
+) !void {
+ const stdout = &stdout_file.interface;
+ // No presenter: an unauthenticated oauth provider fails with
+ // LoginRequired rather than starting an interactive device login.
+ try auth_mgr.resolveInto(active_config, provider_name, false, null);
+ agent.setConfig(active_config);
+
+ var blocks = [_]panto.ContentBlock{
+ .{ .Text = try panto.textualBlockFromSlice(alloc, prompt) },
+ };
+ var stream = try agent.run(.{ .blocks = &blocks });
+ defer stream.deinit();
+
+ var printer: TextPrinter = .{ .out = stdout };
+ var fallback_used = false;
+ var auth_retry_used = false;
+ while (true) {
+ const ev = stream.next() catch |err| {
+ if (!fallback_used and err == error.ProviderBadRequest and
+ tui_selectors.adaptiveFallback(&active_config.provider))
+ {
+ fallback_used = true;
+ agent.setConfig(active_config);
+ try stream.reopen();
+ continue;
+ }
+ if (!auth_retry_used and err == error.ProviderAuthFailed) {
+ auth_retry_used = true;
+ auth_mgr.resolveInto(active_config, provider_name, true, null) catch return err;
+ agent.setConfig(active_config);
+ try stream.reopen();
+ continue;
+ }
+ return err;
+ };
+ const e = ev orelse break;
+ try printer.onEvent(e);
+ try stdout_file.flush(); // stream text as it arrives
+ }
+ try stdout_file.flush();
+}
+
+test "TextPrinter prints only Text deltas, newline-terminated" {
+ var out = std.Io.Writer.Allocating.init(std.testing.allocator);
+ defer out.deinit();
+ var p: TextPrinter = .{ .out = &out.writer };
+
+ try p.onEvent(.{ .message_start = .assistant });
+ try p.onEvent(.{ .block_start = .{ .block_type = .Thinking, .index = 0 } });
+ try p.onEvent(.{ .content_delta = .{ .index = 0, .delta = "pondering" } });
+ try p.onEvent(.{ .block_start = .{ .block_type = .Text, .index = 1 } });
+ try p.onEvent(.{ .content_delta = .{ .index = 1, .delta = "hello" } });
+ try p.onEvent(.{ .content_delta = .{ .index = 1, .delta = " world" } });
+ try p.onEvent(.{ .block_complete = .{ .index = 1, .block = undefined } });
+ try p.onEvent(.turn_complete);
+ try std.testing.expectEqualStrings("hello world\n", out.written());
+}