From f759f149377942c4e04802c45162cda1c9bfb2b3 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 4 Jul 2026 09:50:12 -0600 Subject: big cli/tui gaps project --- src/main.zig | 880 +++++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 651 insertions(+), 229 deletions(-) (limited to 'src/main.zig') 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 []`. + // Parse the agent-mode flags (`--resume []`, `-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(); - - // 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(); - - // Source the system prompt now that the base agent tree has been - // staged to `/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(), + + // 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(); + + // 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); + + // 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 }); - }; + // 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 }); + }; + } } - // Discover Lua extensions across three layers — base - // (/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()); - } + // 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; - // 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; - - // 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); - - // 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; - }; + try builtin_commands.register(&cmd_registry); + + // 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()); - - // 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`). + // `[tui] tools_collapsed`: the Ctrl+O collapse state's starting value. + if (app_config.tui_tools_collapsed) |c| app.tools_collapsed = c; + + // 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 ` (or a bare alias): model override. + model: ?[]const u8 = null, // owned + /// `--effort `: 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 []`: 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,43 +722,288 @@ 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 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 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()); +} -- cgit v1.3