aether-code 0.36.8 → 0.38.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.
@@ -27,8 +27,9 @@ import {
27
27
  } from "../src/mcp-registry.js";
28
28
  import readline from "node:readline";
29
29
  import { c, errorLine, divider, setTerminalTitle } from "../src/render.js";
30
+ import { getVersion } from "../src/version.js";
30
31
 
31
- const VERSION = "0.32.0";
32
+ const VERSION = getVersion();
32
33
 
33
34
  /**
34
35
  * Try to start MCP servers from ~/.aether/mcp.json. Returns a started
@@ -315,13 +316,10 @@ async function handleBalance() {
315
316
  const me = await fetchBalance();
316
317
  console.log(c.bold(c.magenta("Aether")));
317
318
  console.log(c.gray("─".repeat(50)));
318
- console.log(`Plan ${c.cyan(me.plan)}${me.role !== "USER" ? c.gray(` · ${me.role}`) : ""}`);
319
+ console.log(`Plan ${c.cyan(me.plan)}`);
319
320
  console.log(`Balance ${c.bold(me.balance.toLocaleString())} credits`);
320
321
  console.log(` plan ${me.planCredits.toLocaleString()}`);
321
322
  console.log(` topup ${me.topupCredits.toLocaleString()}`);
322
- if (me.rate) {
323
- console.log(`Rate ${me.rate.used}/${me.rate.limit} this hour${me.rate.resetIn ? ` · resets in ${me.rate.resetIn}s` : ""}`);
324
- }
325
323
  if (me.isSuspended) console.log(c.red("\n⚠ Account is suspended."));
326
324
  } catch (err) {
327
325
  if (err instanceof AetherError && err.code === "NO_API_KEY") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aether-code",
3
- "version": "0.36.8",
3
+ "version": "0.38.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": {
@@ -23,7 +23,7 @@
23
23
  "node": ">=18"
24
24
  },
25
25
  "scripts": {
26
- "lint": "node --check bin/aether-code.js src/agent.js src/api.js src/config.js src/render.js src/tools.js src/diff.js src/repl.js src/mcp.js src/mcp-cli.js src/mcp-registry.js src/skills.js src/update-check.js src/ink-input.js",
26
+ "lint": "node --check bin/aether-code.js src/agent.js src/api.js src/config.js src/render.js src/tools.js src/diff.js src/repl.js src/mcp.js src/mcp-cli.js src/mcp-registry.js src/skills.js src/update-check.js src/ink-input.js src/version.js src/sessions.js src/project-context.js",
27
27
  "test": "node --test \"test/**/*.test.js\"",
28
28
  "prepublishOnly": "npm run lint && npm test"
29
29
  },
package/src/agent.js CHANGED
@@ -8,27 +8,31 @@ import { agentTurnStream, AetherError } from "./api.js";
8
8
  import { TOOL_DEFINITIONS, executeTool } from "./tools.js";
9
9
  import { unnamespaceToolName } from "./mcp.js";
10
10
  import { loadAllSkills, selectSkills, renderSkillsBlock } from "./skills.js";
11
+ import { scanProject } from "./project-context.js";
11
12
  import { c, divider, turn, toolLabel, toolSummary, makeTokenStripper, errorLine, startSpinner } from "./render.js";
12
13
 
13
14
  const DEFAULT_MAX_TURNS = 25;
14
15
 
