coder-agent 2.3.0 → 2.3.1

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/dist/agent.js CHANGED
@@ -237,7 +237,7 @@ function extractTextToolCalls(content) {
237
237
  return calls;
238
238
  }
239
239
  // ─── Gemini API client with Auto-Rotation Fallback ────────────────────────────
240
- async function callGeminiAPIWithRotation(apiKey, params, maxRetries = 3, initialDelayMs = 1500) {
240
+ async function callGeminiAPIWithRotation(apiKey, params, maxRetries = 3, initialDelayMs = 1500, signal) {
241
241
  const rotationList = [
242
242
  "gemini-2.5-flash",
243
243
  "gemini-2.5-pro",
@@ -251,6 +251,11 @@ async function callGeminiAPIWithRotation(apiKey, params, maxRetries = 3, initial
251
251
  }
252
252
  let attempts = 0;
253
253
  while (attempts < maxRetries) {
254
+ if (signal?.aborted) {
255
+ const abortErr = new Error("The user aborted a request.");
256
+ abortErr.name = "AbortError";
257
+ throw abortErr;
258
+ }
254
259
  try {
255
260
  const res = await fetch("https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", {
256
261
  method: "POST",
@@ -258,7 +263,8 @@ async function callGeminiAPIWithRotation(apiKey, params, maxRetries = 3, initial
258
263
  "Content-Type": "application/json",
259
264
  "Authorization": `Bearer ${apiKey}`
260
265
  },
261
- body: JSON.stringify({ ...params, model: currentModel })
266
+ body: JSON.stringify({ ...params, model: currentModel }),
267
+ signal
262
268
  });
263
269
  if (!res.ok) {
264
270
  const errText = await res.text();
@@ -270,6 +276,9 @@ async function callGeminiAPIWithRotation(apiKey, params, maxRetries = 3, initial
270
276
  return { data, modelUsed: currentModel };
271
277
  }
272
278
  catch (err) {
279
+ if (signal?.aborted || err?.name === "AbortError") {
280
+ throw err;
281
+ }
273
282
  attempts++;
274
283
  const status = err?.status;
275
284
  const isRetryableError = status === 429 || status === 503 || (status >= 500 && status < 600) || !status;
@@ -288,7 +297,18 @@ async function callGeminiAPIWithRotation(apiKey, params, maxRetries = 3, initial
288
297
  // Otherwise do standard delay retry on same model
289
298
  if (attempts < maxRetries) {
290
299
  const delay = initialDelayMs * Math.pow(2, attempts - 1);
291
- await new Promise(resolve => setTimeout(resolve, delay));
300
+ await new Promise((resolve, reject) => {
301
+ const timer = setTimeout(resolve, delay);
302
+ if (signal) {
303
+ const onAbort = () => {
304
+ clearTimeout(timer);
305
+ const abortErr = new Error("The user aborted a request.");
306
+ abortErr.name = "AbortError";
307
+ reject(abortErr);
308
+ };
309
+ signal.addEventListener("abort", onAbort);
310
+ }
311
+ });
292
312
  continue;
293
313
  }
294
314
  }
@@ -321,155 +341,195 @@ export class Agent {
321
341
  setModel(model) {
322
342
  this.model = model;
323
343
  }
324
- async chat(userMessage) {
325
- await this.memory.init(this.memoryScope, "coder");
326
- // ── Phase 1: Input & Enriched Context Pre-Parsing ──────────────────────
327
- console.log(chalk.dim('\n' + '─'.repeat(48) + '\n'));
328
- startSpinner("thinking...");
329
- const diagnostics = extractDiagnostics(userMessage);
330
- let enrichedPrompt = userMessage;
331
- if (diagnostics.length > 0) {
332
- updateSpinner("resolving files & context...");
333
- const contexts = [];
334
- for (const diag of diagnostics) {
335
- const filePath = normalizeFilePath(diag.resource);
336
- try {
337
- const content = await fs.readFile(filePath, "utf-8");
338
- const lines = content.split(/\r?\n/);
339
- const startLine = Math.max(1, diag.startLineNumber - 10);
340
- const endLine = Math.min(lines.length, (diag.endLineNumber || diag.startLineNumber) + 10);
341
- const slice = lines.slice(startLine - 1, endLine);
342
- const numberedLines = slice.map((line, idx) => `${startLine + idx}: ${line}`);
343
- contexts.push(`File contents of ${filePath} (around line ${diag.startLineNumber}):\n` + numberedLines.join("\n"));
344
- }
345
- catch (err) {
344
+ async chat(userMessage, signal) {
345
+ try {
346
+ if (signal?.aborted) {
347
+ const abortErr = new Error("The user aborted a request.");
348
+ abortErr.name = "AbortError";
349
+ throw abortErr;
350
+ }
351
+ await this.memory.init(this.memoryScope, "coder");
352
+ if (signal?.aborted) {
353
+ const abortErr = new Error("The user aborted a request.");
354
+ abortErr.name = "AbortError";
355
+ throw abortErr;
356
+ }
357
+ // ── Phase 1: Input & Enriched Context Pre-Parsing ──────────────────────
358
+ console.log(chalk.dim('\n' + '─'.repeat(48) + '\n'));
359
+ startSpinner("thinking...");
360
+ const diagnostics = extractDiagnostics(userMessage);
361
+ let enrichedPrompt = userMessage;
362
+ if (diagnostics.length > 0) {
363
+ updateSpinner("resolving files & context...");
364
+ const contexts = [];
365
+ for (const diag of diagnostics) {
366
+ if (signal?.aborted) {
367
+ const abortErr = new Error("The user aborted a request.");
368
+ abortErr.name = "AbortError";
369
+ throw abortErr;
370
+ }
371
+ const filePath = normalizeFilePath(diag.resource);
346
372
  try {
347
- const relativePath = path.relative("/", filePath).replace(/^[a-zA-Z]:/, "").replace(/^\\+|^[//]+/, "");
348
- const localContent = await fs.readFile(relativePath, "utf-8");
349
- const lines = localContent.split(/\r?\n/);
373
+ const content = await fs.readFile(filePath, "utf-8");
374
+ const lines = content.split(/\r?\n/);
350
375
  const startLine = Math.max(1, diag.startLineNumber - 10);
351
376
  const endLine = Math.min(lines.length, (diag.endLineNumber || diag.startLineNumber) + 10);
352
377
  const slice = lines.slice(startLine - 1, endLine);
353
378
  const numberedLines = slice.map((line, idx) => `${startLine + idx}: ${line}`);
354
- contexts.push(`File contents of resolved path ${relativePath} (around line ${diag.startLineNumber}):\n` + numberedLines.join("\n"));
379
+ contexts.push(`File contents of ${filePath} (around line ${diag.startLineNumber}):\n` + numberedLines.join("\n"));
355
380
  }
356
- catch {
357
- contexts.push(`[File Context: failed to read path ${filePath} - ${err.message}]`);
381
+ catch (err) {
382
+ try {
383
+ const relativePath = path.relative("/", filePath).replace(/^[a-zA-Z]:/, "").replace(/^\\+|^[//]+/, "");
384
+ const localContent = await fs.readFile(relativePath, "utf-8");
385
+ const lines = localContent.split(/\r?\n/);
386
+ const startLine = Math.max(1, diag.startLineNumber - 10);
387
+ const endLine = Math.min(lines.length, (diag.endLineNumber || diag.startLineNumber) + 10);
388
+ const slice = lines.slice(startLine - 1, endLine);
389
+ const numberedLines = slice.map((line, idx) => `${startLine + idx}: ${line}`);
390
+ contexts.push(`File contents of resolved path ${relativePath} (around line ${diag.startLineNumber}):\n` + numberedLines.join("\n"));
391
+ }
392
+ catch {
393
+ contexts.push(`[File Context: failed to read path ${filePath} - ${err.message}]`);
394
+ }
358
395
  }
359
396
  }
397
+ enrichedPrompt += "\n\n=== Enriched Code Context (Auto-Parsed) ===\n" + contexts.join("\n\n") + "\n===========================================";
360
398
  }
361
- enrichedPrompt += "\n\n=== Enriched Code Context (Auto-Parsed) ===\n" + contexts.join("\n\n") + "\n===========================================";
362
- }
363
- this.memory.add({ role: "user", content: enrichedPrompt });
364
- let iterations = 0;
365
- const MAX_ITERATIONS = 12;
366
- const modifiedFiles = new Set();
367
- let cleanContent = "";
368
- while (iterations < MAX_ITERATIONS) {
369
- iterations++;
370
- updateSpinner("thinking...");
371
- const responseObj = await callGeminiAPIWithRotation(this.apiKey, {
372
- model: this.model,
373
- messages: this.memory.getAll(),
374
- tools: TOOL_DEFINITIONS,
375
- tool_choice: "auto",
376
- temperature: 0.2,
377
- });
378
- stopSpinner();
379
- this.model = responseObj.modelUsed; // Persist successful model rotation
380
- const response = responseObj.data;
381
- const choice = response.choices[0];
382
- const msg = choice.message;
383
- // Extract text-based tool calls if native tool_calls is empty
384
- let toolCalls = msg.tool_calls || [];
385
- const extracted = extractTextToolCalls(msg.content ?? "");
386
- if (toolCalls.length === 0 && extracted.length > 0) {
387
- toolCalls = extracted.map(e => ({
388
- id: e.id,
389
- type: "function",
390
- function: {
391
- name: e.name,
392
- arguments: JSON.stringify(e.args)
393
- }
394
- }));
395
- }
396
- // Save assistant message to memory
397
- const assistantMsg = {
398
- role: "assistant",
399
- content: msg.content ?? "",
400
- tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
401
- };
402
- this.memory.add(assistantMsg);
403
- // Clean up text tool calls from the conversational output content
404
- cleanContent = msg.content ?? "";
405
- for (const call of extracted) {
406
- cleanContent = cleanContent.replace(call.rawText, "");
407
- }
408
- cleanContent = cleanContent.trim();
409
- // ── No tool calls → final answer ─────────────────────────────────────
410
- if (toolCalls.length === 0) {
411
- break;
412
- }
413
- // ── Phase 2: Tool Execution ───────────────────────────────────────────
414
- const statusLines = [];
415
- for (const toolCall of toolCalls) {
416
- const name = toolCall.function.name;
417
- let args = {};
418
- try {
419
- args = JSON.parse(toolCall.function.arguments);
399
+ this.memory.add({ role: "user", content: enrichedPrompt });
400
+ let iterations = 0;
401
+ const MAX_ITERATIONS = 12;
402
+ const modifiedFiles = new Set();
403
+ let cleanContent = "";
404
+ while (iterations < MAX_ITERATIONS) {
405
+ if (signal?.aborted) {
406
+ const abortErr = new Error("The user aborted a request.");
407
+ abortErr.name = "AbortError";
408
+ throw abortErr;
420
409
  }
421
- catch { }
422
- // Render tool execution card
423
- const badge = getToolBadge(name);
424
- const target = getToolTargetDescription(name, args);
425
- const details = getToolDetailsDescription(name, args);
426
- console.log(` ${badge} ${chalk.gray(target)}`);
427
- console.log(chalk.dim(` ${details}`));
428
- // Track created or modified files
429
- if (name === "write_file" || name === "patch_file") {
430
- if (args.file_path) {
431
- modifiedFiles.add(path.normalize(args.file_path));
410
+ iterations++;
411
+ updateSpinner("thinking...");
412
+ const responseObj = await callGeminiAPIWithRotation(this.apiKey, {
413
+ model: this.model,
414
+ messages: this.memory.getAll(),
415
+ tools: TOOL_DEFINITIONS,
416
+ tool_choice: "auto",
417
+ temperature: 0.2,
418
+ }, 3, 1500, signal);
419
+ stopSpinner();
420
+ if (signal?.aborted) {
421
+ const abortErr = new Error("The user aborted a request.");
422
+ abortErr.name = "AbortError";
423
+ throw abortErr;
424
+ }
425
+ this.model = responseObj.modelUsed; // Persist successful model rotation
426
+ const response = responseObj.data;
427
+ const choice = response.choices[0];
428
+ const msg = choice.message;
429
+ // Extract text-based tool calls if native tool_calls is empty
430
+ let toolCalls = msg.tool_calls || [];
431
+ const extracted = extractTextToolCalls(msg.content ?? "");
432
+ if (toolCalls.length === 0 && extracted.length > 0) {
433
+ toolCalls = extracted.map(e => ({
434
+ id: e.id,
435
+ type: "function",
436
+ function: {
437
+ name: e.name,
438
+ arguments: JSON.stringify(e.args)
439
+ }
440
+ }));
441
+ }
442
+ // Save assistant message to memory
443
+ const assistantMsg = {
444
+ role: "assistant",
445
+ content: msg.content ?? "",
446
+ tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
447
+ };
448
+ this.memory.add(assistantMsg);
449
+ // Clean up text tool calls from the conversational output content
450
+ cleanContent = msg.content ?? "";
451
+ for (const call of extracted) {
452
+ cleanContent = cleanContent.replace(call.rawText, "");
453
+ }
454
+ cleanContent = cleanContent.trim();
455
+ // ── No tool calls → final answer ─────────────────────────────────────
456
+ if (toolCalls.length === 0) {
457
+ break;
458
+ }
459
+ // ── Phase 2: Tool Execution ───────────────────────────────────────────
460
+ const statusLines = [];
461
+ for (const toolCall of toolCalls) {
462
+ if (signal?.aborted) {
463
+ const abortErr = new Error("The user aborted a request.");
464
+ abortErr.name = "AbortError";
465
+ throw abortErr;
466
+ }
467
+ const name = toolCall.function.name;
468
+ let args = {};
469
+ try {
470
+ args = JSON.parse(toolCall.function.arguments);
471
+ }
472
+ catch { }
473
+ // Render tool execution card
474
+ const badge = getToolBadge(name);
475
+ const target = getToolTargetDescription(name, args);
476
+ const details = getToolDetailsDescription(name, args);
477
+ console.log(` ${badge} ${chalk.gray(target)}`);
478
+ console.log(chalk.dim(` ${details}`));
479
+ // Track created or modified files
480
+ if (name === "write_file" || name === "patch_file") {
481
+ if (args.file_path) {
482
+ modifiedFiles.add(path.normalize(args.file_path));
483
+ }
484
+ }
485
+ const result = await dispatchTool(name, args, signal);
486
+ if (signal?.aborted) {
487
+ const abortErr = new Error("The user aborted a request.");
488
+ abortErr.name = "AbortError";
489
+ throw abortErr;
432
490
  }
491
+ // Generate outcome result status line
492
+ const isError = result.startsWith("ERROR:");
493
+ if (isError) {
494
+ statusLines.push(`${chalk.hex('#ff453a')('✕')} ${chalk.gray(result.slice(6).trim())}`);
495
+ }
496
+ else {
497
+ statusLines.push(`${chalk.hex('#30d158')('✓')} ${chalk.gray(getToolSuccessSummary(name, args, result))}`);
498
+ }
499
+ this.memory.add({
500
+ role: "tool",
501
+ content: result,
502
+ tool_call_id: toolCall.id,
503
+ name,
504
+ });
433
505
  }
434
- const result = await dispatchTool(name, args);
435
- // Generate outcome result status line
436
- const isError = result.startsWith("ERROR:");
437
- if (isError) {
438
- statusLines.push(`${chalk.hex('#ff453a')('✕')} ${chalk.gray(result.slice(6).trim())}`);
506
+ // Stream results status lines
507
+ console.log("");
508
+ for (const line of statusLines) {
509
+ console.log(line);
439
510
  }
440
- else {
441
- statusLines.push(`${chalk.hex('#30d158')('')} ${chalk.gray(getToolSuccessSummary(name, args, result))}`);
511
+ if (iterations < MAX_ITERATIONS) {
512
+ console.log(chalk.dim('\n' + ''.repeat(48) + '\n'));
513
+ startSpinner("thinking...");
442
514
  }
443
- this.memory.add({
444
- role: "tool",
445
- content: result,
446
- tool_call_id: toolCall.id,
447
- name,
448
- });
449
515
  }
450
- // Stream results status lines
451
- console.log("");
452
- for (const line of statusLines) {
453
- console.log(line);
516
+ // Output modified files metadata line if applicable
517
+ if (modifiedFiles.size > 0) {
518
+ console.log(chalk.dim('─') + ' ' + chalk.dim(`${modifiedFiles.size} file(s) modified`));
454
519
  }
455
- if (iterations < MAX_ITERATIONS) {
456
- console.log(chalk.dim('\n' + '─'.repeat(48) + '\n'));
457
- startSpinner("thinking...");
520
+ // ── Phase 3: Final Agent Response ───────────────────────────────────────
521
+ console.log(chalk.dim('\n' + '─'.repeat(48) + '\n'));
522
+ if (cleanContent) {
523
+ console.log(formatResponseText(cleanContent));
524
+ console.log(""); // one trailing blank line
525
+ }
526
+ if (iterations >= MAX_ITERATIONS) {
527
+ console.log(chalk.hex('#ff453a')('✕ error'));
528
+ console.log(chalk.dim(' Max tool iterations reached.'));
458
529
  }
459
530
  }
460
- // Output modified files metadata line if applicable
461
- if (modifiedFiles.size > 0) {
462
- console.log(chalk.dim('─') + ' ' + chalk.dim(`${modifiedFiles.size} file(s) modified`));
463
- }
464
- // ── Phase 3: Final Agent Response ───────────────────────────────────────
465
- console.log(chalk.dim('\n' + '─'.repeat(48) + '\n'));
466
- if (cleanContent) {
467
- console.log(formatResponseText(cleanContent));
468
- console.log(""); // one trailing blank line
469
- }
470
- if (iterations >= MAX_ITERATIONS) {
471
- console.log(chalk.hex('#ff453a')('✕ error'));
472
- console.log(chalk.dim(' Max tool iterations reached.'));
531
+ finally {
532
+ stopSpinner();
473
533
  }
474
534
  }
475
535
  }
package/dist/index.js CHANGED
@@ -174,6 +174,7 @@ async function main() {
174
174
  });
175
175
  let inputBuffer = "";
176
176
  let pasteTimeout = null;
177
+ let currentAbortController = null;
177
178
  rl.setPrompt(chalk.hex('#0a84ff')('›') + ' ');
178
179
  rl.prompt();
179
180
  rl.on("line", (line) => {
@@ -245,29 +246,46 @@ async function main() {
245
246
  rl.prompt();
246
247
  return;
247
248
  }
249
+ currentAbortController = new AbortController();
248
250
  try {
249
- await agent.chat(trimmed);
251
+ await agent.chat(trimmed, currentAbortController.signal);
250
252
  }
251
253
  catch (err) {
252
- console.log(chalk.hex('#ff453a')('✕ error'));
253
- if (err?.status === 401) {
254
- console.log(chalk.dim(" Invalid API key. Check your configuration."));
255
- }
256
- else if (err?.status === 429) {
257
- console.log(chalk.dim(" Rate limit exceeded on Gemini API."));
254
+ if (err?.name === "AbortError" || currentAbortController.signal.aborted) {
255
+ console.log(chalk.hex('#ff453a')('\n✕ cancelled'));
258
256
  }
259
257
  else {
260
- console.log(chalk.dim(` ${err.message}`));
258
+ console.log(chalk.hex('#ff453a')('✕ error'));
259
+ if (err?.status === 401) {
260
+ console.log(chalk.dim(" Invalid API key. Check your configuration."));
261
+ }
262
+ else if (err?.status === 429) {
263
+ console.log(chalk.dim(" Rate limit exceeded on Gemini API."));
264
+ }
265
+ else {
266
+ console.log(chalk.dim(` ${err.message}`));
267
+ }
261
268
  }
262
269
  }
270
+ finally {
271
+ currentAbortController = null;
272
+ }
263
273
  rl.resume();
264
274
  rl.prompt();
265
275
  }, 80);
266
276
  });
267
277
  // Handle Ctrl+C gracefully
268
- rl.on("SIGINT", () => {
269
- process.exit(0);
270
- });
278
+ const sigintHandler = () => {
279
+ if (currentAbortController) {
280
+ currentAbortController.abort();
281
+ }
282
+ else {
283
+ rl.close();
284
+ process.exit(0);
285
+ }
286
+ };
287
+ process.on("SIGINT", sigintHandler);
288
+ rl.on("SIGINT", sigintHandler);
271
289
  }
272
290
  main().catch((err) => {
273
291
  console.error(chalk.hex('#ff453a')('✕ error'));
package/dist/tools.js CHANGED
@@ -181,7 +181,7 @@ export async function list_directory({ dir_path = "." }) {
181
181
  return `ERROR: ${e.message}`;
182
182
  }
183
183
  }
184
- export async function run_shell({ command, cwd }) {
184
+ export async function run_shell({ command, cwd }, signal) {
185
185
  try {
186
186
  let targetCwd = process.cwd();
187
187
  if (cwd) {
@@ -200,18 +200,22 @@ export async function run_shell({ command, cwd }) {
200
200
  const { stdout, stderr } = await execAsync(command, {
201
201
  cwd: targetCwd,
202
202
  timeout: 30_000,
203
+ signal,
203
204
  });
204
205
  const out = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n--- stderr ---\n");
205
206
  return out || "(no output)";
206
207
  }
207
208
  catch (e) {
209
+ if (e.name === "AbortError" || signal?.aborted) {
210
+ throw e;
211
+ }
208
212
  return `EXIT ${e.code ?? "?"}: ${e.stderr || e.message}`;
209
213
  }
210
214
  }
211
- export async function web_search({ query }) {
215
+ export async function web_search({ query }, signal) {
212
216
  try {
213
217
  const url = `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`;
214
- const res = await fetch(url);
218
+ const res = await fetch(url, { signal });
215
219
  const data = (await res.json());
216
220
  const parts = [];
217
221
  if (data.AbstractText)
@@ -229,6 +233,9 @@ export async function web_search({ query }) {
229
233
  return parts.length ? parts.join("\n\n") : `No instant answer found for: "${query}". Try rephrasing.`;
230
234
  }
231
235
  catch (e) {
236
+ if (e.name === "AbortError" || signal?.aborted) {
237
+ throw e;
238
+ }
232
239
  return `Search error: ${e.message}`;
233
240
  }
234
241
  }
@@ -341,13 +348,13 @@ export async function patch_file({ file_path, target_code, replacement_code }) {
341
348
  }
342
349
  }
343
350
  // ─── Dispatcher ──────────────────────────────────────────────────────────────
344
- export async function dispatchTool(name, args) {
351
+ export async function dispatchTool(name, args, signal) {
345
352
  switch (name) {
346
353
  case "read_file": return read_file(args);
347
354
  case "write_file": return write_file(args);
348
355
  case "list_directory": return list_directory(args);
349
- case "run_shell": return run_shell(args);
350
- case "web_search": return web_search(args);
356
+ case "run_shell": return run_shell(args, signal);
357
+ case "web_search": return web_search(args, signal);
351
358
  case "find_files": return find_files(args);
352
359
  case "read_file_lines": return read_file_lines(args);
353
360
  case "search_grep": return search_grep(args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coder-agent",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "description": "CLI coding agent powered by Google Gemini",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",