smolcoder-plus 1.0.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/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/agent.js +748 -0
- package/dist/attachments.js +158 -0
- package/dist/config.js +87 -0
- package/dist/context.js +498 -0
- package/dist/detect.js +474 -0
- package/dist/events.js +24 -0
- package/dist/history.js +9 -0
- package/dist/hosts.js +107 -0
- package/dist/index.js +391 -0
- package/dist/logo.js +48 -0
- package/dist/netscan.js +159 -0
- package/dist/network.js +193 -0
- package/dist/plan.js +102 -0
- package/dist/prompt.js +84 -0
- package/dist/providers/lmstudio.js +347 -0
- package/dist/providers/ollama.js +269 -0
- package/dist/providers/scheduler.js +57 -0
- package/dist/providers/transport.js +86 -0
- package/dist/providers/types.js +62 -0
- package/dist/sandbox.js +207 -0
- package/dist/session.js +639 -0
- package/dist/tools/check.js +193 -0
- package/dist/tools/fs-tools.js +431 -0
- package/dist/tools/index.js +260 -0
- package/dist/tools/search-worker.js +34 -0
- package/dist/tools/shell.js +186 -0
- package/dist/tools/tasks.js +147 -0
- package/dist/tools/web-search.js +155 -0
- package/dist/tui/editor.js +134 -0
- package/dist/tui/keys.js +145 -0
- package/dist/tui/tui.js +723 -0
- package/dist/ui.js +226 -0
- package/dist/util.js +91 -0
- package/dist/verification.js +71 -0
- package/dist/web/channel.js +260 -0
- package/dist/web/client.js +1010 -0
- package/dist/web/hub.js +952 -0
- package/dist/web/page.js +87 -0
- package/dist/web/store.js +199 -0
- package/dist/web/styles.js +333 -0
- package/dist/web/terminal.js +190 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// smolcoder — a smol, 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
|
+
// Web: smol --web serves a browser UI with a workspace sidebar — many
|
|
9
|
+
// projects and sessions side by side, started from anywhere.
|
|
10
|
+
// Headless: smol -p "prompt" for people and automations.
|
|
11
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
14
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
15
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
16
|
+
}
|
|
17
|
+
Object.defineProperty(o, k2, desc);
|
|
18
|
+
}) : (function(o, m, k, k2) {
|
|
19
|
+
if (k2 === undefined) k2 = k;
|
|
20
|
+
o[k2] = m[k];
|
|
21
|
+
}));
|
|
22
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
23
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
24
|
+
}) : function(o, v) {
|
|
25
|
+
o["default"] = v;
|
|
26
|
+
});
|
|
27
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
28
|
+
var ownKeys = function(o) {
|
|
29
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
30
|
+
var ar = [];
|
|
31
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
32
|
+
return ar;
|
|
33
|
+
};
|
|
34
|
+
return ownKeys(o);
|
|
35
|
+
};
|
|
36
|
+
return function (mod) {
|
|
37
|
+
if (mod && mod.__esModule) return mod;
|
|
38
|
+
var result = {};
|
|
39
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
40
|
+
__setModuleDefault(result, mod);
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
})();
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
const fs = __importStar(require("fs"));
|
|
46
|
+
const os = __importStar(require("os"));
|
|
47
|
+
const path = __importStar(require("path"));
|
|
48
|
+
const agent_1 = require("./agent");
|
|
49
|
+
const config_1 = require("./config");
|
|
50
|
+
const context_1 = require("./context");
|
|
51
|
+
const events_1 = require("./events");
|
|
52
|
+
const logo_1 = require("./logo");
|
|
53
|
+
const plan_1 = require("./plan");
|
|
54
|
+
const prompt_1 = require("./prompt");
|
|
55
|
+
const session_1 = require("./session");
|
|
56
|
+
const shell_1 = require("./tools/shell");
|
|
57
|
+
const tasks_1 = require("./tools/tasks");
|
|
58
|
+
const tui_1 = require("./tui/tui");
|
|
59
|
+
const ui_1 = require("./ui");
|
|
60
|
+
const util_1 = require("./util");
|
|
61
|
+
const hub_1 = require("./web/hub");
|
|
62
|
+
const VERSION = require("../package.json").version;
|
|
63
|
+
const DEFAULT_WEB_PORT = 7433;
|
|
64
|
+
function parseArgs(argv) {
|
|
65
|
+
const args = { workspace: process.cwd() };
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
const a = argv[i];
|
|
68
|
+
if (a === "--help" || a === "-h")
|
|
69
|
+
args.help = true;
|
|
70
|
+
else if (a === "--version" || a === "-v")
|
|
71
|
+
args.version = true;
|
|
72
|
+
else if (a === "--mode" || a === "-m") {
|
|
73
|
+
const v = argv[++i];
|
|
74
|
+
if (v === "ro" || v === "read-only" || v === "readonly")
|
|
75
|
+
args.mode = "ro";
|
|
76
|
+
else if (v === "edit" || v === "e" || v === "write" || v === "w")
|
|
77
|
+
args.mode = "edit";
|
|
78
|
+
else if (v === "bypass" || v === "bypass-permissions" || v === "b" || v === "yolo" || v === "y")
|
|
79
|
+
args.mode = "bypass";
|
|
80
|
+
else {
|
|
81
|
+
console.error(`Unknown mode "${v}". Use ro, edit, or bypass.`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else if (a === "--bypass" || a === "--bypass-permissions" || a === "--yolo")
|
|
86
|
+
args.mode = "bypass";
|
|
87
|
+
else if (a === "--model")
|
|
88
|
+
args.model = argv[++i];
|
|
89
|
+
else if (a === "--ctx") {
|
|
90
|
+
args.ctx = Number(argv[++i]);
|
|
91
|
+
if (!Number.isSafeInteger(args.ctx) || args.ctx < 1024) {
|
|
92
|
+
console.error("--ctx must be a whole number of at least 1024 tokens.");
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else if (a === "--effort") {
|
|
97
|
+
const v = argv[++i];
|
|
98
|
+
if (v === "off" || v === "low" || v === "medium" || v === "high")
|
|
99
|
+
args.effort = v;
|
|
100
|
+
else if (v === "default")
|
|
101
|
+
args.effort = null;
|
|
102
|
+
else {
|
|
103
|
+
console.error(`Unknown effort "${v}". Use off, low, medium, high, or default.`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (a === "--verify") {
|
|
108
|
+
args.verify = argv[++i];
|
|
109
|
+
if (!args.verify?.trim())
|
|
110
|
+
throw new Error('--verify needs an acceptance command');
|
|
111
|
+
}
|
|
112
|
+
else if (a === "--verify-attempts") {
|
|
113
|
+
args.verifyAttempts = Number(argv[++i]);
|
|
114
|
+
if (!Number.isSafeInteger(args.verifyAttempts) || args.verifyAttempts < 1)
|
|
115
|
+
throw new Error('--verify-attempts must be a positive whole number');
|
|
116
|
+
}
|
|
117
|
+
else if (a === "--print" || a === "-p")
|
|
118
|
+
args.print = argv[++i];
|
|
119
|
+
else if (a === "--web") {
|
|
120
|
+
args.web = true;
|
|
121
|
+
if (argv[i + 1] && /^\d+$/.test(argv[i + 1]))
|
|
122
|
+
args.webPort = Number(argv[++i]);
|
|
123
|
+
}
|
|
124
|
+
else if (!a.startsWith("-")) {
|
|
125
|
+
args.workspace = path.resolve(a);
|
|
126
|
+
args.workspaceGiven = true;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
console.error(`Unknown option "${a}". Try smolcoder-plus --help.`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return args;
|
|
134
|
+
}
|
|
135
|
+
const HELP = `
|
|
136
|
+
${util_1.c.bold("smolcoder-plus")} v${VERSION} — a smol, zero-config coding agent for local models.
|
|
137
|
+
|
|
138
|
+
Detects Ollama and LM Studio on this computer automatically — any port, Docker
|
|
139
|
+
containers included. Models on other machines: /models → "Find models on
|
|
140
|
+
another machine" searches your network or takes an address, and remembers it.
|
|
141
|
+
|
|
142
|
+
${util_1.c.bold("Usage:")}
|
|
143
|
+
smolcoder-plus [workspace] [options]
|
|
144
|
+
|
|
145
|
+
${util_1.c.bold("Options:")}
|
|
146
|
+
-m, --mode <ro|edit|bypass> ro: read files only. edit: read/write files and run
|
|
147
|
+
commands inside the workspace; anything reaching
|
|
148
|
+
outside it asks y/n. bypass: no approvals at all.
|
|
149
|
+
--model <name> pick a model by (partial) name
|
|
150
|
+
--ctx <tokens> force a context window (Ollama: sends num_ctx)
|
|
151
|
+
--verify <command> headless acceptance gate; automatically repair failures
|
|
152
|
+
--verify-attempts <count> maximum acceptance checks (default 6; requires --verify)
|
|
153
|
+
--effort <level> reasoning effort: off, low, medium, high, default
|
|
154
|
+
--web [port] browser UI (default port ${DEFAULT_WEB_PORT}): a sidebar of your
|
|
155
|
+
workspaces and sessions, an embedded browser and
|
|
156
|
+
terminal panel. Run it from anywhere; a second
|
|
157
|
+
smolcoder-plus --web adds its folder to the running UI.
|
|
158
|
+
-p, --print "<prompt>" headless: run a single prompt and exit
|
|
159
|
+
-h, --help this help
|
|
160
|
+
-v, --version version
|
|
161
|
+
|
|
162
|
+
${util_1.c.bold("Keys:")}
|
|
163
|
+
shift+tab cycle mode (read-only → edit → bypass permissions)
|
|
164
|
+
/ slash commands (autocomplete menu)
|
|
165
|
+
esc cancel a running turn · clear the input
|
|
166
|
+
ctrl+c ×2 quit
|
|
167
|
+
|
|
168
|
+
${util_1.c.bold("Slash commands:")}
|
|
169
|
+
/models switch model /tasks background tasks
|
|
170
|
+
/mode set mode /logs <id> task output
|
|
171
|
+
/effort reasoning effort /stop <id> kill a task
|
|
172
|
+
/context context usage /clear reset conversation
|
|
173
|
+
/compact compact now /exit quit
|
|
174
|
+
`;
|
|
175
|
+
/** Node fires 'exit' on normal termination but NOT on a killing signal, so
|
|
176
|
+
* background tasks (dev servers) survive a closed terminal (SIGHUP) or `kill`
|
|
177
|
+
* (SIGTERM) unless we handle those explicitly. Runs synchronous cleanup then
|
|
178
|
+
* re-exits so the 'exit' path is still reached. */
|
|
179
|
+
function installSignalCleanup(cleanup) {
|
|
180
|
+
let done = false;
|
|
181
|
+
const run = (code) => {
|
|
182
|
+
if (done)
|
|
183
|
+
return;
|
|
184
|
+
done = true;
|
|
185
|
+
try {
|
|
186
|
+
cleanup();
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
/* best effort */
|
|
190
|
+
}
|
|
191
|
+
process.exit(code);
|
|
192
|
+
};
|
|
193
|
+
process.on("SIGTERM", () => run(143));
|
|
194
|
+
process.on("SIGHUP", () => run(129));
|
|
195
|
+
process.on("SIGINT", () => run(130));
|
|
196
|
+
}
|
|
197
|
+
/** The SMOL banner that opens every interactive session. */
|
|
198
|
+
function printLogo() {
|
|
199
|
+
for (const line of (0, logo_1.terminalLogo)(process.stdout.columns || 80, VERSION))
|
|
200
|
+
console.log(line);
|
|
201
|
+
}
|
|
202
|
+
function prefsOf(args) {
|
|
203
|
+
return { mode: args.mode, model: args.model, ctx: args.ctx, effort: args.effort };
|
|
204
|
+
}
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
async function main() {
|
|
207
|
+
const args = parseArgs(process.argv.slice(2));
|
|
208
|
+
if (args.help) {
|
|
209
|
+
console.log(HELP);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (args.version) {
|
|
213
|
+
console.log(VERSION);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (args.verify && (!args.print || args.web))
|
|
217
|
+
throw new Error('--verify requires a headless -p run');
|
|
218
|
+
if (args.verifyAttempts !== undefined && !args.verify)
|
|
219
|
+
throw new Error('--verify-attempts requires --verify');
|
|
220
|
+
if (!fs.existsSync(args.workspace) || !fs.statSync(args.workspace).isDirectory()) {
|
|
221
|
+
console.error(`Workspace folder does not exist: ${args.workspace}`);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
if (args.print !== undefined)
|
|
225
|
+
await runHeadless(args);
|
|
226
|
+
else if (args.web)
|
|
227
|
+
await runWeb(args);
|
|
228
|
+
else
|
|
229
|
+
await runInteractive(args);
|
|
230
|
+
}
|
|
231
|
+
// ---- headless (-p) ---------------------------------------------------------
|
|
232
|
+
async function runHeadless(args) {
|
|
233
|
+
const ui = new ui_1.UI();
|
|
234
|
+
const bus = new events_1.EventBus();
|
|
235
|
+
const cfg = (0, config_1.loadConfig)();
|
|
236
|
+
const chosen = await (0, session_1.prepareModel)(prefsOf(args), cfg);
|
|
237
|
+
if (!chosen) {
|
|
238
|
+
ui.println((0, session_1.noBackendsMessage)());
|
|
239
|
+
ui.close();
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
const mode = args.mode ?? cfg.lastMode ?? "edit";
|
|
243
|
+
const shell = (0, shell_1.pickShell)();
|
|
244
|
+
const provider = (0, session_1.makeProvider)(chosen);
|
|
245
|
+
provider.setEffort(args.effort !== undefined ? args.effort : (cfg.effort ?? null));
|
|
246
|
+
const taskManager = new tasks_1.TaskManager(args.workspace);
|
|
247
|
+
const toolCtx = {
|
|
248
|
+
workspace: args.workspace,
|
|
249
|
+
taskManager,
|
|
250
|
+
plan: new plan_1.Plan(),
|
|
251
|
+
filesTouched: new Set(),
|
|
252
|
+
commandsRun: [],
|
|
253
|
+
};
|
|
254
|
+
const ctxMgr = new context_1.ContextManager(chosen.contextWindow, provider.maxOutputTokens);
|
|
255
|
+
const agentsMd = (0, prompt_1.loadAgentsMd)(args.workspace);
|
|
256
|
+
if (agentsMd)
|
|
257
|
+
ui.status(`· AGENTS.md loaded (${agentsMd.split("\n").length} lines)`);
|
|
258
|
+
const systemPrompt = (0, prompt_1.buildSystemPrompt)({ workspace: args.workspace, mode, shellLabel: shell.label, agentsMd });
|
|
259
|
+
const agent = new agent_1.Agent(provider, mode, systemPrompt, toolCtx, ctxMgr, bus, ui, false, 1000, args.verify ? { command: args.verify, maxAttempts: args.verifyAttempts } : undefined);
|
|
260
|
+
(0, session_1.reportCompactions)(bus, ui);
|
|
261
|
+
process.on("exit", () => taskManager.killAll());
|
|
262
|
+
installSignalCleanup(() => taskManager.killAll());
|
|
263
|
+
ui.println((0, session_1.sessionLine)(chosen, mode));
|
|
264
|
+
if (chosen.note)
|
|
265
|
+
ui.warn(` ${chosen.note}`);
|
|
266
|
+
const effortSetting = args.effort !== undefined ? args.effort : (cfg.effort ?? null);
|
|
267
|
+
ui.status(` effort ${provider.effortLabel() ?? effortSetting ?? "default"}`);
|
|
268
|
+
const advice = (0, session_1.effortAdvice)(chosen, effortSetting);
|
|
269
|
+
if (advice)
|
|
270
|
+
ui.warn(` ${advice}`);
|
|
271
|
+
try {
|
|
272
|
+
await agent.runTurn(args.print);
|
|
273
|
+
if (agent.outcome !== "completed")
|
|
274
|
+
process.exitCode = 1;
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
ui.error(`\n${err?.message ?? err}`);
|
|
278
|
+
process.exitCode = 1;
|
|
279
|
+
}
|
|
280
|
+
const st = agent.lastTurnStats;
|
|
281
|
+
if (st) {
|
|
282
|
+
// Machine-readable summary for scripts/benchmarks comparing backends.
|
|
283
|
+
process.stderr.write(`[stats] ${JSON.stringify({
|
|
284
|
+
backend: chosen.backend,
|
|
285
|
+
outcome: agent.outcome,
|
|
286
|
+
verification: agent.verificationResult ? { attempts: agent.verificationResult.attempts, passed: agent.verificationResult.passed } : null,
|
|
287
|
+
model: chosen.id,
|
|
288
|
+
durationMs: st.durationMs,
|
|
289
|
+
modelCalls: st.modelCalls,
|
|
290
|
+
toolCalls: st.toolCalls,
|
|
291
|
+
generatedTokens: st.generatedTokens,
|
|
292
|
+
thinkingTokensEst: Math.round(st.thinkingChars / 4),
|
|
293
|
+
genTokPerSec: st.genSeconds > 0 ? Math.round(st.generatedTokens / st.genSeconds) : null,
|
|
294
|
+
promptTokensLast: st.promptTokensLast,
|
|
295
|
+
contextWindow: chosen.contextWindow,
|
|
296
|
+
planDone: toolCtx.plan.exists ? `${toolCtx.plan.doneCount}/${toolCtx.plan.steps.length}` : null,
|
|
297
|
+
})}\n`);
|
|
298
|
+
}
|
|
299
|
+
taskManager.killAll();
|
|
300
|
+
ui.close();
|
|
301
|
+
}
|
|
302
|
+
// ---- interactive TUI -------------------------------------------------------
|
|
303
|
+
async function runInteractive(args) {
|
|
304
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
305
|
+
console.error('Interactive mode needs a terminal. For headless use, run: smolcoder-plus -p "your prompt" — or serve a browser UI with --web');
|
|
306
|
+
process.exit(1);
|
|
307
|
+
}
|
|
308
|
+
printLogo();
|
|
309
|
+
const cfg = (0, config_1.loadConfig)();
|
|
310
|
+
process.stdout.write(util_1.c.dim("· looking for Ollama and LM Studio…"));
|
|
311
|
+
let chosen = await (0, session_1.prepareModel)(prefsOf(args), cfg, (label) => {
|
|
312
|
+
process.stdout.write("\r\x1b[2K" + util_1.c.dim(`· ${label}…`));
|
|
313
|
+
});
|
|
314
|
+
process.stdout.write("\r\x1b[2K");
|
|
315
|
+
const tui = new tui_1.Tui();
|
|
316
|
+
if (!chosen) {
|
|
317
|
+
// Nothing on this computer: open the TUI early and offer to look on the
|
|
318
|
+
// network. Until a session exists, esc/ctrl+c while it searches quits.
|
|
319
|
+
tui.onCancel = () => {
|
|
320
|
+
tui.close();
|
|
321
|
+
process.exit(130);
|
|
322
|
+
};
|
|
323
|
+
tui.start();
|
|
324
|
+
chosen = await (0, session_1.setupWithoutLocalModels)(tui, prefsOf(args));
|
|
325
|
+
if (!chosen) {
|
|
326
|
+
tui.close();
|
|
327
|
+
console.log((0, session_1.noBackendsMessage)());
|
|
328
|
+
process.exit(1);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const session = new session_1.Session(tui, { workspace: args.workspace, chosen, prefs: prefsOf(args), cfg, help: HELP });
|
|
332
|
+
session.onExit = () => process.exit(0);
|
|
333
|
+
process.on("exit", () => session.taskManager.killAll());
|
|
334
|
+
installSignalCleanup(() => {
|
|
335
|
+
session.taskManager.killAll();
|
|
336
|
+
try {
|
|
337
|
+
tui.close(); // restore the raw-mode terminal on signal death
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
/* best effort */
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
tui.start();
|
|
344
|
+
session.announce();
|
|
345
|
+
tui.println("");
|
|
346
|
+
await session.run();
|
|
347
|
+
}
|
|
348
|
+
// ---- web hub (--web) -------------------------------------------------------
|
|
349
|
+
/** The home folder or a drive root is not a project: launching there opens
|
|
350
|
+
* the hub with the sidebar and lets the user pick a workspace. */
|
|
351
|
+
function isHomeOrRoot(p) {
|
|
352
|
+
const norm = (s) => (process.platform === "win32" ? s.toLowerCase() : s);
|
|
353
|
+
const r = path.resolve(p);
|
|
354
|
+
return norm(r) === norm(os.homedir()) || path.dirname(r) === r;
|
|
355
|
+
}
|
|
356
|
+
async function runWeb(args) {
|
|
357
|
+
console.log(`${util_1.c.bold("smolcoder-plus")} ${util_1.c.dim("v" + VERSION + " · web")}`);
|
|
358
|
+
const port = args.webPort ?? DEFAULT_WEB_PORT;
|
|
359
|
+
const workspace = args.workspace;
|
|
360
|
+
const autoStart = !!args.workspaceGiven || !isHomeOrRoot(workspace);
|
|
361
|
+
// A hub is already running: hand it this folder instead of starting another.
|
|
362
|
+
const rec = (0, hub_1.readHubRecord)();
|
|
363
|
+
if (rec && (args.webPort === undefined || rec.port === port) && (await (0, hub_1.pingHub)(rec))) {
|
|
364
|
+
const r = await (0, hub_1.askHubToOpen)(rec, workspace, autoStart);
|
|
365
|
+
if (r) {
|
|
366
|
+
const url = `http://127.0.0.1:${rec.port}/?k=${rec.token}${r.id ? "#" + r.id : ""}`;
|
|
367
|
+
console.log(`\n ${autoStart ? "started a session for" : "added"} ${workspace} in the running web UI:\n ${url}\n`);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const hub = new hub_1.WebHub({ port, prefs: prefsOf(args), help: HELP, version: VERSION });
|
|
372
|
+
try {
|
|
373
|
+
await hub.start();
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
if (err?.code === "EADDRINUSE") {
|
|
377
|
+
console.error(`\nPort ${port} is already in use. Pick another with: smolcoder-plus --web ${port + 1}`);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
throw err;
|
|
381
|
+
}
|
|
382
|
+
process.on("exit", () => hub.shutdownSync());
|
|
383
|
+
installSignalCleanup(() => hub.shutdownSync());
|
|
384
|
+
if (autoStart)
|
|
385
|
+
hub.openSession(workspace);
|
|
386
|
+
console.log(`\n smolcoder web UI: ${hub.url()}\n ${autoStart ? `workspace ${workspace}` : "pick a workspace in the sidebar"} · ctrl+c stops the server\n`);
|
|
387
|
+
}
|
|
388
|
+
main().catch((err) => {
|
|
389
|
+
console.error(err);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
});
|
package/dist/logo.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// The SMOL block-letter logo. One source for both surfaces — the banner
|
|
3
|
+
// printed when an interactive terminal session starts, and the web UI's
|
|
4
|
+
// welcome screen and fresh-session view — so the branding cannot drift or
|
|
5
|
+
// quietly disappear from one of them again.
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.LOGO_TEXT = exports.LOGO_WIDTH = exports.LOGO_ROWS = void 0;
|
|
8
|
+
exports.plainBrand = plainBrand;
|
|
9
|
+
exports.terminalLogo = terminalLogo;
|
|
10
|
+
const util_1 = require("./util");
|
|
11
|
+
exports.LOGO_ROWS = [
|
|
12
|
+
"███████╗ ███╗ ███╗ ██████╗ ██╗ ",
|
|
13
|
+
"██╔════╝ ████╗ ████║ ██╔═══██╗ ██║ ",
|
|
14
|
+
"███████╗ ██╔████╔██║ ██║ ██║ ██║ ",
|
|
15
|
+
"╚════██║ ██║╚██╔╝██║ ██║ ██║ ██║ ",
|
|
16
|
+
"███████║ ██║ ╚═╝ ██║ ╚██████╔╝ ███████╗",
|
|
17
|
+
"╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝",
|
|
18
|
+
];
|
|
19
|
+
/** Every row is padded to this many cells. */
|
|
20
|
+
exports.LOGO_WIDTH = exports.LOGO_ROWS[0].length;
|
|
21
|
+
/** The logo as one block of text (trailing padding removed) for the web page. */
|
|
22
|
+
exports.LOGO_TEXT = exports.LOGO_ROWS.map((r) => r.trimEnd()).join("\n");
|
|
23
|
+
/** One-line fallback for wherever the art does not fit. */
|
|
24
|
+
function plainBrand(version) {
|
|
25
|
+
return `${util_1.c.bold("smol")}${util_1.c.dim(util_1.c.bold("coder"))} ${util_1.c.dim("v" + version)}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Lines of the terminal banner for a terminal `cols` wide: the block logo in
|
|
29
|
+
* the accent colour with "coder vX" beside its last row, or the plain
|
|
30
|
+
* one-liner when the terminal is too narrow for the art.
|
|
31
|
+
*/
|
|
32
|
+
function terminalLogo(cols, version) {
|
|
33
|
+
const indent = " ";
|
|
34
|
+
if (cols < indent.length + exports.LOGO_WIDTH)
|
|
35
|
+
return [plainBrand(version)];
|
|
36
|
+
const tailText = `coder v${version}`;
|
|
37
|
+
const tail = util_1.c.dim(util_1.c.bold("coder") + " v" + version);
|
|
38
|
+
const tailFits = cols >= indent.length + exports.LOGO_WIDTH + 2 + tailText.length;
|
|
39
|
+
const lines = [""];
|
|
40
|
+
exports.LOGO_ROWS.forEach((row, i) => {
|
|
41
|
+
const last = i === exports.LOGO_ROWS.length - 1;
|
|
42
|
+
lines.push(indent + util_1.c.cyan(row) + (last && tailFits ? " " + tail : ""));
|
|
43
|
+
});
|
|
44
|
+
if (!tailFits)
|
|
45
|
+
lines.push(indent + tail);
|
|
46
|
+
lines.push("");
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
package/dist/netscan.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// "Find models on my network": an ordinary TCP connect sweep of the local
|
|
3
|
+
// subnet on the two model-server ports, then a question to each open port
|
|
4
|
+
// about what it is. No ping, no raw sockets, no admin rights — the same on
|
|
5
|
+
// Windows, macOS and Linux. Only ever run when the user asks for it.
|
|
6
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
7
|
+
if (k2 === undefined) k2 = k;
|
|
8
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
9
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
10
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
11
|
+
}
|
|
12
|
+
Object.defineProperty(o, k2, desc);
|
|
13
|
+
}) : (function(o, m, k, k2) {
|
|
14
|
+
if (k2 === undefined) k2 = k;
|
|
15
|
+
o[k2] = m[k];
|
|
16
|
+
}));
|
|
17
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
18
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
19
|
+
}) : function(o, v) {
|
|
20
|
+
o["default"] = v;
|
|
21
|
+
});
|
|
22
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
23
|
+
var ownKeys = function(o) {
|
|
24
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
25
|
+
var ar = [];
|
|
26
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
27
|
+
return ar;
|
|
28
|
+
};
|
|
29
|
+
return ownKeys(o);
|
|
30
|
+
};
|
|
31
|
+
return function (mod) {
|
|
32
|
+
if (mod && mod.__esModule) return mod;
|
|
33
|
+
var result = {};
|
|
34
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
35
|
+
__setModuleDefault(result, mod);
|
|
36
|
+
return result;
|
|
37
|
+
};
|
|
38
|
+
})();
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.localSubnets = localSubnets;
|
|
41
|
+
exports.scanSubnets = scanSubnets;
|
|
42
|
+
const dns = __importStar(require("dns"));
|
|
43
|
+
const net = __importStar(require("net"));
|
|
44
|
+
const os = __importStar(require("os"));
|
|
45
|
+
const detect_1 = require("./detect");
|
|
46
|
+
const hosts_1 = require("./hosts");
|
|
47
|
+
const CONNECT_TIMEOUT_MS = 350;
|
|
48
|
+
// Well under macOS's default limit of 256 open files per process.
|
|
49
|
+
const CONCURRENCY = 64;
|
|
50
|
+
// Wider networks are searched around our own address only.
|
|
51
|
+
const MIN_PREFIX = 22;
|
|
52
|
+
const CLAMPED_PREFIX = 24;
|
|
53
|
+
// Virtual switches that never lead to another machine. Deliberately short:
|
|
54
|
+
// a Hyper-V "external" switch IS the real network, so names are not a general filter.
|
|
55
|
+
const VIRTUAL_IFACE = /^(vEthernet \((WSL|Default Switch)|docker\d*$|br-[0-9a-f]+$|veth[0-9a-f]+$|virbr\d*$)/i;
|
|
56
|
+
const toInt = (ip) => ip.split(".").reduce((n, part) => n * 256 + Number(part), 0);
|
|
57
|
+
const toIp = (n) => [24, 16, 8, 0].map((shift) => Math.floor(n / 2 ** shift) % 256).join(".");
|
|
58
|
+
function isPrivateV4(ip) {
|
|
59
|
+
const [a, b] = ip.split(".").map(Number);
|
|
60
|
+
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
61
|
+
}
|
|
62
|
+
/** The private IPv4 networks this computer is on. Chosen by address, not by
|
|
63
|
+
* adapter name, so it behaves the same on every OS. */
|
|
64
|
+
function localSubnets(ifaces = os.networkInterfaces()) {
|
|
65
|
+
const own = new Set();
|
|
66
|
+
for (const list of Object.values(ifaces))
|
|
67
|
+
for (const i of list ?? [])
|
|
68
|
+
own.add(i.address);
|
|
69
|
+
const subnets = new Map();
|
|
70
|
+
for (const [name, list] of Object.entries(ifaces)) {
|
|
71
|
+
if (VIRTUAL_IFACE.test(name))
|
|
72
|
+
continue;
|
|
73
|
+
for (const i of list ?? []) {
|
|
74
|
+
const v4 = i.family === "IPv4" || i.family === 4;
|
|
75
|
+
if (!v4 || i.internal || !isPrivateV4(i.address))
|
|
76
|
+
continue;
|
|
77
|
+
const mask = toInt(i.netmask);
|
|
78
|
+
let prefix = 0;
|
|
79
|
+
for (let bit = 31; bit >= 0 && Math.floor(mask / 2 ** bit) % 2 === 1; bit--)
|
|
80
|
+
prefix++;
|
|
81
|
+
if (prefix >= 31)
|
|
82
|
+
continue; // point-to-point link, nobody else on it
|
|
83
|
+
if (prefix < MIN_PREFIX)
|
|
84
|
+
prefix = CLAMPED_PREFIX;
|
|
85
|
+
const size = 2 ** (32 - prefix);
|
|
86
|
+
const network = Math.floor(toInt(i.address) / size) * size;
|
|
87
|
+
const cidr = `${toIp(network)}/${prefix}`;
|
|
88
|
+
if (subnets.has(cidr))
|
|
89
|
+
continue;
|
|
90
|
+
const addresses = [];
|
|
91
|
+
for (let n = network + 1; n < network + size - 1; n++)
|
|
92
|
+
if (!own.has(toIp(n)))
|
|
93
|
+
addresses.push(toIp(n));
|
|
94
|
+
subnets.set(cidr, { cidr, addresses });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [...subnets.values()];
|
|
98
|
+
}
|
|
99
|
+
function canConnect(ip, port, timeoutMs) {
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
const socket = net.connect({ host: ip, port });
|
|
102
|
+
const finish = (open) => {
|
|
103
|
+
socket.destroy();
|
|
104
|
+
resolve(open);
|
|
105
|
+
};
|
|
106
|
+
socket.setTimeout(timeoutMs, () => finish(false));
|
|
107
|
+
socket.once("connect", () => finish(true));
|
|
108
|
+
socket.once("error", () => finish(false));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/** A name for the machine, but only one that leads back to the same address —
|
|
112
|
+
* that is what makes it safe to save instead of an IP the router may reassign. */
|
|
113
|
+
function stableName(ip) {
|
|
114
|
+
const lookup = async () => {
|
|
115
|
+
try {
|
|
116
|
+
for (const name of await dns.promises.reverse(ip)) {
|
|
117
|
+
const back = await dns.promises.lookup(name, { family: 4 }).catch(() => null);
|
|
118
|
+
if (back?.address === ip)
|
|
119
|
+
return name.replace(/\.$/, "");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
/* no reverse DNS on this network */
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
};
|
|
127
|
+
// A resolver that never answers must not stall the results; the IP will do.
|
|
128
|
+
return Promise.race([lookup(), new Promise((resolve) => setTimeout(() => resolve(undefined), 1500).unref())]);
|
|
129
|
+
}
|
|
130
|
+
/** Sweep the subnets and report every machine running Ollama or LM Studio. */
|
|
131
|
+
async function scanSubnets(subnets, opts = {}) {
|
|
132
|
+
const ports = opts.ports ?? [hosts_1.OLLAMA_PORT, hosts_1.LMSTUDIO_PORT];
|
|
133
|
+
const timeout = opts.connectTimeoutMs ?? CONNECT_TIMEOUT_MS;
|
|
134
|
+
const targets = subnets.flatMap((s) => s.addresses).flatMap((ip) => ports.map((port) => ({ ip, port })));
|
|
135
|
+
const open = [];
|
|
136
|
+
let next = 0;
|
|
137
|
+
let checked = 0;
|
|
138
|
+
const worker = async () => {
|
|
139
|
+
while (next < targets.length) {
|
|
140
|
+
const target = targets[next++];
|
|
141
|
+
if (await canConnect(target.ip, target.port, timeout))
|
|
142
|
+
open.push(target);
|
|
143
|
+
opts.onProgress?.(++checked, targets.length);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, targets.length) }, worker));
|
|
147
|
+
const byIp = new Map();
|
|
148
|
+
await Promise.all(open.map(async ({ ip, port }) => {
|
|
149
|
+
const info = await (0, detect_1.identifyServer)(`http://${ip}:${port}`);
|
|
150
|
+
if (!info)
|
|
151
|
+
return;
|
|
152
|
+
const host = byIp.get(ip) ?? { ip, servers: [] };
|
|
153
|
+
host.servers.push({ url: info.baseUrl, backend: info.backend, models: info.models.length });
|
|
154
|
+
byIp.set(ip, host);
|
|
155
|
+
}));
|
|
156
|
+
const hosts = [...byIp.values()].sort((a, b) => toInt(a.ip) - toInt(b.ip));
|
|
157
|
+
await Promise.all(hosts.map(async (h) => (h.name = await stableName(h.ip))));
|
|
158
|
+
return hosts;
|
|
159
|
+
}
|