15
- // Environment block prepended to the first user message so the model can
16
- // resolve named locations to real absolute paths.
17
- function envContext(cwd) {
16
+ export const MOD_RE =
17
+ /\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;
18
+
19
+ export function envContext(cwd) {
18
20
  const home = os.homedir();
19
21
  const desktop = path.join(home, "Desktop");
20
22
  const documents = path.join(home, "Documents");
21
23
  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
24
  const shellNote = win
27
25
  ? `shell: Windows cmd.exe. To create files OR directories, use the write_file tool — ` +
28
26
  `it creates any missing parent folders automatically. Do NOT use shell "mkdir -p", ` +
29
27
  `"touch", "ls", "rm", "cp", "mv", or "&&"-chained Unix commands; they FAIL in cmd.exe. ` +
30
28
  `Run programs/tests with their interpreter (python, node, java, etc.).`
31
29
  : `shell: POSIX sh. Standard Unix commands are available. write_file still auto-creates parent dirs.`;
30
+
31
+ let projectBlock = "";
32
+ try {
33
+ projectBlock = scanProject(cwd);
34
+ } catch { /* non-fatal */ }
35
+
32
36
  return (
33
37
  `[environment]\n` +
34
38
  `os: ${process.platform}\n` +
@@ -40,7 +44,8 @@ function envContext(cwd) {
40
44
  `When the user names a location ("my desktop", "home", "documents"), write to ` +
41
45
  `the matching ABSOLUTE path above (e.g. desktop -> ${desktop}). Otherwise ` +
42
46
  `work under the cwd. Use absolute paths when a specific location is named.\n` +
43
- `[/environment]\n\n`
47
+ `[/environment]\n\n` +
48
+ (projectBlock ? projectBlock + "\n" : "")
44
49
  );
45
50
  }
46
51
 
@@ -102,8 +107,6 @@ export async function runAgent({
102
107
  // Completeness guard: gemma sometimes reads files then narrates the fix as
103
108
  // text, or refactors one file but forgets the others. If a MODIFICATION task
104
109
  // finishes having read files it never edited, we nudge it once to finish.
105
- const MOD_RE =
106
- /\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;
107
110
  const looksLikeModification = MOD_RE.test(initialPrompt);
108
111
  let appliedNothingNudges = 0;
109
112
 
@@ -309,7 +312,7 @@ export async function runAgent({
309
312
  * worse than no skills at all. Prepending into the user message keeps
310
313
  * both layers active.
311
314
  */
312
- function buildTurnMessages(messages, allSkills, referencedPaths) {
315
+ export function buildTurnMessages(messages, allSkills, referencedPaths) {
313
316
  if (allSkills.length === 0) return messages;
314
317
  // Find the latest user message — that's where the current task lives.
315
318
  let lastUserIdx = -1;
package/src/api.js CHANGED
@@ -1,22 +1,9 @@
1
1
  // API client.
2
2
 
3
- import fs from "node:fs";
4
- import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
3
  import { getConfig } from "./config.js";
4
+ import { getVersion } from "./version.js";
7
5
 
8
- // Resolve our own version once for the User-Agent header — read from
9
- // package.json so it can't drift (the file previously sent three different
10
- // hardcoded versions across three calls).
11
- function readVersion() {
12
- try {
13
- const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
14
- return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version || "0";
15
- } catch {
16
- return "0";
17
- }
18
- }
19
- const USER_AGENT = `aether-code/${readVersion()}`;
6
+ const USER_AGENT = `aether-code/${getVersion()}`;
20
7
 
21
8
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
9
 
@@ -93,7 +80,7 @@ export async function fetchBalance(apiKeyOverride) {
93
80
  const msg = data?.error || `${res.status} ${res.statusText}`;
94
81
  throw new AetherError(msg, code, res.status, data);
95
82
  }
96
- return data; // { plan, role, planCredits, topupCredits, balance, isSuspended, rate }
83
+ return data; // { plan, planCredits, topupCredits, balance, isSuspended }
97
84
  }
98
85
 
99
86
  export class AetherError extends Error {
package/src/mcp.js CHANGED
@@ -31,6 +31,7 @@ import os from "node:os";
31
31
  import path from "node:path";
32
32
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33
33
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
34
+ import { getVersion } from "./version.js";
34
35
 
35
36
  const DEFAULT_CONFIG_PATH = path.join(os.homedir(), ".aether", "mcp.json");
36
37
  const NAMESPACE_SEP = "__";
@@ -163,7 +164,7 @@ export class MCPManager {
163
164
  env: { ...process.env, ...(cfg.env ?? {}) },
164
165
  });
165
166
  const client = new Client(
166
- { name: "aether-code", version: "0.9.0" },
167
+ { name: "aether-code", version: getVersion() },
167
168
  { capabilities: {} },
168
169
  );
169
170
  // Bound the init handshake so a hung server doesn't stall startup.
@@ -0,0 +1,279 @@
1
+ // Project context scanner. Reads the cwd on startup to give the model
2
+ // immediate understanding of what project it's working in — tech stack,
3
+ // file structure, available scripts, dependencies, and any project-specific
4
+ // instructions. This is the single biggest quality-of-life improvement for
5
+ // a weaker model: instead of guessing, it KNOWS.
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ const SKIP_DIRS = new Set([
11
+ "node_modules", ".git", ".next", "dist", "build", "__pycache__",
12
+ ".venv", "venv", "env", ".env", ".idea", ".vscode", "coverage",
13
+ ".cache", ".turbo", "target", "out", ".output", ".nuxt", ".svelte-kit",
14
+ "vendor", "bower_components", ".parcel-cache", ".webpack",
15
+ ]);
16
+
17
+ const FRAMEWORK_SIGNATURES = {
18
+ "next.config.js": "Next.js",
19
+ "next.config.mjs": "Next.js",
20
+ "next.config.ts": "Next.js",
21
+ "nuxt.config.ts": "Nuxt",
22
+ "svelte.config.js": "SvelteKit",
23
+ "astro.config.mjs": "Astro",
24
+ "remix.config.js": "Remix",
25
+ "angular.json": "Angular",
26
+ "vue.config.js": "Vue CLI",
27
+ "vite.config.ts": "Vite",
28
+ "vite.config.js": "Vite",
29
+ "webpack.config.js": "Webpack",
30
+ "tsconfig.json": "TypeScript",
31
+ "Cargo.toml": "Rust (Cargo)",
32
+ "go.mod": "Go",
33
+ "pyproject.toml": "Python",
34
+ "setup.py": "Python (setuptools)",
35
+ "requirements.txt": "Python",
36
+ "Gemfile": "Ruby (Bundler)",
37
+ "pom.xml": "Java (Maven)",
38
+ "build.gradle": "Java/Kotlin (Gradle)",
39
+ "CMakeLists.txt": "C/C++ (CMake)",
40
+ "Makefile": "Make",
41
+ "Dockerfile": "Docker",
42
+ "docker-compose.yml": "Docker Compose",
43
+ "docker-compose.yaml": "Docker Compose",
44
+ ".env": "dotenv config",
45
+ "prisma/schema.prisma": "Prisma ORM",
46
+ "drizzle.config.ts": "Drizzle ORM",
47
+ };
48
+
49
+ const DEP_FRAMEWORKS = {
50
+ next: "Next.js",
51
+ react: "React",
52
+ vue: "Vue",
53
+ svelte: "Svelte",
54
+ angular: "Angular",
55
+ express: "Express",
56
+ fastify: "Fastify",
57
+ koa: "Koa",
58
+ hono: "Hono",
59
+ "socket.io": "Socket.IO",
60
+ prisma: "Prisma",
61
+ drizzle: "Drizzle",
62
+ mongoose: "Mongoose",
63
+ sequelize: "Sequelize",
64
+ tailwindcss: "Tailwind CSS",
65
+ "styled-components": "styled-components",
66
+ electron: "Electron",
67
+ jest: "Jest",
68
+ vitest: "Vitest",
69
+ mocha: "Mocha",
70
+ playwright: "Playwright",
71
+ cypress: "Cypress",
72
+ };
73
+
74
+ function readJsonSafe(filePath) {
75
+ try {
76
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ function readTextSafe(filePath, maxBytes = 2000) {
83
+ try {
84
+ const stat = fs.statSync(filePath);
85
+ if (!stat.isFile() || stat.size > 500_000) return null;
86
+ const buf = Buffer.alloc(Math.min(stat.size, maxBytes));
87
+ const fd = fs.openSync(filePath, "r");
88
+ const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
89
+ fs.closeSync(fd);
90
+ return buf.toString("utf8", 0, bytesRead);
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function listTopLevel(cwd) {
97
+ let entries;
98
+ try {
99
+ entries = fs.readdirSync(cwd, { withFileTypes: true });
100
+ } catch {
101
+ return { dirs: [], files: [] };
102
+ }
103
+ const dirs = [];
104
+ const files = [];
105
+ for (const e of entries) {
106
+ if (e.name.startsWith(".") && e.name !== ".env" && e.name !== ".gitignore") continue;
107
+ if (SKIP_DIRS.has(e.name)) continue;
108
+ if (e.isDirectory()) {
109
+ dirs.push(e.name + "/");
110
+ } else if (e.isFile()) {
111
+ files.push(e.name);
112
+ }
113
+ }
114
+ dirs.sort();
115
+ files.sort();
116
+ return { dirs, files };
117
+ }
118
+
119
+ function listSecondLevel(cwd, dirName) {
120
+ const full = path.join(cwd, dirName);
121
+ let entries;
122
+ try {
123
+ entries = fs.readdirSync(full, { withFileTypes: true });
124
+ } catch {
125
+ return [];
126
+ }
127
+ const items = [];
128
+ for (const e of entries) {
129
+ if (e.name.startsWith(".")) continue;
130
+ if (SKIP_DIRS.has(e.name)) continue;
131
+ items.push(e.isDirectory() ? e.name + "/" : e.name);
132
+ }
133
+ items.sort();
134
+ return items.slice(0, 30);
135
+ }
136
+
137
+ function detectFrameworks(cwd, topFiles) {
138
+ const found = new Set();
139
+ for (const file of topFiles) {
140
+ if (FRAMEWORK_SIGNATURES[file]) found.add(FRAMEWORK_SIGNATURES[file]);
141
+ }
142
+ if (fs.existsSync(path.join(cwd, "prisma", "schema.prisma"))) {
143
+ found.add("Prisma ORM");
144
+ }
145
+ return [...found];
146
+ }
147
+
148
+ function extractPackageInfo(cwd) {
149
+ const pkg = readJsonSafe(path.join(cwd, "package.json"));
150
+ if (!pkg) return null;
151
+ const info = { name: pkg.name, version: pkg.version };
152
+ if (pkg.scripts && Object.keys(pkg.scripts).length > 0) {
153
+ info.scripts = Object.keys(pkg.scripts);
154
+ }
155
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
156
+ const detectedLibs = [];
157
+ for (const [dep, label] of Object.entries(DEP_FRAMEWORKS)) {
158
+ if (allDeps[dep]) detectedLibs.push(label);
159
+ }
160
+ if (detectedLibs.length > 0) info.frameworks = detectedLibs;
161
+ const depCount = Object.keys(pkg.dependencies || {}).length;
162
+ const devCount = Object.keys(pkg.devDependencies || {}).length;
163
+ if (depCount || devCount) {
164
+ info.depCounts = `${depCount} deps, ${devCount} devDeps`;
165
+ }
166
+ return info;
167
+ }
168
+
169
+ function extractCargoInfo(cwd) {
170
+ const raw = readTextSafe(path.join(cwd, "Cargo.toml"), 1500);
171
+ if (!raw) return null;
172
+ const name = raw.match(/^name\s*=\s*"(.+?)"/m)?.[1];
173
+ const version = raw.match(/^version\s*=\s*"(.+?)"/m)?.[1];
174
+ return { name, version, type: "Rust" };
175
+ }
176
+
177
+ function extractGoInfo(cwd) {
178
+ const raw = readTextSafe(path.join(cwd, "go.mod"), 500);
179
+ if (!raw) return null;
180
+ const mod = raw.match(/^module\s+(\S+)/m)?.[1];
181
+ const goVer = raw.match(/^go\s+(\S+)/m)?.[1];
182
+ return { module: mod, goVersion: goVer, type: "Go" };
183
+ }
184
+
185
+ function extractPythonInfo(cwd) {
186
+ const pyproject = readTextSafe(path.join(cwd, "pyproject.toml"), 1500);
187
+ if (pyproject) {
188
+ const name = pyproject.match(/^name\s*=\s*"(.+?)"/m)?.[1];
189
+ return { name, type: "Python" };
190
+ }
191
+ if (fs.existsSync(path.join(cwd, "requirements.txt"))) {
192
+ return { type: "Python (requirements.txt)" };
193
+ }
194
+ return null;
195
+ }
196
+
197
+ export function scanProject(cwd) {
198
+ const { dirs, files } = listTopLevel(cwd);
199
+ if (dirs.length === 0 && files.length === 0) {
200
+ return "[project]\nEmpty directory — no files detected.\n[/project]\n";
201
+ }
202
+
203
+ const lines = ["[project]"];
204
+
205
+ const frameworks = detectFrameworks(cwd, files);
206
+ if (frameworks.length > 0) {
207
+ lines.push(`stack: ${frameworks.join(", ")}`);
208
+ }
209
+
210
+ const pkg = extractPackageInfo(cwd);
211
+ const cargo = extractCargoInfo(cwd);
212
+ const goInfo = extractGoInfo(cwd);
213
+ const pyInfo = extractPythonInfo(cwd);
214
+
215
+ if (pkg) {
216
+ if (pkg.name) lines.push(`package: ${pkg.name}${pkg.version ? `@${pkg.version}` : ""}`);
217
+ if (pkg.frameworks) lines.push(`libraries: ${pkg.frameworks.join(", ")}`);
218
+ if (pkg.depCounts) lines.push(`dependencies: ${pkg.depCounts}`);
219
+ if (pkg.scripts) lines.push(`scripts: ${pkg.scripts.join(", ")}`);
220
+ }
221
+ if (cargo) {
222
+ lines.push(`crate: ${cargo.name || "unknown"}${cargo.version ? `@${cargo.version}` : ""}`);
223
+ }
224
+ if (goInfo) {
225
+ lines.push(`module: ${goInfo.module || "unknown"}${goInfo.goVersion ? ` (go ${goInfo.goVersion})` : ""}`);
226
+ }
227
+ if (pyInfo) {
228
+ lines.push(`python project: ${pyInfo.name || pyInfo.type}`);
229
+ }
230
+
231
+ lines.push("");
232
+ lines.push("structure:");
233
+ for (const d of dirs) {
234
+ lines.push(` ${d}`);
235
+ const children = listSecondLevel(cwd, d.replace(/\/$/, ""));
236
+ for (const child of children.slice(0, 15)) {
237
+ lines.push(` ${child}`);
238
+ }
239
+ if (children.length > 15) {
240
+ lines.push(` ... (${children.length - 15} more)`);
241
+ }
242
+ }
243
+ for (const f of files) {
244
+ lines.push(` ${f}`);
245
+ }
246
+
247
+ const contextFile = path.join(cwd, ".aether", "context.md");
248
+ const context = readTextSafe(contextFile, 3000);
249
+ if (context) {
250
+ lines.push("");
251
+ lines.push("project-instructions:");
252
+ lines.push(context.trim());
253
+ }
254
+
255
+ const readme = readTextSafe(path.join(cwd, "README.md"), 1500);
256
+ if (readme) {
257
+ lines.push("");
258
+ lines.push("readme-excerpt:");
259
+ lines.push(readme.trim());
260
+ }
261
+
262
+ lines.push("[/project]");
263
+ return lines.join("\n") + "\n";
264
+ }
265
+
266
+ export function scanProjectQuick(cwd) {
267
+ const { dirs, files } = listTopLevel(cwd);
268
+ const total = dirs.length + files.length;
269
+ if (total === 0) return "empty directory";
270
+ const frameworks = detectFrameworks(cwd, files);
271
+ const pkg = extractPackageInfo(cwd);
272
+ const parts = [];
273
+ if (pkg?.name) parts.push(pkg.name);
274
+ if (frameworks.length > 0) parts.push(frameworks.join(", "));
275
+ else if (pkg?.frameworks?.length) parts.push(pkg.frameworks[0]);
276
+ parts.push(`${total} items`);
277
+ if (pkg?.scripts) parts.push(`${pkg.scripts.length} scripts`);
278
+ return parts.join(" · ");
279
+ }
package/src/render.js CHANGED
@@ -88,6 +88,13 @@ export function toolLabel(name, args) {
88
88
  case "web_fetch": return `${verb("fetch")} ${arg(a.url)}`;
89
89
  case "todo_write": return ""; // its Plan box is the label
90
90
  case "ask_user": return ""; // the interactive menu is its display
91
+ case "git_status": return `${verb("git")} ${arg("status")}`;
92
+ case "git_diff": return `${verb("git")} ${arg("diff" + (a.staged ? " --staged" : "") + (a.base ? ` ${a.base}...${a.head || "HEAD"}` : ""))}`;
93
+ case "git_log": return `${verb("git")} ${arg("log" + (a.file ? ` -- ${a.file}` : "") + (a.branch ? ` ${a.branch}` : ""))}`;
94
+ case "git_commit": return `${verb("git")} ${arg(`commit "${ellip(a.message || "", 50)}"`)}`
95
+ case "git_branch": return `${verb("git")} ${arg(`branch ${a.name}`)}`;
96
+ case "git_push": return `${verb("git")} ${arg("push" + (a.force ? " --force" : ""))}`;
97
+ case "git_create_pr": return `${verb("git")} ${arg(`pr "${ellip(a.title || "", 50)}"`)}`
91
98
  default: return `${verb(name)} ${arg(JSON.stringify(a))}`;
92
99
  }
93
100
  }
@@ -142,6 +149,32 @@ export function toolSummary(name, result) {
142
149
  }
143
150
  if (name === "todo_write") return ""; // the Plan is its own feedback
144
151
  if (name === "ask_user") return ""; // the menu already showed the answer
152
+ if (name === "git_commit" || name === "git_push" || name === "git_create_pr") {
153
+ return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
154
+ }
155
+ if (name === "git_status") {
156
+ try {
157
+ const s = JSON.parse(out);
158
+ if (s.clean) return ` ${branch} ${c.gray("clean")}`;
159
+ const parts = [];
160
+ if (s.staged?.length) parts.push(`${s.staged.length} staged`);
161
+ if (s.unstaged?.length) parts.push(`${s.unstaged.length} modified`);
162
+ if (s.untracked?.length) parts.push(`${s.untracked.length} untracked`);
163
+ return ` ${branch} ${c.gray(parts.join(", "))}`;
164
+ } catch { /* fall through */ }
165
+ }
166
+ if (name === "git_diff") {
167
+ if (out === "(no changes)") return ` ${branch} ${c.gray("no changes")}`;
168
+ const diffLines = out.split("\n").length;
169
+ return ` ${branch} ${c.gray(`${diffLines} lines of diff`)}`;
170
+ }
171
+ if (name === "git_log") {
172
+ const logLines = out.trim().split("\n").filter((l) => l.trim()).length;
173
+ return ` ${branch} ${c.gray(`${logLines} lines`)}`;
174
+ }
175
+ if (name === "git_branch") {
176
+ return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;
177
+ }
145
178
  if (name === "write_file" || name === "edit_file") {
146
179
  // Handler already printed the diff; echo its short status line.
147
180
  return ` ${branch} ${c.gray(ellip(firstLine, 100))}`;