const std = @import("std"); const panto = @import("panto"); const pricing_format = @import("pricing_format.zig"); const markdown = @import("markdown.zig"); const lua_bridge = @import("lua_bridge.zig"); const lua_runtime = @import("lua_runtime.zig"); const lua_event_bridge = @import("lua_event_bridge.zig"); const extension_loader = @import("extension_loader.zig"); const panto_home = @import("panto_home.zig"); const luarocks_runtime = @import("luarocks_runtime.zig"); const subcommand = @import("subcommand.zig"); const session_paths = @import("session_paths.zig"); const models_toml = @import("models_toml.zig"); const config_file = @import("config_file.zig"); const auth_manager = @import("auth_manager.zig"); 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 /// per-session file; otherwise this keeps the stderr default. pub const std_options: std.Options = .{ .logFn = debug_log.logFn }; // TUI foundation layer (Phase 1, sub-phase 1). Not yet wired into the REPL; // referenced here so `zig build test` type-checks and exercises them. const tui_terminal = @import("tui_terminal.zig"); const tui_key = @import("tui_key.zig"); const tui_input = @import("tui_input.zig"); const tui_theme = @import("tui_theme.zig"); const tui_component = @import("tui_component.zig"); const tui_engine = @import("tui_engine.zig"); const tui_components = @import("tui_components.zig"); const tui_event = @import("tui_event.zig"); const tui_app = @import("tui_app.zig"); const tui_selectors = @import("tui_selectors.zig"); // Shorthand alias for the Lua C API. The bridge module owns the actual // `@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 // `.warn` for expected-failure paths exercised by tests; the test // runner's logger gates on `std.testing.log_level`, which defaults to // `.warn`. Raising it to `.err` silences expected warnings without // changing production behavior. Anything that *should* be visible in a // passing test must use `std.debug.print` or assert via the testing API. std.testing.log_level = .err; std.testing.refAllDecls(@This()); _ = lua_bridge; _ = lua_runtime; _ = lua_event_bridge; _ = extension_loader; _ = panto_home; _ = luarocks_runtime; _ = subcommand; _ = models_toml; _ = config_file; _ = auth_manager; _ = glob; _ = system_prompt; _ = command; _ = command_compaction; _ = builtin_commands; _ = debug_log; _ = pricing_format; _ = markdown; _ = tui_terminal; _ = tui_key; _ = tui_input; _ = tui_theme; _ = tui_component; _ = tui_engine; _ = tui_components; _ = tui_event; _ = tui_app; _ = tui_selectors; } /// Spin up a Lua interpreter, run a no-op, tear it down. Catches /// link/compile errors on the Lua dependency at the earliest moment. /// Logs only in debug builds; release builds run silently. fn luaSmokeCheck() void { const L = lua.luaL_newstate() orelse { std.log.err("lua: luaL_newstate returned null", .{}); return; }; defer lua.lua_close(L); lua.luaL_openlibs(L); // Push _VERSION to confirm libs loaded. _ = lua.lua_getglobal(L, "_VERSION"); const ver = lua.lua_tolstring(L, -1, null); if (ver != null) { std.log.debug("lua: {s} linked OK", .{ver}); } lua.lua_settop(L, 0); } /// Print a friendly message for a config.toml load failure before the /// process exits non-zero. fn reportConfigError(err: anyerror) void { switch (err) { error.InvalidConfigToml => std.debug.print( "error: a config.toml failed to parse (see log above).\n", .{}, ), error.InvalidProvider => std.debug.print( "error: a [providers.*] entry is malformed (need style + base_url).\n", .{}, ), error.InvalidModelRef => std.debug.print( "error: defaults.model must look like \"provider:model\".\n", .{}, ), error.UnknownDefaultProvider => std.debug.print( "error: defaults.model names a provider that isn't configured (or whose API key env var isn't set).\n", .{}, ), error.PolicyConflict => std.debug.print( "error: a tool/extension pattern appears in both allow and deny.\n", .{}, ), error.NoHomeDirectory => std.debug.print( "error: cannot resolve config paths — set HOME or XDG_CONFIG_HOME/XDG_DATA_HOME.\n", .{}, ), else => std.debug.print("error: failed to load config: {s}\n", .{@errorName(err)}), } } /// Print a friendly message for a model-selection failure. fn reportModelError(err: anyerror, cfg: *const config_file.Config) void { switch (err) { error.NoModelSelected => { std.debug.print( "error: no model selected. Set `defaults.model = \"provider:alias\"` in config.toml.\n", .{}, ); if (cfg.providers.len == 0) { std.debug.print( " (no providers resolved — check that an API key or its env var is present.)\n", .{}, ); } }, error.UnknownProvider => std.debug.print( "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)}), } } pub fn main(init: std.process.Init) !void { const alloc = init.gpa; const io = init.io; // Smoke test: prove Lua is linked. Slice 1 — no extension runtime // wired up yet, this just confirms the static lib comes through. luaSmokeCheck(); // Resolve the absolute path of the running panto binary. Needed // both by `panto lua` (we re-exec ourselves through a wrapper // luarocks invokes) and by the agent's bootstrap. const panto_path = try std.process.executablePathAlloc(io, alloc); defer alloc.free(panto_path); // Subcommand dispatch: `panto lua` and `panto bootstrap` short // out of the agent loop, but still run the same luarocks bootstrap // pipeline so first-run setup happens consistently. switch (try subcommand.dispatch( alloc, io, init.environ_map, init.minimal.args, panto_path, )) { .done => return, .agent => {}, } // 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); var stdout_buffer: [4096]u8 = undefined; var stdout_file = std.Io.File.stdout().writer(io, &stdout_buffer); const stdout = &stdout_file.interface; // The TUI reads raw bytes from the tty fd directly (raw mode); we only // 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); const cwd = cwd_buf[0..cwd_n]; const session_dir = try session_paths.sessionDirForCwd(alloc, init.environ_map, cwd); defer alloc.free(session_dir); // Load the merged config.toml (base → user → project). Providers, // default model, and tool/extension policy all come from here. var app_config = config_file.load(alloc, io, init.environ_map, cwd) catch |err| { reportConfigError(err); std.process.exit(1); }; defer app_config.deinit(); // Load models.toml — model aliases (wire name + knobs) and pricing — // merged across the base/user/project/local layers. Missing files are // fine (empty registries); cost lookups return null, formatted as // "unknown" by the display layer. var models = try models_toml.load(alloc, io, init.environ_map, cwd); defer models.deinit(); std.log.debug( "models.toml: {d} model def(s), {d} price entr(ies) from merged layers", .{ models.defs.count(), models.pricing.count() }, ); // `models.pricing` is parsed and ready; the print-based CLI doesn't // 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, cli_flags.model) catch |err| { reportModelError(err, &app_config); std.process.exit(1); }; 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). const compaction_provider_config: ?panto.ProviderConfig = blk: { const ref = app_config.compaction_model_ref orelse break :blk null; break :blk config_file.buildProviderConfig(&app_config, &models.defs, ref) catch |err| { std.log.warn("compaction_model resolution failed ({t}); using active model", .{err}); break :blk null; }; }; const compaction_cfg: panto.CompactionConfig = .{ .keep_verbatim = app_config.compaction_keep_verbatim orelse 20_000, .model = compaction_provider_config, }; // Process-global HTTP client: one connection pool for every provider / // base_url the agent may switch to. Torn down at shutdown. panto.init(alloc, io); defer panto.deinit(); const banner_model_initial: []const u8 = switch (provider_config) { inline else => |c| c.model, }; const banner_provider_initial: []const u8 = model_ref.provider; // The session store: a directory-backed JSONL catalog rooted at the // per-cwd `session_dir` (the CLI owns the cwd→dir grouping; the store // is cwd-agnostic). Must outlive the agent, which appends through a // `Session` handle minted/resolved below. const cwd_json = try std.json.Stringify.valueAlloc(alloc, cwd, .{}); defer alloc.free(cwd_json); const session_metadata = try std.fmt.allocPrint(alloc, "{{\"cwd\":{s}}}", .{cwd_json}); defer alloc.free(session_metadata); var session_store_impl = try panto.FileSystemJSONLStore.initWithMetadata(alloc, io, session_dir, session_metadata); defer session_store_impl.deinit(); const session_store = session_store_impl.store(); // Create or resume the session. Resume failures (missing/ambiguous id) // are user errors — print a tidy message and exit 1 rather than // printing a Zig stack trace. var session = openSession( session_store, cli_flags, session_dir, ) catch |err| switch (err) { error.SessionNotFound, error.AmbiguousSessionId => std.process.exit(1), else => return err, }; // `session.info` is adopted by the agent below (`Agent.init` can't fail) // and freed in the agent's `deinit`; no separate cleanup here. // Arm the per-session debug log now that we know the session id. // PANTO_DEBUG!=0 redirects `std.log` output to // `/debug/.log`. Best-effort: failures leave normal // logging in place. debug_log.init(alloc, io, init.environ_map, session.info.id); const is_resume = cli_flags.resume_kind != .none and session.info.message_count > 0; // On resume, reconstruct the conversation from the store. (The // dangling-prompt recovery feature was dropped in R2.) On a fresh // session, the agent starts with an empty conversation. var adopted_conversation: ?panto.Conversation = null; if (is_resume) { adopted_conversation = try session.load(); 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 }, ); } } // The active configuration snapshot the agent (re-)reads each turn. var active_config: panto.Config = .{ .provider = provider_config, .compaction = compaction_cfg, }; // 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, L, panto_path, ); // 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 }); }; } } // 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; // 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); // 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 // Context's `stdout`) and flushed into the TUI transcript after each // dispatch — TUI-safe: handlers never write raw bytes mid-frame. This is // the documented minimal choice for slash-command output under the TUI; a // richer routing (per-command transcript components) can refine it later. var cmd_capture = std.Io.Writer.Allocating.init(alloc); defer cmd_capture.deinit(); var cmd_ctx: command.Context = .{ .allocator = alloc, .agent = agent, .stdout = &cmd_capture.writer, .stdout_file = &stdout_file, .compaction_prompt = world.compaction_prompt, .provider_name = banner_provider_initial, .model_name = banner_model_initial, .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). // The terminal enters raw mode + bracketed paste; the engine drives a // differential render of the transcript + pinned input box + footer; the // app loop pumps libpanto's pull Stream into component state. The terminal // is restored on every exit path (defer) and on crash (signal handlers // installed by `enableRawMode` + the panic-restore hook). var term = tui_terminal.Terminal.init(stdin_handle, init.environ_map.*) catch |err| switch (err) { tui_terminal.Error.NotATty => { std.debug.print( "error: panto's TUI requires an interactive terminal (stdin/stdout must be a tty).\n", .{}, ); std.process.exit(1); }, else => return err, }; try term.enableRawMode(); defer term.deinit(); const size = term.refreshSize(); var tui_writer_buf: [16 * 1024]u8 = undefined; var tui_file = std.Io.File.stdout().writer(io, &tui_writer_buf); var engine = tui_engine.Engine.init( alloc, &tui_file.interface, size.cols, size.rows, term.caps.synchronized_output, ); defer engine.deinit(); var input_box = tui_components.InputBox.init(alloc); defer input_box.deinit(); var footer = tui_components.Footer.init(alloc); defer footer.deinit(); var io_clock = tui_app.IoClock.init(io); var app = tui_app.App.init( alloc, &engine, io_clock.clock(), &input_box, &footer, ); defer app.deinit(); // `[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 `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 { const fw: *std.Io.File.Writer = @ptrCast(@alignCast(ctx)); fw.interface.flush() catch {}; } }; app.setFlusher(&tui_file, Flusher.flush); const model_label = try std.fmt.allocPrint( alloc, "{s}:{s}", .{ banner_provider_initial, banner_model_initial }, ); defer alloc.free(model_label); // 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`. const selector_ctrl = try tui_app.SelectorController.init( alloc, &app, agent, &app_config, &models.defs, &models.pricing, &active_config, 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 {}; tui_app.runLoop(&app, &term, .{ .cmd_registry = &cmd_registry, .cmd_ctx = &cmd_ctx, .cmd_capture = &cmd_capture, .model_label = model_label, .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. error.UserExit => {}, else => { // Restore the terminal before surfacing the diagnostic so the // message is readable. term.deinit(); tui_file.interface.flush() catch {}; std.debug.print("\n[fatal: {s}]\n", .{@errorName(err)}); return err; }, }; } // ----------------------------------------------------------------------------- // CLI flag parsing // ----------------------------------------------------------------------------- const AgentFlags = struct { /// `--resume` without an id: resume the most recent session. /// `--resume `: resume the session whose id has this prefix. /// 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); } }; const ResumeKind = enum { none, most_recent, by_id }; 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); var i: usize = 0; while (i < argv.items.len) : (i += 1) { const a = argv.items[i]; if (std.mem.eql(u8, a, "--resume")) { 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; } 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 // ----------------------------------------------------------------------------- /// Mint or resolve the `Session` to drive, against the catalog `store`. /// 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, ) !panto.Session { switch (flags.resume_kind) { .none => return store.create(), .most_recent => { if (try store.latest()) |sess| return sess; std.debug.print("no sessions to resume; starting fresh.\n", .{}); return store.create(); }, .by_id => { const id = flags.resume_id.?; 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()); }