aether-code 0.29.0 → 0.31.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/bin/aether-code.js +12 -2
- package/package.json +1 -1
- package/src/agent.js +2 -0
- package/src/api.js +4 -1
- package/src/repl.js +37 -6
- package/src/skills.js +6 -0
- package/src/tools.js +56 -3
package/bin/aether-code.js
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
import readline from "node:readline";
|
|
29
29
|
import { c, errorLine, divider, setTerminalTitle } from "../src/render.js";
|
|
30
30
|
|
|
31
|
-
const VERSION = "0.
|
|
31
|
+
const VERSION = "0.31.0";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Try to start MCP servers from ~/.aether/mcp.json. Returns a started
|
|
@@ -90,6 +90,8 @@ ${c.bold("FLAGS")}
|
|
|
90
90
|
Default is auto-approve (skip-permissions).
|
|
91
91
|
--sandbox Restrict file access to --cwd. Default is full access.
|
|
92
92
|
--cwd <path> Working directory for the agent (default: current dir).
|
|
93
|
+
--model <name> core (default) | ultra (Opus) | max (Grok). Premium models
|
|
94
|
+
need purchased credits, else they fall back to Core.
|
|
93
95
|
--max-turns <n> Maximum turns before stopping (default: 25).
|
|
94
96
|
--help, -h Show this help.
|
|
95
97
|
--version, -v Print version.
|
|
@@ -117,6 +119,7 @@ function parseArgs(argv) {
|
|
|
117
119
|
else if (a === "--help" || a === "-h") { args.flags.help = true; }
|
|
118
120
|
else if (a === "--version" || a === "-v") { args.flags.version = true; }
|
|
119
121
|
else if (a === "--cwd") { args.flags.cwd = argv[++i]; }
|
|
122
|
+
else if (a === "--model") { args.flags.model = argv[++i]; }
|
|
120
123
|
else if (a === "--max-turns") { args.flags.maxTurns = parseInt(argv[++i], 10); }
|
|
121
124
|
else if (a.startsWith("--")) {
|
|
122
125
|
const eq = a.indexOf("=");
|
|
@@ -190,6 +193,12 @@ async function main() {
|
|
|
190
193
|
const unsafePaths = !args.flags.sandbox;
|
|
191
194
|
const maxTurns = Number.isInteger(args.flags.maxTurns) ? args.flags.maxTurns : 25;
|
|
192
195
|
|
|
196
|
+
// --model: core (default), ultra (Opus), max (Grok). Premium models are gated
|
|
197
|
+
// server-side — without purchased credits the server uses Core regardless.
|
|
198
|
+
const MODEL_IDS = { core: null, gemma: null, ultra: "claude-opus-4-6", opus: "claude-opus-4-6", max: "grok-4-3", grok: "grok-4-3" };
|
|
199
|
+
const modelKey = args.flags.model ? String(args.flags.model).toLowerCase() : null;
|
|
200
|
+
const model = modelKey && modelKey in MODEL_IDS ? MODEL_IDS[modelKey] : (args.flags.model || null);
|
|
201
|
+
|
|
193
202
|
// Subcommand routing — these shadow the "task as positional arg" mode
|
|
194
203
|
const sub = args._[0]?.toLowerCase();
|
|
195
204
|
if (sub === "login" || sub === "auth") {
|
|
@@ -224,7 +233,7 @@ async function main() {
|
|
|
224
233
|
console.log(c.gray(`Note: launched from a system folder (${redirectedFrom}).`));
|
|
225
234
|
console.log(c.gray(`Your files will be saved to ${c.cyan(cwd)} so you can find them. Use ${c.cyan("--cwd <path>")} or ${c.cyan("/cwd")} to change.`));
|
|
226
235
|
}
|
|
227
|
-
await runRepl({ cwd, autoYes, unsafePaths, maxTurns, mcpManager });
|
|
236
|
+
await runRepl({ cwd, autoYes, unsafePaths, maxTurns, model, mcpManager });
|
|
228
237
|
return;
|
|
229
238
|
}
|
|
230
239
|
|
|
@@ -249,6 +258,7 @@ async function main() {
|
|
|
249
258
|
autoYes,
|
|
250
259
|
unsafePaths,
|
|
251
260
|
maxTurns,
|
|
261
|
+
model,
|
|
252
262
|
mcpManager,
|
|
253
263
|
});
|
|
254
264
|
if (mcpManager) await mcpManager.shutdown().catch(() => {});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aether-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Uncensored AI coding agent for your terminal — Claude Code alternative with MCP support. Reads code, writes files, runs commands. Drives IDA Pro, Roblox Studio, Wireshark, Blender, and any MCP server. No refusal layer.",
|
|
5
5
|
"homepage": "https://trynoguard.com",
|
|
6
6
|
"repository": {
|
package/src/agent.js
CHANGED
|
@@ -51,6 +51,7 @@ export async function runAgent({
|
|
|
51
51
|
autoYes = false,
|
|
52
52
|
unsafePaths = false,
|
|
53
53
|
maxTurns = DEFAULT_MAX_TURNS,
|
|
54
|
+
model = null, // null = server default (gemma). Premium models gated server-side.
|
|
54
55
|
onTokens = () => {},
|
|
55
56
|
// Optional MCPManager. When provided, its tools are merged into the agent's
|
|
56
57
|
// toolset and tool calls prefixed `mcp__` are routed to it instead of the
|
|
@@ -135,6 +136,7 @@ export async function runAgent({
|
|
|
135
136
|
res = await agentTurnStream({
|
|
136
137
|
messages: turnMessages,
|
|
137
138
|
tools,
|
|
139
|
+
model,
|
|
138
140
|
onDelta: (text) => {
|
|
139
141
|
// Buffered strip of leaked model channel/control tokens (which can
|
|
140
142
|
// be split across stream chunks) before display.
|
package/src/api.js
CHANGED
|
@@ -122,6 +122,7 @@ export async function agentTurnStream({
|
|
|
122
122
|
tools,
|
|
123
123
|
maxTokens,
|
|
124
124
|
temperature,
|
|
125
|
+
model,
|
|
125
126
|
onDelta,
|
|
126
127
|
onToolCallDelta,
|
|
127
128
|
onFinish,
|
|
@@ -150,6 +151,7 @@ export async function agentTurnStream({
|
|
|
150
151
|
tools,
|
|
151
152
|
max_tokens: maxTokens,
|
|
152
153
|
temperature,
|
|
154
|
+
...(model ? { model } : {}),
|
|
153
155
|
}),
|
|
154
156
|
}, { onRetry: defaultOnRetry });
|
|
155
157
|
} catch (e) {
|
|
@@ -274,7 +276,7 @@ export async function agentTurnStream({
|
|
|
274
276
|
* problematic (corporate proxies, weird client setups). The CLI defaults to
|
|
275
277
|
* streaming.
|
|
276
278
|
*/
|
|
277
|
-
export async function agentTurn({ messages, tools, maxTokens, temperature }) {
|
|
279
|
+
export async function agentTurn({ messages, tools, maxTokens, temperature, model }) {
|
|
278
280
|
const { apiKey, baseUrl } = getConfig();
|
|
279
281
|
if (!apiKey) {
|
|
280
282
|
throw new AetherError(
|
|
@@ -298,6 +300,7 @@ export async function agentTurn({ messages, tools, maxTokens, temperature }) {
|
|
|
298
300
|
tools,
|
|
299
301
|
max_tokens: maxTokens,
|
|
300
302
|
temperature,
|
|
303
|
+
...(model ? { model } : {}),
|
|
301
304
|
}),
|
|
302
305
|
}, { onRetry: defaultOnRetry });
|
|
303
306
|
} catch (e) {
|
package/src/repl.js
CHANGED
|
@@ -16,9 +16,21 @@ import { c, errorLine, boxLines, sideBySide } from "./render.js";
|
|
|
16
16
|
import { checkForUpdate } from "./update-check.js";
|
|
17
17
|
import { promptBox, EXIT_SIGNAL } from "./box-input.js";
|
|
18
18
|
|
|
19
|
-
const VERSION = "0.
|
|
19
|
+
const VERSION = "0.31.0";
|
|
20
20
|
const MODEL_NAME = "Aether Core";
|
|
21
21
|
|
|
22
|
+
// /model aliases → { id, name }. id=null = server default (gemma / Aether Core).
|
|
23
|
+
// Premium models are gated SERVER-SIDE: if you haven't purchased credits, the
|
|
24
|
+
// server transparently uses Core instead — selecting one here can't bypass that.
|
|
25
|
+
const MODEL_ALIASES = {
|
|
26
|
+
core: { id: null, name: "Aether Core" },
|
|
27
|
+
gemma: { id: null, name: "Aether Core" },
|
|
28
|
+
ultra: { id: "claude-opus-4-6", name: "Aether Ultra" },
|
|
29
|
+
opus: { id: "claude-opus-4-6", name: "Aether Ultra" },
|
|
30
|
+
max: { id: "grok-4-3", name: "Aether Max" },
|
|
31
|
+
grok: { id: "grok-4-3", name: "Aether Max" },
|
|
32
|
+
};
|
|
33
|
+
|
|
22
34
|
const SHORTCUTS = `
|
|
23
35
|
${c.cyan("/help")} Show this help
|
|
24
36
|
${c.cyan("/exit")} Exit (or Ctrl+C twice)
|
|
@@ -27,18 +39,21 @@ const SHORTCUTS = `
|
|
|
27
39
|
${c.cyan("/cwd")} ${c.gray("[path]")} Show or change working directory
|
|
28
40
|
${c.cyan("/yes")} Toggle auto-approve mode (skip y/N prompts)
|
|
29
41
|
${c.cyan("/turns")} ${c.gray("<n>")} Set max turns per prompt (default 25)
|
|
30
|
-
${c.cyan("/model")}
|
|
42
|
+
${c.cyan("/model")} ${c.gray("[core|ultra|max]")} Show or switch model (ultra=Opus, max=Grok — premium)
|
|
31
43
|
|
|
32
44
|
${c.gray("Anything else is sent to the agent as your next message.")}
|
|
33
45
|
${c.gray("Conversation history is kept across messages until you /clear.")}
|
|
34
46
|
`;
|
|
35
47
|
|
|
36
|
-
export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafePaths: initialUnsafePaths, maxTurns: initialMaxTurns, mcpManager = null }) {
|
|
48
|
+
export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafePaths: initialUnsafePaths, maxTurns: initialMaxTurns, model: initialModel = null, mcpManager = null }) {
|
|
49
|
+
const initialAlias = Object.values(MODEL_ALIASES).find((a) => a.id === initialModel);
|
|
37
50
|
const state = {
|
|
38
51
|
cwd: initialCwd,
|
|
39
52
|
autoYes: !!initialAutoYes,
|
|
40
53
|
unsafePaths: !!initialUnsafePaths,
|
|
41
54
|
maxTurns: initialMaxTurns ?? 25,
|
|
55
|
+
model: initialModel ?? null,
|
|
56
|
+
modelName: initialAlias?.name ?? MODEL_NAME,
|
|
42
57
|
messages: [], // accumulates across turns
|
|
43
58
|
balance: null,
|
|
44
59
|
sessionCredits: 0,
|
|
@@ -114,6 +129,7 @@ export async function runRepl({ cwd: initialCwd, autoYes: initialAutoYes, unsafe
|
|
|
114
129
|
cwd: state.cwd,
|
|
115
130
|
autoYes: state.autoYes,
|
|
116
131
|
unsafePaths: state.unsafePaths,
|
|
132
|
+
model: state.model,
|
|
117
133
|
maxTurns: state.maxTurns,
|
|
118
134
|
mcpManager,
|
|
119
135
|
});
|
|
@@ -199,9 +215,24 @@ async function handleSlash(line, state) {
|
|
|
199
215
|
}
|
|
200
216
|
break;
|
|
201
217
|
}
|
|
202
|
-
case "model":
|
|
203
|
-
|
|
218
|
+
case "model": {
|
|
219
|
+
const key = (arg || "").trim().toLowerCase();
|
|
220
|
+
if (!key) {
|
|
221
|
+
console.log(c.gray(`model: ${state.modelName}`));
|
|
222
|
+
console.log(c.gray("switch with /model core | ultra | max (ultra=Opus, max=Grok — premium, need credits)"));
|
|
223
|
+
} else if (MODEL_ALIASES[key]) {
|
|
224
|
+
state.model = MODEL_ALIASES[key].id;
|
|
225
|
+
state.modelName = MODEL_ALIASES[key].name;
|
|
226
|
+
const premium = state.model !== null;
|
|
227
|
+
console.log(
|
|
228
|
+
c.gray(`model → ${c.cyan(state.modelName)}`) +
|
|
229
|
+
(premium ? c.gray(" (premium — needs purchased credits, else falls back to Core)") : ""),
|
|
230
|
+
);
|
|
231
|
+
} else {
|
|
232
|
+
console.log(errorLine(`Unknown model "${key}". Try: core, ultra, max`));
|
|
233
|
+
}
|
|
204
234
|
break;
|
|
235
|
+
}
|
|
205
236
|
default:
|
|
206
237
|
console.log(errorLine(`Unknown command: /${cmd}. Type ${c.cyan("/help")} for shortcuts.`));
|
|
207
238
|
}
|
|
@@ -226,7 +257,7 @@ function printBanner(state) {
|
|
|
226
257
|
const credits = state.balance != null ? `${state.balance.toLocaleString()} credits` : "";
|
|
227
258
|
const left = boxLines(
|
|
228
259
|
[
|
|
229
|
-
c.bold(c.magenta("aether")) + c.gray(" · ") + c.bold(
|
|
260
|
+
c.bold(c.magenta("aether")) + c.gray(" · ") + c.bold(state.modelName) + c.gray(" v" + VERSION),
|
|
230
261
|
c.gray("uncensored AI coding agent"),
|
|
231
262
|
c.gray((credits ? credits + " · " : "") + mode),
|
|
232
263
|
c.gray(shortenPath(state.cwd, 52)),
|
package/src/skills.js
CHANGED
|
@@ -134,6 +134,12 @@ export function loadSkillsFromDir(dir) {
|
|
|
134
134
|
if (!name.endsWith(".md")) continue;
|
|
135
135
|
const filePath = path.join(dir, name);
|
|
136
136
|
const raw = fs.readFileSync(filePath, "utf8");
|
|
137
|
+
// Cap skill size: the body is injected into the prompt every turn it
|
|
138
|
+
// triggers, so a huge file would blow the context window / inflate cost.
|
|
139
|
+
if (raw.length > 50_000) {
|
|
140
|
+
process.stderr.write(` (skipping oversized skill ${name}: ${raw.length} bytes > 50KB)\n`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
137
143
|
skills.push(parseSkill(raw, filePath));
|
|
138
144
|
}
|
|
139
145
|
return skills;
|
package/src/tools.js
CHANGED
|
@@ -279,6 +279,7 @@ const MAX_GLOB_RESULTS = 300;
|
|
|
279
279
|
// relative path. ** spans directory separators; * does not.
|
|
280
280
|
export function globToRegExp(glob) {
|
|
281
281
|
let re = "";
|
|
282
|
+
let braceDepth = 0;
|
|
282
283
|
for (let i = 0; i < glob.length; i++) {
|
|
283
284
|
const ch = glob[i];
|
|
284
285
|
if (ch === "*") {
|
|
@@ -291,6 +292,16 @@ export function globToRegExp(glob) {
|
|
|
291
292
|
}
|
|
292
293
|
} else if (ch === "?") {
|
|
293
294
|
re += "[^/]";
|
|
295
|
+
} else if (ch === "{") {
|
|
296
|
+
// Brace expansion: {js,ts} -> (js|ts). Common in .gitignore (*.{js,ts});
|
|
297
|
+
// treating it literally silently failed to ignore those files.
|
|
298
|
+
re += "(";
|
|
299
|
+
braceDepth++;
|
|
300
|
+
} else if (ch === "}" && braceDepth > 0) {
|
|
301
|
+
re += ")";
|
|
302
|
+
braceDepth--;
|
|
303
|
+
} else if (ch === "," && braceDepth > 0) {
|
|
304
|
+
re += "|";
|
|
294
305
|
} else if ("\\^$+.()|{}[]".includes(ch)) {
|
|
295
306
|
re += "\\" + ch;
|
|
296
307
|
} else {
|
|
@@ -430,13 +441,22 @@ async function askUser(args, _opts) {
|
|
|
430
441
|
function readFile(args, opts) {
|
|
431
442
|
if (typeof args.path !== "string") return { ok: false, output: "path is required" };
|
|
432
443
|
const abs = resolveSafe(args.path, opts);
|
|
433
|
-
|
|
444
|
+
let stat;
|
|
445
|
+
try { stat = fs.statSync(abs); } catch (e) {
|
|
446
|
+
return { ok: false, output: `Cannot access ${args.path}: ${e.code || e.message}` };
|
|
447
|
+
}
|
|
434
448
|
if (stat.isDirectory()) return { ok: false, output: `${args.path} is a directory, not a file` };
|
|
449
|
+
// Block non-regular files (named pipes, sockets, devices) — reading them can
|
|
450
|
+
// hang forever (no timeout on readFileSync).
|
|
451
|
+
if (!stat.isFile()) return { ok: false, output: `${args.path} is not a regular file.` };
|
|
435
452
|
if (stat.size > 1_000_000) {
|
|
436
453
|
return { ok: false, output: `File too large (${stat.size} bytes). Aether refuses to read >1MB at once.` };
|
|
437
454
|
}
|
|
438
|
-
|
|
439
|
-
|
|
455
|
+
try {
|
|
456
|
+
return { ok: true, output: fs.readFileSync(abs, "utf8") };
|
|
457
|
+
} catch (e) {
|
|
458
|
+
return { ok: false, output: `Cannot read ${args.path}: ${e.code || e.message}` };
|
|
459
|
+
}
|
|
440
460
|
}
|
|
441
461
|
|
|
442
462
|
function listDir(args, opts) {
|
|
@@ -497,6 +517,10 @@ function searchFiles(args, opts) {
|
|
|
497
517
|
} catch { continue; }
|
|
498
518
|
const lines = content.split("\n");
|
|
499
519
|
for (let i = 0; i < lines.length; i++) {
|
|
520
|
+
// Skip very long lines: the pattern is model-supplied and a
|
|
521
|
+
// pathological regex on a long line can backtrack catastrophically
|
|
522
|
+
// (ReDoS) and freeze the single-threaded process.
|
|
523
|
+
if (lines[i].length > 2000) continue;
|
|
500
524
|
if (regex.test(lines[i])) {
|
|
501
525
|
matches.push({ file: rel, line: i + 1, text: lines[i].slice(0, 300) });
|
|
502
526
|
if (matches.length >= MAX_SEARCH_MATCHES) { truncated = true; return; }
|
|
@@ -648,12 +672,33 @@ async function webSearch(args, opts) {
|
|
|
648
672
|
// Bounded fetch with a fixed timeout + size cap. Strips scripts/styles, removes
|
|
649
673
|
// tags, decodes common HTML entities. Not a full HTML parser; good enough for
|
|
650
674
|
// reading docs pages, GitHub READMEs, MDN, Stack Overflow answers, etc.
|
|
675
|
+
// SSRF guard: refuse loopback / link-local / private hosts so web_fetch can't
|
|
676
|
+
// be steered (directly or via a redirect) at localhost or cloud-metadata
|
|
677
|
+
// (169.254.169.254) or an internal service.
|
|
678
|
+
function isBlockedHost(hostname) {
|
|
679
|
+
const h = (hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
|
|
680
|
+
if (!h || h === "localhost" || h === "0.0.0.0" || h === "::1" || h === "::") return true;
|
|
681
|
+
if (h.endsWith(".localhost") || h.endsWith(".internal") || h.endsWith(".local")) return true;
|
|
682
|
+
if (/^127\./.test(h)) return true; // loopback
|
|
683
|
+
if (/^10\./.test(h)) return true; // private A
|
|
684
|
+
if (/^192\.168\./.test(h)) return true; // private C
|
|
685
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true; // private B
|
|
686
|
+
if (/^169\.254\./.test(h)) return true; // link-local incl. cloud metadata
|
|
687
|
+
if (/^(fe80:|fc|fd|::ffff:)/.test(h)) return true; // IPv6 link-local / ULA / mapped
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
|
|
651
691
|
async function webFetch(args, opts) {
|
|
652
692
|
void opts;
|
|
653
693
|
if (typeof args.url !== "string") return { ok: false, output: "url is required" };
|
|
654
694
|
if (!/^https?:\/\//i.test(args.url)) {
|
|
655
695
|
return { ok: false, output: "Only http:// and https:// URLs are allowed." };
|
|
656
696
|
}
|
|
697
|
+
let initialHost;
|
|
698
|
+
try { initialHost = new URL(args.url).hostname; } catch { return { ok: false, output: "Invalid URL." }; }
|
|
699
|
+
if (isBlockedHost(initialHost)) {
|
|
700
|
+
return { ok: false, output: "web_fetch blocked: refusing to fetch a private/internal/loopback address." };
|
|
701
|
+
}
|
|
657
702
|
const controller = new AbortController();
|
|
658
703
|
const timeout = setTimeout(() => controller.abort(), 15_000);
|
|
659
704
|
let res;
|
|
@@ -674,6 +719,14 @@ async function webFetch(args, opts) {
|
|
|
674
719
|
return { ok: false, output: `web_fetch error: ${e.name === "AbortError" ? "timed out after 15s" : e.message}` };
|
|
675
720
|
}
|
|
676
721
|
clearTimeout(timeout);
|
|
722
|
+
// Re-check the FINAL url after any redirects — a public URL can 302 to an
|
|
723
|
+
// internal target, which the initial-URL check wouldn't catch.
|
|
724
|
+
try {
|
|
725
|
+
if (isBlockedHost(new URL(res.url).hostname)) {
|
|
726
|
+
res.body?.cancel().catch(() => {});
|
|
727
|
+
return { ok: false, output: "web_fetch blocked: redirect to a private/internal address." };
|
|
728
|
+
}
|
|
729
|
+
} catch { /* res.url unparseable — fall through */ }
|
|
677
730
|
if (!res.ok) {
|
|
678
731
|
return { ok: false, output: `web_fetch HTTP ${res.status} ${res.statusText}` };
|
|
679
732
|
}
|