smolcoder 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,680 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // smolcoder — a tiny, zero-config CLI coding agent for local models.
4
+ //
5
+ // Interactive: an opencode-style inline TUI. No upfront questions — the last
6
+ // (or first) detected model is picked automatically; switch with /models,
7
+ // cycle modes with shift+tab, set reasoning effort with /effort.
8
+ // Headless: smol -p "prompt" for people and automations.
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ const fs = __importStar(require("fs"));
44
+ const os = __importStar(require("os"));
45
+ const path = __importStar(require("path"));
46
+ const agent_1 = require("./agent");
47
+ const context_1 = require("./context");
48
+ const detect_1 = require("./detect");
49
+ const events_1 = require("./events");
50
+ const plan_1 = require("./plan");
51
+ const prompt_1 = require("./prompt");
52
+ const lmstudio_1 = require("./providers/lmstudio");
53
+ const ollama_1 = require("./providers/ollama");
54
+ const index_1 = require("./tools/index");
55
+ const shell_1 = require("./tools/shell");
56
+ const tasks_1 = require("./tools/tasks");
57
+ const tui_1 = require("./tui/tui");
58
+ const ui_1 = require("./ui");
59
+ const webui_1 = require("./web/webui");
60
+ const util_1 = require("./util");
61
+ const VERSION = require("../package.json").version;
62
+ const CONFIG_PATH = path.join(os.homedir(), ".smolcoder.json");
63
+ function parseArgs(argv) {
64
+ const args = { workspace: process.cwd() };
65
+ for (let i = 0; i < argv.length; i++) {
66
+ const a = argv[i];
67
+ if (a === "--help" || a === "-h")
68
+ args.help = true;
69
+ else if (a === "--version" || a === "-v")
70
+ args.version = true;
71
+ else if (a === "--mode" || a === "-m") {
72
+ const v = argv[++i];
73
+ if (v === "ro" || v === "read-only" || v === "readonly")
74
+ args.mode = "ro";
75
+ else if (v === "edit" || v === "e" || v === "write" || v === "w")
76
+ args.mode = "edit";
77
+ else if (v === "bypass" || v === "bypass-permissions" || v === "b" || v === "yolo" || v === "y")
78
+ args.mode = "bypass";
79
+ else {
80
+ console.error(`Unknown mode "${v}". Use ro, edit, or bypass.`);
81
+ process.exit(1);
82
+ }
83
+ }
84
+ else if (a === "--bypass" || a === "--bypass-permissions" || a === "--yolo")
85
+ args.mode = "bypass";
86
+ else if (a === "--model")
87
+ args.model = argv[++i];
88
+ else if (a === "--ctx")
89
+ args.ctx = Number(argv[++i]) || undefined;
90
+ else if (a === "--effort") {
91
+ const v = argv[++i];
92
+ if (v === "off" || v === "low" || v === "medium" || v === "high")
93
+ args.effort = v;
94
+ else if (v === "default")
95
+ args.effort = null;
96
+ else {
97
+ console.error(`Unknown effort "${v}". Use off, low, medium, high, or default.`);
98
+ process.exit(1);
99
+ }
100
+ }
101
+ else if (a === "--print" || a === "-p")
102
+ args.print = argv[++i];
103
+ else if (a === "--web") {
104
+ args.web = true;
105
+ if (argv[i + 1] && /^\d+$/.test(argv[i + 1]))
106
+ args.webPort = Number(argv[++i]);
107
+ }
108
+ else if (!a.startsWith("-"))
109
+ args.workspace = path.resolve(a);
110
+ else {
111
+ console.error(`Unknown option "${a}". Try smol --help.`);
112
+ process.exit(1);
113
+ }
114
+ }
115
+ return args;
116
+ }
117
+ const HELP = `
118
+ ${util_1.c.bold("smolcoder")} v${VERSION} — a tiny, zero-config coding agent for local models.
119
+
120
+ Detects Ollama and LM Studio automatically. No configuration.
121
+
122
+ ${util_1.c.bold("Usage:")}
123
+ smol [workspace] [options]
124
+
125
+ ${util_1.c.bold("Options:")}
126
+ -m, --mode <ro|edit|bypass> ro: read files only. edit: read/write files and run
127
+ commands inside the workspace; anything reaching
128
+ outside it asks y/n. bypass: no approvals at all.
129
+ --model <name> pick a model by (partial) name
130
+ --ctx <tokens> force a context window (Ollama: sends num_ctx)
131
+ --effort <level> reasoning effort: off, low, medium, high, default
132
+ --web [port] serve the session as a local web UI (default port 7433)
133
+ -p, --print "<prompt>" headless: run a single prompt and exit
134
+ -h, --help this help
135
+ -v, --version version
136
+
137
+ ${util_1.c.bold("Keys:")}
138
+ shift+tab cycle mode (read-only → edit → bypass permissions)
139
+ / slash commands (autocomplete menu)
140
+ esc cancel a running turn · clear the input
141
+ ctrl+c ×2 quit
142
+
143
+ ${util_1.c.bold("Slash commands:")}
144
+ /models switch model /tasks background tasks
145
+ /mode set mode /logs <id> task output
146
+ /effort reasoning effort /stop <id> kill a task
147
+ /context context usage /clear reset conversation
148
+ /compact compact now /exit quit
149
+ `;
150
+ function loadConfig() {
151
+ try {
152
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
153
+ // Never let bypass be inherited implicitly from a past session — a single
154
+ // shift+tab into it would otherwise silently persist unattended, unchecked
155
+ // command execution into every later run, including headless -p in CI.
156
+ // Requires an explicit flag (-m bypass / --bypass) each time. Old configs
157
+ // saved "write"/"yolo" under the previous mode names.
158
+ if (cfg.lastMode === "write")
159
+ cfg.lastMode = "edit";
160
+ if (cfg.lastMode === "yolo" || cfg.lastMode === "bypass")
161
+ cfg.lastMode = "edit";
162
+ return cfg;
163
+ }
164
+ catch {
165
+ return {};
166
+ }
167
+ }
168
+ function saveConfig(cfg) {
169
+ try {
170
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
171
+ }
172
+ catch {
173
+ /* non-fatal */
174
+ }
175
+ }
176
+ /** Output budget scales with the window: big windows can afford whole-file
177
+ * writes (a single write_file's JSON must fit in the output), tiny windows
178
+ * must stay conservative. */
179
+ function outputBudget(window) {
180
+ return Math.max(1024, Math.min(16384, Math.floor(window / 4)));
181
+ }
182
+ function makeProvider(m) {
183
+ const maxOut = outputBudget(m.contextWindow);
184
+ return m.backend === "ollama"
185
+ ? new ollama_1.OllamaProvider(m.baseUrl, m.id, m.contextWindow, m.numCtx, maxOut)
186
+ : new lmstudio_1.LmStudioProvider(m.baseUrl, m.id, m.contextWindow, maxOut, m.reasoning);
187
+ }
188
+ /** One-line advice when the effective reasoning setting will be slow: LM
189
+ * Studio applies the model's own default level when none is chosen, and for
190
+ * current qwen builds that default is the maximum. */
191
+ function effortAdvice(m, effort) {
192
+ if (m.backend !== "lmstudio" || !m.reasoning?.default)
193
+ return null;
194
+ const d = m.reasoning.default;
195
+ if (effort === null && /^(high|xhigh)$/.test(d)) {
196
+ return `this model thinks at "${d}" by default on LM Studio — expect long pauses before each tool call. /effort off (or --effort off) is many times faster; /effort low or medium keeps some reasoning.`;
197
+ }
198
+ return null;
199
+ }
200
+ /** Tell the user what context management just did (both UIs; headless logs
201
+ * it to stderr so a long run's log shows when and how hard compaction hit). */
202
+ function reportCompactions(bus, ui) {
203
+ bus.on("post_compact", (report) => {
204
+ const delta = `${report?.before} → ${report?.after} tokens est.`;
205
+ if (report?.action === "evicted")
206
+ ui.status(`· freed context by dropping old tool output (${delta})`);
207
+ else if (report?.action === "compacted")
208
+ ui.status(`· compacted the conversation into hand-over notes (${delta})`);
209
+ else if (report?.action === "floor")
210
+ ui.warn(`· context is at its floor: system prompt + tools + the working tail no longer fit comfortably (${delta}). Consider a bigger context window.`);
211
+ });
212
+ }
213
+ /** Node fires 'exit' on normal termination but NOT on a killing signal, so
214
+ * background tasks (dev servers) survive a closed terminal (SIGHUP) or `kill`
215
+ * (SIGTERM) unless we handle those explicitly. Runs synchronous cleanup then
216
+ * re-exits so the 'exit' path is still reached. */
217
+ function installSignalCleanup(cleanup) {
218
+ let done = false;
219
+ const run = (code) => {
220
+ if (done)
221
+ return;
222
+ done = true;
223
+ try {
224
+ cleanup();
225
+ }
226
+ catch {
227
+ /* best effort */
228
+ }
229
+ process.exit(code);
230
+ };
231
+ process.on("SIGTERM", () => run(143));
232
+ process.on("SIGHUP", () => run(129));
233
+ process.on("SIGINT", () => run(130));
234
+ }
235
+ function autoPickModel(models, wanted, remembered) {
236
+ if (wanted) {
237
+ const hit = models.find((m) => m.id === wanted) ??
238
+ models.find((m) => m.id.toLowerCase().includes(wanted.toLowerCase()));
239
+ if (hit)
240
+ return hit;
241
+ }
242
+ return (models.find((m) => m.id === remembered) ??
243
+ models.find((m) => m.backend === "ollama") ??
244
+ models.find((m) => m.loaded) ??
245
+ models[0]);
246
+ }
247
+ function noBackendsMessage() {
248
+ return (util_1.c.red("No local model backend found.") +
249
+ `\n\nsmolcoder looks for:\n` +
250
+ ` · ${util_1.c.bold("Ollama")} at http://127.0.0.1:11434 ${util_1.c.dim("(or $OLLAMA_HOST)")} — install: https://ollama.com, then: ollama pull qwen3\n` +
251
+ ` · ${util_1.c.bold("LM Studio")} at http://127.0.0.1:1234 — start its local server (Developer tab → Start Server)\n\n` +
252
+ `Start one of them and run smol again. No configuration needed.`);
253
+ }
254
+ function sessionLine(m, mode) {
255
+ return `${util_1.c.green("●")} ${m.backend} · ${util_1.c.bold(m.id)} · ctx ${m.contextWindow.toLocaleString()} · ${index_1.MODE_LABELS[mode]} mode`;
256
+ }
257
+ const MODE_ORDER = ["ro", "edit", "bypass"];
258
+ const LOGO_ROWS = [
259
+ "████████╗ ██╗ ███╗ ██╗ ██╗ ██╗",
260
+ "╚══██╔══╝ ██║ ████╗ ██║ ╚██╗ ██╔╝",
261
+ " ██║ ██║ ██╔██╗ ██║ ╚████╔╝ ",
262
+ " ██║ ██║ ██║╚██╗██║ ╚██╔╝ ",
263
+ " ██║ ██║ ██║ ╚████║ ██║ ",
264
+ " ╚═╝ ╚═╝ ╚═╝ ╚═══╝ ╚═╝ ",
265
+ ];
266
+ function printLogo() {
267
+ const cols = process.stdout.columns || 80;
268
+ if (cols >= 46) {
269
+ console.log();
270
+ LOGO_ROWS.forEach((row, i) => {
271
+ const tail = i === LOGO_ROWS.length - 1 ? " " + util_1.c.dim(util_1.c.bold("coder") + " v" + VERSION) : "";
272
+ console.log(" " + util_1.c.cyan(row) + tail);
273
+ });
274
+ console.log();
275
+ }
276
+ else {
277
+ console.log(`${util_1.c.bold("tiny")}${util_1.c.dim(util_1.c.bold("coder"))} ${util_1.c.dim("v" + VERSION)}`);
278
+ }
279
+ }
280
+ function fmtTokens(n) {
281
+ return n < 1000 ? String(n) : (n / 1000).toFixed(1) + "k";
282
+ }
283
+ function modeColored(mode) {
284
+ const label = index_1.MODE_LABELS[mode];
285
+ if (mode === "bypass")
286
+ return util_1.c.red(util_1.c.bold(label));
287
+ if (mode === "ro")
288
+ return util_1.c.magenta(util_1.c.bold(label));
289
+ return util_1.c.cyan(util_1.c.bold(label));
290
+ }
291
+ // ---------------------------------------------------------------------------
292
+ async function main() {
293
+ const args = parseArgs(process.argv.slice(2));
294
+ if (args.help) {
295
+ console.log(HELP);
296
+ return;
297
+ }
298
+ if (args.version) {
299
+ console.log(VERSION);
300
+ return;
301
+ }
302
+ if (!fs.existsSync(args.workspace) || !fs.statSync(args.workspace).isDirectory()) {
303
+ console.error(`Workspace folder does not exist: ${args.workspace}`);
304
+ process.exit(1);
305
+ }
306
+ if (args.print !== undefined) {
307
+ await runHeadless(args);
308
+ }
309
+ else {
310
+ await runInteractive(args);
311
+ }
312
+ }
313
+ // ---- headless (-p) ---------------------------------------------------------
314
+ async function runHeadless(args) {
315
+ const ui = new ui_1.UI();
316
+ const bus = new events_1.EventBus();
317
+ const models = await (0, detect_1.detectAll)();
318
+ if (models.length === 0) {
319
+ ui.println(noBackendsMessage());
320
+ ui.close();
321
+ process.exit(1);
322
+ }
323
+ const cfg = loadConfig();
324
+ let chosen = autoPickModel(models, args.model, cfg.lastModel);
325
+ chosen = await (0, detect_1.resolveContextWindow)(chosen, args.ctx);
326
+ const mode = args.mode ?? cfg.lastMode ?? "edit";
327
+ const shell = (0, shell_1.pickShell)();
328
+ const provider = makeProvider(chosen);
329
+ provider.setEffort(args.effort !== undefined ? args.effort : (cfg.effort ?? null));
330
+ const taskManager = new tasks_1.TaskManager(args.workspace);
331
+ const toolCtx = {
332
+ workspace: args.workspace,
333
+ taskManager,
334
+ plan: new plan_1.Plan(),
335
+ filesTouched: new Set(),
336
+ commandsRun: [],
337
+ };
338
+ const ctxMgr = new context_1.ContextManager(chosen.contextWindow, provider.maxOutputTokens);
339
+ const agentsMd = (0, prompt_1.loadAgentsMd)(args.workspace);
340
+ if (agentsMd)
341
+ ui.status(`· AGENTS.md loaded (${agentsMd.split("\n").length} lines)`);
342
+ const systemPrompt = (0, prompt_1.buildSystemPrompt)({ workspace: args.workspace, mode, shellLabel: shell.label, agentsMd });
343
+ const agent = new agent_1.Agent(provider, mode, systemPrompt, toolCtx, ctxMgr, bus, ui, false, 1000);
344
+ reportCompactions(bus, ui);
345
+ process.on("exit", () => taskManager.killAll());
346
+ installSignalCleanup(() => taskManager.killAll());
347
+ ui.println(sessionLine(chosen, mode));
348
+ if (chosen.note)
349
+ ui.warn(` ${chosen.note}`);
350
+ const effortSetting = args.effort !== undefined ? args.effort : (cfg.effort ?? null);
351
+ ui.status(` effort ${provider.effortLabel() ?? effortSetting ?? "default"}`);
352
+ const advice = effortAdvice(chosen, effortSetting);
353
+ if (advice)
354
+ ui.warn(` ${advice}`);
355
+ try {
356
+ await agent.runTurn(args.print);
357
+ }
358
+ catch (err) {
359
+ ui.error(`\n${err?.message ?? err}`);
360
+ process.exitCode = 1;
361
+ }
362
+ const st = agent.lastTurnStats;
363
+ if (st) {
364
+ // Machine-readable summary for scripts/benchmarks comparing backends.
365
+ process.stderr.write(`[stats] ${JSON.stringify({
366
+ backend: chosen.backend,
367
+ model: chosen.id,
368
+ durationMs: st.durationMs,
369
+ modelCalls: st.modelCalls,
370
+ toolCalls: st.toolCalls,
371
+ generatedTokens: st.generatedTokens,
372
+ thinkingTokensEst: Math.round(st.thinkingChars / 4),
373
+ genTokPerSec: st.genSeconds > 0 ? Math.round(st.generatedTokens / st.genSeconds) : null,
374
+ promptTokensLast: st.promptTokensLast,
375
+ contextWindow: chosen.contextWindow,
376
+ planDone: toolCtx.plan.exists ? `${toolCtx.plan.doneCount}/${toolCtx.plan.steps.length}` : null,
377
+ })}\n`);
378
+ }
379
+ taskManager.killAll();
380
+ ui.close();
381
+ }
382
+ // ---- interactive TUI -------------------------------------------------------
383
+ const SLASH_COMMANDS = [
384
+ { name: "models", desc: "Switch model" },
385
+ { name: "mode", desc: "Set mode (ro / edit / bypass)" },
386
+ { name: "effort", desc: "Set reasoning effort" },
387
+ { name: "plan", desc: "Show the agent's plan" },
388
+ { name: "context", desc: "Show context usage" },
389
+ { name: "compact", desc: "Compact the conversation now" },
390
+ { name: "tasks", desc: "List background tasks" },
391
+ { name: "logs", desc: "Show task output — /logs t1" },
392
+ { name: "stop", desc: "Stop a background task — /stop t1" },
393
+ { name: "clear", desc: "Reset the conversation" },
394
+ { name: "help", desc: "Show help" },
395
+ { name: "exit", desc: "Quit smolcoder" },
396
+ ];
397
+ async function runInteractive(args) {
398
+ const isWeb = !!args.web;
399
+ if (!isWeb && (!process.stdout.isTTY || !process.stdin.isTTY)) {
400
+ console.error('Interactive mode needs a terminal. For headless use, run: smol -p "your prompt" — or serve a browser UI with --web');
401
+ process.exit(1);
402
+ }
403
+ if (isWeb)
404
+ console.log(`${util_1.c.bold("tiny")}${util_1.c.dim(util_1.c.bold("coder"))} ${util_1.c.dim("v" + VERSION + " · web")}`);
405
+ else
406
+ printLogo();
407
+ process.stdout.write(util_1.c.dim("· looking for Ollama and LM Studio…"));
408
+ const models = await (0, detect_1.detectAll)();
409
+ process.stdout.write("\r\x1b[2K");
410
+ if (models.length === 0) {
411
+ console.log(noBackendsMessage());
412
+ process.exit(1);
413
+ }
414
+ const cfg = loadConfig();
415
+ let chosen = autoPickModel(models, args.model, cfg.lastModel);
416
+ process.stdout.write(util_1.c.dim(`· loading ${chosen.id}…`));
417
+ chosen = await (0, detect_1.resolveContextWindow)(chosen, args.ctx);
418
+ process.stdout.write("\r\x1b[2K");
419
+ const mode0 = args.mode ?? cfg.lastMode ?? "edit";
420
+ let effort = args.effort !== undefined ? args.effort : (cfg.effort ?? null);
421
+ const shell = (0, shell_1.pickShell)();
422
+ const bus = new events_1.EventBus();
423
+ const provider = makeProvider(chosen);
424
+ provider.setEffort(effort);
425
+ const taskManager = new tasks_1.TaskManager(args.workspace);
426
+ const toolCtx = {
427
+ workspace: args.workspace,
428
+ taskManager,
429
+ plan: new plan_1.Plan(),
430
+ filesTouched: new Set(),
431
+ commandsRun: [],
432
+ };
433
+ const ctxMgr = new context_1.ContextManager(chosen.contextWindow, provider.maxOutputTokens);
434
+ const agentsMd = (0, prompt_1.loadAgentsMd)(args.workspace);
435
+ const sysPrompt = (m) => (0, prompt_1.buildSystemPrompt)({ workspace: args.workspace, mode: m, shellLabel: shell.label, agentsMd });
436
+ // The step cap is a runaway-loop backstop, not a work limit — esc/ctrl+c is
437
+ // the user's real kill switch, so set it far above any legitimate task.
438
+ const tui = isWeb ? new webui_1.WebUI(args.webPort ?? 7433) : new tui_1.Tui();
439
+ const agent = new agent_1.Agent(provider, mode0, sysPrompt(mode0), toolCtx, ctxMgr, bus, tui, true, 1000);
440
+ const persist = () => saveConfig({ lastModel: chosen.id, lastMode: agent.mode, effort });
441
+ tui.slashCommands = SLASH_COMMANDS;
442
+ tui.hintLeft = args.workspace.replace(os.homedir(), "~");
443
+ tui.getStatus = () => {
444
+ const tasks = taskManager.runningSummary().length;
445
+ return (`${modeColored(agent.mode)} ${util_1.c.dim("·")} ${chosen.id} ${util_1.c.dim(chosen.backend)}` +
446
+ (effort || agent.provider.effortLabel()
447
+ ? ` ${util_1.c.dim("·")} ${util_1.c.yellow(agent.provider.effortLabel() ?? effort ?? "")}`
448
+ : "") +
449
+ ` ${util_1.c.dim("·")} ${util_1.c.dim(`${fmtTokens(agent.contextTokens())} (${agent.contextPercent()}%)`)}` +
450
+ (toolCtx.plan.exists
451
+ ? ` ${util_1.c.dim("·")} ${toolCtx.plan.currentIndex < 0
452
+ ? util_1.c.green(`plan ${toolCtx.plan.doneCount}/${toolCtx.plan.steps.length}`)
453
+ : util_1.c.cyan(`plan ${toolCtx.plan.doneCount}/${toolCtx.plan.steps.length}`)}`
454
+ : "") +
455
+ (tasks ? ` ${util_1.c.dim("·")} ${util_1.c.green(`${tasks} task${tasks > 1 ? "s" : ""}`)}` : ""));
456
+ };
457
+ tui.onModeCycle = () => {
458
+ const next = MODE_ORDER[(MODE_ORDER.indexOf(agent.mode) + 1) % MODE_ORDER.length];
459
+ agent.setMode(next, sysPrompt(next));
460
+ persist();
461
+ };
462
+ tui.onCancel = () => agent.cancel();
463
+ const shutdown = async () => {
464
+ await bus.emit("session_end");
465
+ taskManager.killAll();
466
+ tui.close();
467
+ process.exit(0);
468
+ };
469
+ tui.onExit = () => void shutdown();
470
+ process.on("exit", () => taskManager.killAll());
471
+ installSignalCleanup(() => {
472
+ taskManager.killAll();
473
+ try {
474
+ tui.close(); // restore the raw-mode terminal on signal death
475
+ }
476
+ catch {
477
+ /* best effort */
478
+ }
479
+ });
480
+ reportCompactions(bus, tui);
481
+ if (tui instanceof webui_1.WebUI) {
482
+ tui.getState = () => ({
483
+ mode: agent.mode,
484
+ model: chosen.id,
485
+ backend: chosen.backend,
486
+ effort: agent.provider.effortLabel() ?? effort,
487
+ ctxTokens: agent.contextTokens(),
488
+ ctxPct: agent.contextPercent(),
489
+ plan: toolCtx.plan.exists
490
+ ? { steps: toolCtx.plan.steps, current: toolCtx.plan.currentIndex }
491
+ : null,
492
+ tasks: taskManager.runningSummary().length,
493
+ workspace: args.workspace,
494
+ commands: SLASH_COMMANDS,
495
+ });
496
+ }
497
+ tui.start();
498
+ tui.println(sessionLine(chosen, agent.mode));
499
+ if (chosen.note)
500
+ tui.warn(` ${chosen.note}`);
501
+ tui.status(` workspace ${args.workspace} · shell ${shell.label}`);
502
+ if (agentsMd)
503
+ tui.status(` AGENTS.md loaded (${agentsMd.split("\n").length} lines)`);
504
+ {
505
+ const advice = effortAdvice(chosen, effort);
506
+ if (advice)
507
+ tui.warn(` ${advice}`);
508
+ }
509
+ if (!isWeb)
510
+ tui.println("");
511
+ persist();
512
+ await bus.emit("session_start");
513
+ const switchModel = async (filter) => {
514
+ const fresh = await (0, detect_1.detectAll)();
515
+ if (!fresh.length) {
516
+ tui.error("No backends reachable right now.");
517
+ return;
518
+ }
519
+ const options = fresh.map((m) => ({
520
+ label: m.id,
521
+ hint: m.backend === "ollama"
522
+ ? "ollama"
523
+ : `lm studio${m.loaded ? ` · ctx ${m.contextWindow.toLocaleString()}` : " · not loaded"}`,
524
+ current: m.id === chosen.id && m.backend === chosen.backend,
525
+ }));
526
+ const idx = await tui.select("Select model", options);
527
+ if (idx === null)
528
+ return;
529
+ tui.startSpinner(`loading ${fresh[idx].id}`);
530
+ const next = await (0, detect_1.resolveContextWindow)(fresh[idx], args.ctx);
531
+ tui.stopSpinner();
532
+ chosen = next;
533
+ const p = makeProvider(next);
534
+ p.setEffort(effort);
535
+ agent.setProvider(p);
536
+ ctxMgr.setWindow(next.contextWindow, p.maxOutputTokens);
537
+ persist();
538
+ tui.println(sessionLine(next, agent.mode));
539
+ if (next.note)
540
+ tui.warn(` ${next.note}`);
541
+ {
542
+ const advice = effortAdvice(next, effort);
543
+ if (advice)
544
+ tui.warn(` ${advice}`);
545
+ }
546
+ void filter;
547
+ };
548
+ const setMode = async (arg) => {
549
+ let next = arg === "ro"
550
+ ? "ro"
551
+ : arg === "edit" || arg === "write"
552
+ ? "edit"
553
+ : arg === "bypass" || arg === "yolo"
554
+ ? "bypass"
555
+ : undefined;
556
+ if (!next) {
557
+ const idx = await tui.select("Select mode", [
558
+ { label: "read-only", hint: "read and search files only", current: agent.mode === "ro" },
559
+ {
560
+ label: "edit",
561
+ hint: "edit files; run commands inside the workspace, ask y/n for anything outside it",
562
+ current: agent.mode === "edit",
563
+ },
564
+ {
565
+ label: "bypass permissions",
566
+ hint: "full access, never asks for approval",
567
+ current: agent.mode === "bypass",
568
+ },
569
+ ]);
570
+ if (idx === null)
571
+ return;
572
+ next = MODE_ORDER[idx];
573
+ }
574
+ agent.setMode(next, sysPrompt(next));
575
+ persist();
576
+ };
577
+ const setEffort = async (arg) => {
578
+ const levels = ["default", "off", "low", "medium", "high"];
579
+ let next;
580
+ if (arg && levels.includes(arg)) {
581
+ next = arg === "default" ? null : arg;
582
+ }
583
+ else {
584
+ const idx = await tui.select("Reasoning effort", [
585
+ {
586
+ label: "default",
587
+ hint: chosen.reasoning?.default ? `the model's own default (${chosen.reasoning.default})` : "leave it to the model",
588
+ current: effort === null,
589
+ },
590
+ { label: "off", hint: "no thinking — fastest, best for long tool loops", current: effort === "off" },
591
+ { label: "low", hint: "brief reasoning", current: effort === "low" },
592
+ { label: "medium", hint: "", current: effort === "medium" },
593
+ { label: "high", hint: "most thorough — slow on local models", current: effort === "high" },
594
+ ]);
595
+ if (idx === null)
596
+ return;
597
+ next = idx === 0 ? null : levels[idx];
598
+ }
599
+ effort = next;
600
+ agent.provider.setEffort(effort);
601
+ persist();
602
+ const label = agent.provider.effortLabel();
603
+ tui.status(`· effort ${label ?? effort ?? "default"}`);
604
+ const advice = effortAdvice(chosen, effort);
605
+ if (advice)
606
+ tui.warn(` ${advice}`);
607
+ };
608
+ for (;;) {
609
+ const input = await tui.readInput();
610
+ if (input.startsWith("/")) {
611
+ const [cmd, ...rest] = input.slice(1).split(/\s+/);
612
+ const arg = rest[0];
613
+ switch (cmd) {
614
+ case "exit":
615
+ case "quit":
616
+ case "q":
617
+ await shutdown();
618
+ return;
619
+ case "help":
620
+ tui.println(HELP);
621
+ break;
622
+ case "models":
623
+ case "model":
624
+ await switchModel(arg);
625
+ break;
626
+ case "mode":
627
+ await setMode(arg);
628
+ break;
629
+ case "effort":
630
+ await setEffort(arg);
631
+ break;
632
+ case "plan":
633
+ if (toolCtx.plan.exists)
634
+ tui.planUpdated(toolCtx.plan);
635
+ else
636
+ tui.status("· no plan yet — the agent creates one when it starts a multi-step task");
637
+ break;
638
+ case "tasks":
639
+ tui.println(taskManager.list());
640
+ break;
641
+ case "logs":
642
+ tui.println(taskManager.logs(arg ?? "", Number(rest[1]) || 50));
643
+ break;
644
+ case "stop":
645
+ tui.println(taskManager.stop(arg ?? ""));
646
+ break;
647
+ case "compact":
648
+ tui.startSpinner("compacting");
649
+ await agent.compactNow();
650
+ tui.stopSpinner();
651
+ tui.status(`· compacted — ctx now ${agent.contextPercent()}%`);
652
+ break;
653
+ case "context":
654
+ tui.status(`· ctx ${agent.contextPercent()}% of ${chosen.contextWindow.toLocaleString()} tokens · ${agent.messages.length} messages`);
655
+ break;
656
+ case "clear":
657
+ agent.resetTranscript();
658
+ toolCtx.plan.reset();
659
+ tui.status("· conversation cleared");
660
+ break;
661
+ default:
662
+ tui.warn(`Unknown command /${cmd} — try /help`);
663
+ }
664
+ continue;
665
+ }
666
+ try {
667
+ await agent.runTurn(input);
668
+ }
669
+ catch (err) {
670
+ tui.error(`\n${err?.message ?? err}`);
671
+ if (String(err?.message ?? "").toLowerCase().includes("does not support tools")) {
672
+ tui.warn("This model does not support tool calling. Pick a tool-capable model with /models (e.g. qwen3, llama3.1, mistral-nemo).");
673
+ }
674
+ }
675
+ }
676
+ }
677
+ main().catch((err) => {
678
+ console.error(err);
679
+ process.exit(1);
680
+ });