1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
|
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 [<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);
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
// `<data home>/debug/<id>.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 <id>`: 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 <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);
}
};
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 <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
// -----------------------------------------------------------------------------
/// 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());
}
|