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.
@@ -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.27.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.27.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" && (call.function.name === "write_file" || call.function.name === "edit_file")) {
273
+ if (result.ok && typeof args.path === "string") {
234
274
  const rel = path.relative(cwd, path.resolve(cwd, args.path)) || args.path;
235
- filesTouched.set(rel, call.function.name === "write_file" ? "created" : "edited");
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
- while (true) {
179
- const { value, done } = await reader.read();
180
- if (done) break;
181
- buffer += decoder.decode(value, { stream: true });
182
- const lines = buffer.split("\n");
183
- buffer = lines.pop() ?? "";
184
- for (const line of lines) {
185
- if (!line.startsWith("data:")) continue;
186
- const json = line.slice(5).trim();
187
- if (!json) continue;
188
- let evt;
189
- try { evt = JSON.parse(json); } catch { continue; }
190
- if (evt.kind === "delta") {
191
- textBuf += evt.content;
192
- if (onDelta) onDelta(evt.content);
193
- } else if (evt.kind === "tool_call_delta") {
194
- const slot = toolBuf.get(evt.index) || { id: undefined, name: undefined, args: "" };
195
- if (evt.id) slot.id = evt.id;
196
- if (evt.name) slot.name = evt.name;
197
- if (evt.args_delta) slot.args += evt.args_delta;
198
- toolBuf.set(evt.index, slot);
199
- if (onToolCallDelta) onToolCallDelta(evt);
200
- } else if (evt.kind === "finish") {
201
- finishReason = evt.reason;
202
- if (onFinish) onFinish(evt.reason);
203
- } else if (evt.kind === "done") {
204
- creditsCharged = evt.creditsCharged ?? 0;
205
- balanceAfter = evt.balanceAfter ?? null;
206
- usage = evt.usage ?? usage;
207
- } else if (evt.kind === "error") {
208
- streamError = evt.error;
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.27.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
- if (!abs.startsWith(cwd + path.sep) && abs !== cwd) {
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 replaces every occurrence without regex-escaping pitfalls; for
595
- // the default single-edit path occurrences === 1 so it's equivalent.
596
- const newContent = args.replace_all
597
- ? oldContent.split(args.find).join(args.replace)
598
- : oldContent.replace(args.find, args.replace);
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));