aether-code 0.27.0 → 0.29.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 +14 -1
- package/package.json +1 -1
- package/src/agent.js +46 -2
- package/src/api.js +65 -32
- package/src/repl.js +1 -1
- package/src/tools.js +13 -6
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.29.0";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Try to start MCP servers from ~/.aether/mcp.json. Returns a started
|
|
@@ -146,6 +146,19 @@ function isSystemDir(p) {
|
|
|
146
146
|
);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// Always restore the terminal on exit. The raw-mode input box, the ask_user
|
|
150
|
+
// menu, and the spinner hide the cursor (\x1b[?25l) and set raw mode; if the
|
|
151
|
+
// process dies mid-prompt (an uncaught error, or the MCP SIGINT handler's
|
|
152
|
+
// process.exit(130)) their own cleanup never runs and the user's shell is left
|
|
153
|
+
// with no cursor and no echo. This idempotent backstop fires on every exit.
|
|
154
|
+
function restoreTerminal() {
|
|
155
|
+
try {
|
|
156
|
+
if (process.stdout.isTTY) process.stdout.write("\x1b[?25h"); // show cursor
|
|
157
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) process.stdin.setRawMode(false);
|
|
158
|
+
} catch { /* terminal already gone — nothing to restore */ }
|
|
159
|
+
}
|
|
160
|
+
process.on("exit", restoreTerminal);
|
|
161
|
+
|
|
149
162
|
async function main() {
|
|
150
163
|
const args = parseArgs(process.argv.slice(2));
|
|
151
164
|
setTerminalTitle("Aether");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aether-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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
|
@@ -18,6 +18,17 @@ function envContext(cwd) {
|
|
|
18
18
|
const home = os.homedir();
|
|
19
19
|
const desktop = path.join(home, "Desktop");
|
|
20
20
|
const documents = path.join(home, "Documents");
|
|
21
|
+
const win = process.platform === "win32";
|
|
22
|
+
// OS-specific shell guidance: gemma tends to emit Unix commands (mkdir -p,
|
|
23
|
+
// touch, &&-chains) which FAIL in Windows cmd.exe ("The syntax of the command
|
|
24
|
+
// is incorrect"). Steer it to the file tools (which are cross-platform and
|
|
25
|
+
// auto-create parent dirs) and OS-appropriate shell usage.
|
|
26
|
+
const shellNote = win
|
|
27
|
+
? `shell: Windows cmd.exe. To create files OR directories, use the write_file tool — ` +
|
|
28
|
+
`it creates any missing parent folders automatically. Do NOT use shell "mkdir -p", ` +
|
|
29
|
+
`"touch", "ls", "rm", "cp", "mv", or "&&"-chained Unix commands; they FAIL in cmd.exe. ` +
|
|
30
|
+
`Run programs/tests with their interpreter (python, node, java, etc.).`
|
|
31
|
+
: `shell: POSIX sh. Standard Unix commands are available. write_file still auto-creates parent dirs.`;
|
|
21
32
|
return (
|
|
22
33
|
`[environment]\n` +
|
|
23
34
|
`os: ${process.platform}\n` +
|
|
@@ -25,6 +36,7 @@ function envContext(cwd) {
|
|
|
25
36
|
`home: ${home}\n` +
|
|
26
37
|
`desktop: ${desktop}\n` +
|
|
27
38
|
`documents: ${documents}\n` +
|
|
39
|
+
`${shellNote}\n` +
|
|
28
40
|
`When the user names a location ("my desktop", "home", "documents"), write to ` +
|
|
29
41
|
`the matching ABSOLUTE path above (e.g. desktop -> ${desktop}). Otherwise ` +
|
|
30
42
|
`work under the cwd. Use absolute paths when a specific location is named.\n` +
|
|
@@ -83,6 +95,16 @@ export async function runAgent({
|
|
|
83
95
|
// Files the run created/edited, shown in a summary at the end so the user
|
|
84
96
|
// knows exactly what was produced and where to find it.
|
|
85
97
|
const filesTouched = new Map();
|
|
98
|
+
// Files the run read — used by the completeness guard to spot files that were
|
|
99
|
+
// read (e.g. callers in a refactor) but never edited.
|
|
100
|
+
const filesRead = new Set();
|
|
101
|
+
// Completeness guard: gemma sometimes reads files then narrates the fix as
|
|
102
|
+
// text, or refactors one file but forgets the others. If a MODIFICATION task
|
|
103
|
+
// finishes having read files it never edited, we nudge it once to finish.
|
|
104
|
+
const MOD_RE =
|
|
105
|
+
/\b(refactor|fix|add|adds?|change|update|implement|creat|writ|remov|delet|rename|extract|move|replace|build|generate|insert|append|convert|migrat|wire|hook|edit|modif|patch|integrat)/i;
|
|
106
|
+
const looksLikeModification = MOD_RE.test(initialPrompt);
|
|
107
|
+
let appliedNothingNudges = 0;
|
|
86
108
|
|
|
87
109
|
for (let i = 0; i < maxTurns; i++) {
|
|
88
110
|
// No turn header and no leading blank here — each step (assistant text and
|
|
@@ -173,6 +195,24 @@ export async function runAgent({
|
|
|
173
195
|
|
|
174
196
|
const toolCalls = res.message.tool_calls ?? [];
|
|
175
197
|
if (toolCalls.length === 0) {
|
|
198
|
+
// Completeness guard: on a modification task, if the model read files it
|
|
199
|
+
// never edited, it likely either described the fix instead of applying it
|
|
200
|
+
// (zero edits) or refactored some files but forgot others (partial). Nudge
|
|
201
|
+
// once (at most) to finish. The "if it needs no change, just finish" escape
|
|
202
|
+
// keeps a legitimately read-for-context file from forcing a wrong edit.
|
|
203
|
+
const unedited = [...filesRead].filter((p) => !filesTouched.has(p));
|
|
204
|
+
if (looksLikeModification && appliedNothingNudges < 1 && (filesTouched.size === 0 ? referencedPaths.length > 0 : unedited.length > 0)) {
|
|
205
|
+
appliedNothingNudges++;
|
|
206
|
+
const msg =
|
|
207
|
+
filesTouched.size === 0
|
|
208
|
+
? "You read the file(s) but applied NO changes (no write_file / edit_file). This task asks you to " +
|
|
209
|
+
"modify code — ACTUALLY APPLY the change now with edit_file/write_file, don't just describe it."
|
|
210
|
+
: `You edited ${[...filesTouched.keys()].join(", ")} but read ${unedited.join(", ")} without editing ${unedited.length === 1 ? "it" : "them"}. ` +
|
|
211
|
+
`If the task needs ${unedited.length === 1 ? "that file" : "those files"} changed too (e.g. updating callers/imports after a refactor), do it NOW. ` +
|
|
212
|
+
`If ${unedited.length === 1 ? "it genuinely needs" : "they genuinely need"} no change, then finish.`;
|
|
213
|
+
messages.push({ role: "user", content: msg });
|
|
214
|
+
continue; // give the model another turn to finish
|
|
215
|
+
}
|
|
176
216
|
if (filesTouched.size) {
|
|
177
217
|
console.log("\n" + c.bold("Files") + c.gray(` in ${cwd}`));
|
|
178
218
|
for (const [p, action] of filesTouched) {
|
|
@@ -230,9 +270,13 @@ export async function runAgent({
|
|
|
230
270
|
if (call.function.name === "read_file" || call.function.name === "edit_file" || call.function.name === "write_file") {
|
|
231
271
|
if (typeof args.path === "string") referencedPaths.push(args.path);
|
|
232
272
|
}
|
|
233
|
-
if (result.ok && typeof args.path === "string"
|
|
273
|
+
if (result.ok && typeof args.path === "string") {
|
|
234
274
|
const rel = path.relative(cwd, path.resolve(cwd, args.path)) || args.path;
|
|
235
|
-
|
|
275
|
+
if (call.function.name === "write_file" || call.function.name === "edit_file") {
|
|
276
|
+
filesTouched.set(rel, call.function.name === "write_file" ? "created" : "edited");
|
|
277
|
+
} else if (call.function.name === "read_file") {
|
|
278
|
+
filesRead.add(rel);
|
|
279
|
+
}
|
|
236
280
|
}
|
|
237
281
|
const summary = toolSummary(call.function.name, result);
|
|
238
282
|
if (summary) console.log(summary);
|
package/src/api.js
CHANGED
|
@@ -33,6 +33,9 @@ export async function fetchWithRetry(url, options, { retries = 2, baseDelay = 50
|
|
|
33
33
|
try {
|
|
34
34
|
const res = await fetch(url, options);
|
|
35
35
|
if (res.status >= 500 && attempt < retries) {
|
|
36
|
+
// Release the connection back to the pool before retrying — an
|
|
37
|
+
// unconsumed body keeps the socket open (undici won't reuse it).
|
|
38
|
+
res.body?.cancel().catch(() => {});
|
|
36
39
|
attempt++;
|
|
37
40
|
if (onRetry) onRetry(attempt, `HTTP ${res.status}`);
|
|
38
41
|
await sleep(baseDelay * 2 ** (attempt - 1));
|
|
@@ -175,39 +178,69 @@ export async function agentTurnStream({
|
|
|
175
178
|
const reader = res.body.getReader();
|
|
176
179
|
const decoder = new TextDecoder();
|
|
177
180
|
let buffer = "";
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
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
|
-
|
|
181
|
+
|
|
182
|
+
// Dispatch a single parsed SSE "data:" line.
|
|
183
|
+
const handleLine = (line) => {
|
|
184
|
+
if (!line.startsWith("data:")) return;
|
|
185
|
+
const json = line.slice(5).trim(); // .trim() also drops a CRLF trailing \r
|
|
186
|
+
if (!json) return;
|
|
187
|
+
let evt;
|
|
188
|
+
try { evt = JSON.parse(json); } catch { return; }
|
|
189
|
+
if (evt.kind === "delta") {
|
|
190
|
+
textBuf += evt.content;
|
|
191
|
+
if (onDelta) onDelta(evt.content);
|
|
192
|
+
} else if (evt.kind === "tool_call_delta") {
|
|
193
|
+
const slot = toolBuf.get(evt.index) || { id: undefined, name: undefined, args: "" };
|
|
194
|
+
if (evt.id) slot.id = evt.id;
|
|
195
|
+
if (evt.name) slot.name = evt.name;
|
|
196
|
+
if (evt.args_delta) slot.args += evt.args_delta;
|
|
197
|
+
toolBuf.set(evt.index, slot);
|
|
198
|
+
if (onToolCallDelta) onToolCallDelta(evt);
|
|
199
|
+
} else if (evt.kind === "finish") {
|
|
200
|
+
finishReason = evt.reason;
|
|
201
|
+
if (onFinish) onFinish(evt.reason);
|
|
202
|
+
} else if (evt.kind === "done") {
|
|
203
|
+
creditsCharged = evt.creditsCharged ?? 0;
|
|
204
|
+
balanceAfter = evt.balanceAfter ?? null;
|
|
205
|
+
usage = evt.usage ?? usage;
|
|
206
|
+
} else if (evt.kind === "error") {
|
|
207
|
+
streamError = evt.error;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Watchdog: if the open stream stalls (no bytes) for too long, abort instead
|
|
212
|
+
// of hanging the CLI forever.
|
|
213
|
+
const READ_TIMEOUT_MS = 120_000;
|
|
214
|
+
const readOnce = () => {
|
|
215
|
+
let timer;
|
|
216
|
+
const timeout = new Promise((_, reject) => {
|
|
217
|
+
timer = setTimeout(
|
|
218
|
+
() => reject(new AetherError("Stream stalled — no data for 120s", "STREAM_TIMEOUT", 0)),
|
|
219
|
+
READ_TIMEOUT_MS,
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
return Promise.race([reader.read(), timeout]).finally(() => clearTimeout(timer));
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
while (true) {
|
|
227
|
+
const { value, done } = await readOnce();
|
|
228
|
+
if (done) break;
|
|
229
|
+
buffer += decoder.decode(value, { stream: true });
|
|
230
|
+
const lines = buffer.split("\n");
|
|
231
|
+
buffer = lines.pop() ?? "";
|
|
232
|
+
for (const line of lines) handleLine(line);
|
|
210
233
|
}
|
|
234
|
+
// Flush any multibyte bytes the decoder is holding, then process a final
|
|
235
|
+
// line that arrived without a trailing newline (else the "done" event with
|
|
236
|
+
// credits/usage is silently dropped).
|
|
237
|
+
buffer += decoder.decode();
|
|
238
|
+
if (buffer.trim()) handleLine(buffer);
|
|
239
|
+
} catch (e) {
|
|
240
|
+
reader.cancel().catch(() => {});
|
|
241
|
+
throw e;
|
|
242
|
+
} finally {
|
|
243
|
+
try { reader.releaseLock(); } catch { /* already released / pending — ignore */ }
|
|
211
244
|
}
|
|
212
245
|
|
|
213
246
|
if (streamError) {
|
package/src/repl.js
CHANGED
|
@@ -16,7 +16,7 @@ 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.29.0";
|
|
20
20
|
const MODEL_NAME = "Aether Core";
|
|
21
21
|
|
|
22
22
|
const SHORTCUTS = `
|
package/src/tools.js
CHANGED
|
@@ -336,7 +336,14 @@ function resolveSafe(rel, opts) {
|
|
|
336
336
|
const abs = path.isAbsolute(rel) ? path.normalize(rel) : path.resolve(opts.cwd, rel);
|
|
337
337
|
if (!opts.unsafePaths) {
|
|
338
338
|
const cwd = path.resolve(opts.cwd);
|
|
339
|
-
|
|
339
|
+
// Use path.relative (not startsWith) so Windows drive-relative inputs like
|
|
340
|
+
// "C:evil.txt" — which path.isAbsolute() reports as NON-absolute and
|
|
341
|
+
// path.resolve() may anchor to a drive's own cwd outside the project — are
|
|
342
|
+
// caught. A path is inside cwd only if its relative form doesn't climb out
|
|
343
|
+
// (no leading "..") and isn't itself absolute (different drive/root).
|
|
344
|
+
const relToCwd = path.relative(cwd, abs);
|
|
345
|
+
const escapes = relToCwd === ".." || relToCwd.startsWith(".." + path.sep) || path.isAbsolute(relToCwd);
|
|
346
|
+
if (abs !== cwd && escapes) {
|
|
340
347
|
throw new Error(
|
|
341
348
|
`Refusing to touch path outside cwd: ${abs}\n Run with --unsafe-paths if you really mean this.`,
|
|
342
349
|
);
|
|
@@ -591,11 +598,11 @@ async function editFile(args, opts) {
|
|
|
591
598
|
output: `\`find\` text appears ${occurrences} times — must be unique. Add more context to disambiguate, or set replace_all: true to replace all ${occurrences}.`,
|
|
592
599
|
};
|
|
593
600
|
}
|
|
594
|
-
// split/join
|
|
595
|
-
// the
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
601
|
+
// split/join for BOTH paths: String.replace(str, str) would interpret special
|
|
602
|
+
// patterns in the replacement ($&, $`, $', $$) and silently corrupt code that
|
|
603
|
+
// contains them (regex, jQuery $, template strings). split/join is literal.
|
|
604
|
+
// On the single-edit path occurrences === 1, so it's equivalent + safe.
|
|
605
|
+
const newContent = oldContent.split(args.find).join(args.replace);
|
|
599
606
|
const rel = path.relative(opts.cwd, abs);
|
|
600
607
|
console.log(c.dim(`edit ${rel}${args.replace_all ? ` (${occurrences} occurrences)` : ""}`));
|
|
601
608
|
console.log(unifiedDiff(oldContent, newContent, rel));
|