@element47/ag 4.5.2 → 4.5.4

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.
Files changed (52) hide show
  1. package/README.md +91 -5
  2. package/dist/cli/__tests__/repl-rawmode.test.d.ts +2 -0
  3. package/dist/cli/__tests__/repl-rawmode.test.d.ts.map +1 -0
  4. package/dist/cli/__tests__/repl-rawmode.test.js +46 -0
  5. package/dist/cli/__tests__/repl-rawmode.test.js.map +1 -0
  6. package/dist/cli/repl.d.ts.map +1 -1
  7. package/dist/cli/repl.js +24 -4
  8. package/dist/cli/repl.js.map +1 -1
  9. package/dist/cli.js +5 -0
  10. package/dist/cli.js.map +1 -1
  11. package/dist/core/__tests__/agent.test.js +19 -19
  12. package/dist/core/__tests__/agent.test.js.map +1 -1
  13. package/dist/core/__tests__/context.test.js +45 -1
  14. package/dist/core/__tests__/context.test.js.map +1 -1
  15. package/dist/core/__tests__/events.test.d.ts +2 -0
  16. package/dist/core/__tests__/events.test.d.ts.map +1 -0
  17. package/dist/core/__tests__/events.test.js +131 -0
  18. package/dist/core/__tests__/events.test.js.map +1 -0
  19. package/dist/core/__tests__/extensions.test.d.ts +2 -0
  20. package/dist/core/__tests__/extensions.test.d.ts.map +1 -0
  21. package/dist/core/__tests__/extensions.test.js +59 -0
  22. package/dist/core/__tests__/extensions.test.js.map +1 -0
  23. package/dist/core/__tests__/guardrails.test.js +18 -0
  24. package/dist/core/__tests__/guardrails.test.js.map +1 -1
  25. package/dist/core/agent.d.ts +18 -0
  26. package/dist/core/agent.d.ts.map +1 -1
  27. package/dist/core/agent.js +217 -110
  28. package/dist/core/agent.js.map +1 -1
  29. package/dist/core/context.d.ts.map +1 -1
  30. package/dist/core/context.js +14 -6
  31. package/dist/core/context.js.map +1 -1
  32. package/dist/core/events.d.ts +62 -0
  33. package/dist/core/events.d.ts.map +1 -0
  34. package/dist/core/events.js +23 -0
  35. package/dist/core/events.js.map +1 -0
  36. package/dist/core/extensions.d.ts +10 -0
  37. package/dist/core/extensions.d.ts.map +1 -0
  38. package/dist/core/extensions.js +66 -0
  39. package/dist/core/extensions.js.map +1 -0
  40. package/dist/core/permissions.js +1 -1
  41. package/dist/core/permissions.js.map +1 -1
  42. package/dist/memory/memory.js +1 -1
  43. package/dist/memory/memory.js.map +1 -1
  44. package/dist/tools/__tests__/bash.test.js +64 -2
  45. package/dist/tools/__tests__/bash.test.js.map +1 -1
  46. package/dist/tools/__tests__/file.test.js +7 -1
  47. package/dist/tools/__tests__/file.test.js.map +1 -1
  48. package/dist/tools/bash.d.ts +2 -0
  49. package/dist/tools/bash.d.ts.map +1 -1
  50. package/dist/tools/bash.js +175 -43
  51. package/dist/tools/bash.js.map +1 -1
  52. package/package.json +1 -4
@@ -1,6 +1,8 @@
1
1
  import { readdirSync, statSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { execFileSync } from 'node:child_process';
4
+ import { AgentEventEmitter } from './events.js';
5
+ import { discoverExtensions, loadExtensions } from './extensions.js';
4
6
  import { C } from './colors.js';
5
7
  import { loadContext, loadHistory, appendHistory, getStats, clearProject, clearAll, paths, saveGlobalMemory, saveProjectMemory, savePlan, appendPlan, setActivePlan, getActivePlanName, loadGlobalMemory, loadProjectMemory, loadPlan, loadPlanByName, listPlans } from '../memory/memory.js';
6
8
  import { bashToolFactory } from '../tools/bash.js';
@@ -33,9 +35,9 @@ function startSpinner(label) {
33
35
  }
34
36
  const MAX_MESSAGES = 200;
35
37
  const COMPACT_THRESHOLD = 0.9;
36
- const MAX_TOOL_RESULT_CHARS = 8192;
37
- const TRUNCATION_HEAD_LINES = 50;
38
- const TRUNCATION_TAIL_LINES = 50;
38
+ const MAX_TOOL_RESULT_CHARS = 32768;
39
+ const TRUNCATION_HEAD_LINES = 100;
40
+ const TRUNCATION_TAIL_LINES = 100;
39
41
  const COMPACT_HEAD_KEEP = 2;
40
42
  const COMPACT_TAIL_KEEP = 10;
41
43
  const COMPACT_MSG_CHARS = 500;
@@ -147,6 +149,9 @@ export class Agent {
147
149
  contextTracker;
148
150
  compactionInProgress = false;
149
151
  confirmToolCall;
152
+ events = new AgentEventEmitter();
153
+ loadedExtensions = [];
154
+ spinnerControl = null;
150
155
  constructor(config = {}) {
151
156
  this.apiKey = config.apiKey || process.env.OPENROUTER_API_KEY || '';
152
157
  if (!this.apiKey)
@@ -204,7 +209,7 @@ export class Agent {
204
209
  - plan — create and manage multi-step task plans
205
210
  - web(fetch/search) — fetch pages or search the web
206
211
  - skill — activate a skill by name`;
207
- this.maxIterations = config.maxIterations || 25;
212
+ this.maxIterations = config.maxIterations || 200;
208
213
  this.cwd = config.cwd || process.cwd();
209
214
  this.confirmToolCall = config.confirmToolCall ?? null;
210
215
  // Discover skills
@@ -238,6 +243,34 @@ export class Agent {
238
243
  }
239
244
  this.tools.set(name, tool);
240
245
  }
246
+ /** Subscribe to an agent lifecycle event. Returns an unsubscribe function. */
247
+ on(event, handler) {
248
+ return this.events.on(event, handler);
249
+ }
250
+ /** Discover and load extensions from .ag/extensions/ and ~/.ag/extensions/ */
251
+ async initExtensions(extraPaths) {
252
+ const discovered = discoverExtensions(this.cwd);
253
+ const all = [...discovered, ...(extraPaths ?? [])];
254
+ if (all.length > 0) {
255
+ this.loadedExtensions = await loadExtensions(this, all);
256
+ }
257
+ }
258
+ /** Metadata for successfully loaded extensions */
259
+ getLoadedExtensions() {
260
+ return this.loadedExtensions;
261
+ }
262
+ /** Spinner-safe log for extensions. Pauses spinner, writes, resumes. */
263
+ log(message) {
264
+ if (this.spinnerControl)
265
+ this.spinnerControl.pause();
266
+ process.stderr.write(message + '\n');
267
+ if (this.spinnerControl)
268
+ this.spinnerControl.resume();
269
+ }
270
+ /** Called by the REPL to register spinner pause/resume for safe output */
271
+ setSpinnerControl(control) {
272
+ this.spinnerControl = control;
273
+ }
241
274
  refreshCache() {
242
275
  this.cachedContext = loadContext(this.cwd);
243
276
  this.cachedCatalog = buildSkillCatalog(this.allSkills);
@@ -296,10 +329,12 @@ export class Agent {
296
329
  return parts.join('\n\n');
297
330
  }
298
331
  /** Build the JSON request body for chat completions, with prompt caching for supported models */
299
- buildRequestBody(stream) {
332
+ buildRequestBody(stream, overrides) {
333
+ const sysPrompt = overrides?.systemPrompt ?? this.systemPrompt;
334
+ const msgs = overrides?.messages ?? this.messages;
300
335
  const body = {
301
336
  model: this.model,
302
- messages: [{ role: 'system', content: this.systemPrompt }, ...this.messages],
337
+ messages: [{ role: 'system', content: sysPrompt }, ...msgs],
303
338
  tools: Array.from(this.tools.values()).map(t => ({ type: t.type, function: t.function })),
304
339
  tool_choice: 'auto',
305
340
  };
@@ -333,73 +368,86 @@ export class Agent {
333
368
  }
334
369
  return `Skill "${name}" activated. Instructions loaded.`;
335
370
  }
336
- async compactConversation() {
371
+ async compactConversation(customSummary) {
337
372
  const minMessages = COMPACT_HEAD_KEEP + COMPACT_TAIL_KEEP + 4;
338
373
  if (this.messages.length <= minMessages)
339
374
  return;
340
375
  const head = this.messages.slice(0, COMPACT_HEAD_KEEP);
341
376
  const middle = this.messages.slice(COMPACT_HEAD_KEEP, -COMPACT_TAIL_KEEP);
342
377
  const tail = this.messages.slice(-COMPACT_TAIL_KEEP);
343
- // Format middle messages for summarization, capping total size
344
- let totalChars = 0;
345
- const formatted = [];
346
- for (const m of middle) {
347
- let line;
348
- if (m.tool_calls?.length) {
349
- const names = m.tool_calls.map(tc => tc.function.name).join(', ');
350
- line = `[assistant]: (tool call: ${names})`;
378
+ let summary;
379
+ if (customSummary) {
380
+ // Use extension-provided summary instead of LLM call
381
+ summary = customSummary;
382
+ }
383
+ else {
384
+ // Format middle messages for summarization, capping total size
385
+ let totalChars = 0;
386
+ const formatted = [];
387
+ for (const m of middle) {
388
+ let line;
389
+ if (m.tool_calls?.length) {
390
+ const names = m.tool_calls.map(tc => tc.function.name).join(', ');
391
+ line = `[assistant]: (tool call: ${names})`;
392
+ }
393
+ else if (m.role === 'tool') {
394
+ line = `[tool result]: ${(m.content || '').slice(0, COMPACT_MSG_CHARS)}`;
395
+ }
396
+ else {
397
+ line = `[${m.role}]: ${(m.content || '').slice(0, COMPACT_MSG_CHARS)}`;
398
+ }
399
+ if (totalChars + line.length > COMPACT_TOTAL_CHARS)
400
+ break;
401
+ totalChars += line.length;
402
+ formatted.push(line);
351
403
  }
352
- else if (m.role === 'tool') {
353
- line = `[tool result]: ${(m.content || '').slice(0, COMPACT_MSG_CHARS)}`;
404
+ const stopSpinner = startSpinner('compacting context');
405
+ let stopped = false;
406
+ try {
407
+ const res = await fetch(`${this.baseURL}/chat/completions`, {
408
+ method: 'POST',
409
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` },
410
+ body: JSON.stringify({
411
+ model: this.model,
412
+ messages: [
413
+ { role: 'system', content: COMPACTION_PROMPT },
414
+ { role: 'user', content: formatted.join('\n\n') }
415
+ ]
416
+ })
417
+ });
418
+ if (!res.ok)
419
+ throw new Error(`API ${res.status}: ${await res.text()}`);
420
+ const body = await res.json();
421
+ summary = body.choices?.[0]?.message?.content;
422
+ if (!summary)
423
+ throw new Error('No summary returned');
424
+ stopSpinner();
425
+ stopped = true;
354
426
  }
355
- else {
356
- line = `[${m.role}]: ${(m.content || '').slice(0, COMPACT_MSG_CHARS)}`;
427
+ finally {
428
+ if (!stopped)
429
+ stopSpinner();
357
430
  }
358
- if (totalChars + line.length > COMPACT_TOTAL_CHARS)
359
- break;
360
- totalChars += line.length;
361
- formatted.push(line);
362
- }
363
- const stopSpinner = startSpinner('compacting context');
364
- let stopped = false;
365
- try {
366
- const res = await fetch(`${this.baseURL}/chat/completions`, {
367
- method: 'POST',
368
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` },
369
- body: JSON.stringify({
370
- model: this.model,
371
- messages: [
372
- { role: 'system', content: COMPACTION_PROMPT },
373
- { role: 'user', content: formatted.join('\n\n') }
374
- ]
375
- })
376
- });
377
- if (!res.ok)
378
- throw new Error(`API ${res.status}: ${await res.text()}`);
379
- const body = await res.json();
380
- const summary = body.choices?.[0]?.message?.content;
381
- if (!summary)
382
- throw new Error('No summary returned');
383
- const summaryMsg = {
384
- role: 'user',
385
- content: `[Conversation compacted — summary of ${middle.length} earlier messages]\n\n${summary}`
386
- };
387
- this.messages = [...head, summaryMsg, ...tail];
388
- appendHistory(summaryMsg, this.cwd);
389
- // Re-estimate context usage from the compacted messages
390
- const compactedChars = this.messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0)
391
- + this.systemPrompt.length;
392
- this.contextTracker.estimateFromChars(compactedChars);
393
- stopSpinner();
394
- stopped = true;
395
- process.stderr.write(` ${C.yellow}Context compacted: ${middle.length} messages → summary${C.reset}\n`);
396
- }
397
- finally {
398
- if (!stopped)
399
- stopSpinner();
400
431
  }
432
+ const summaryMsg = {
433
+ role: 'user',
434
+ content: `[Conversation compacted — summary of ${middle.length} earlier messages]\n\n${summary}`
435
+ };
436
+ this.messages = [...head, summaryMsg, ...tail];
437
+ appendHistory(summaryMsg, this.cwd);
438
+ // Re-estimate context usage from the compacted messages
439
+ const compactedChars = this.messages.reduce((sum, m) => sum + (m.content?.length ?? 0), 0)
440
+ + this.systemPrompt.length;
441
+ this.contextTracker.estimateFromChars(compactedChars);
442
+ process.stderr.write(` ${C.yellow}Context compacted: ${middle.length} messages → summary${C.reset}\n`);
401
443
  }
402
444
  async chat(content, signal) {
445
+ // ── input event ──
446
+ const inputEvent = { content, skip: false };
447
+ await this.events.emit('input', inputEvent);
448
+ if (inputEvent.skip)
449
+ return '';
450
+ content = inputEvent.content;
403
451
  const userMessage = { role: 'user', content };
404
452
  this.messages.push(userMessage);
405
453
  appendHistory(userMessage, this.cwd);
@@ -412,14 +460,46 @@ export class Agent {
412
460
  this.messages.push({ role: 'assistant', content: '[interrupted by user]' });
413
461
  return '[interrupted by user]';
414
462
  }
463
+ // ── turn_start event ──
464
+ await this.events.emit('turn_start', { iteration: i, maxIterations: this.maxIterations, messageCount: this.messages.length });
415
465
  const iterLabel = this.maxIterations > 1 ? ` [${i + 1}/${this.maxIterations}]` : '';
416
466
  const stopSpinner = startSpinner(`thinking${iterLabel}`);
467
+ // ── before_compact event ──
468
+ if (!this.compactionInProgress && this.contextTracker.shouldCompact(COMPACT_THRESHOLD)) {
469
+ const compactEvent = { messageCount: this.messages.length, cancel: false, customSummary: undefined };
470
+ await this.events.emit('before_compact', compactEvent);
471
+ if (!compactEvent.cancel) {
472
+ this.compactionInProgress = true;
473
+ try {
474
+ await this.compactConversation(compactEvent.customSummary);
475
+ }
476
+ catch (e) {
477
+ process.stderr.write(` ${C.dim}Compaction failed: ${e} — falling back to truncation${C.reset}\n`);
478
+ const keep = COMPACT_HEAD_KEEP + COMPACT_TAIL_KEEP;
479
+ if (this.messages.length > keep) {
480
+ const head = this.messages.slice(0, COMPACT_HEAD_KEEP);
481
+ const tail = this.messages.slice(-COMPACT_TAIL_KEEP);
482
+ const truncMsg = {
483
+ role: 'user',
484
+ content: `[Conversation truncated — ${this.messages.length - keep} older messages removed to stay within context limit]`
485
+ };
486
+ this.messages = [...head, truncMsg, ...tail];
487
+ }
488
+ }
489
+ finally {
490
+ this.compactionInProgress = false;
491
+ }
492
+ }
493
+ }
494
+ // ── before_request event ──
495
+ const reqEvent = { messages: this.messages, systemPrompt: this.systemPrompt, model: this.model, stream: false };
496
+ await this.events.emit('before_request', reqEvent);
417
497
  let msg;
418
498
  try {
419
499
  const res = await fetch(`${this.baseURL}/chat/completions`, {
420
500
  method: 'POST',
421
501
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` },
422
- body: JSON.stringify(this.buildRequestBody(false)),
502
+ body: JSON.stringify(this.buildRequestBody(false, { messages: reqEvent.messages, systemPrompt: reqEvent.systemPrompt })),
423
503
  signal
424
504
  });
425
505
  if (!res.ok)
@@ -437,33 +517,13 @@ export class Agent {
437
517
  finally {
438
518
  stopSpinner();
439
519
  }
440
- // Compact conversation if approaching context limit
441
- if (!this.compactionInProgress && this.contextTracker.shouldCompact(COMPACT_THRESHOLD)) {
442
- this.compactionInProgress = true;
443
- try {
444
- await this.compactConversation();
445
- }
446
- catch (e) {
447
- process.stderr.write(` ${C.dim}Compaction failed: ${e} — falling back to truncation${C.reset}\n`);
448
- // Fallback: simple truncation to prevent context overflow
449
- const keep = COMPACT_HEAD_KEEP + COMPACT_TAIL_KEEP;
450
- if (this.messages.length > keep) {
451
- const head = this.messages.slice(0, COMPACT_HEAD_KEEP);
452
- const tail = this.messages.slice(-COMPACT_TAIL_KEEP);
453
- const truncMsg = {
454
- role: 'user',
455
- content: `[Conversation truncated — ${this.messages.length - keep} older messages removed to stay within context limit]`
456
- };
457
- this.messages = [...head, truncMsg, ...tail];
458
- }
459
- }
460
- finally {
461
- this.compactionInProgress = false;
462
- }
463
- }
520
+ // ── after_response event ──
521
+ await this.events.emit('after_response', { message: msg, usage: undefined });
464
522
  this.messages.push(msg);
465
523
  appendHistory(msg, this.cwd);
466
524
  if (!msg.tool_calls?.length) {
525
+ // ── turn_end event (no tools) ──
526
+ await this.events.emit('turn_end', { iteration: i, hadToolCalls: false, toolCallCount: 0 });
467
527
  return msg.content || '';
468
528
  }
469
529
  // Permission checks — run sequentially so prompts don't overlap
@@ -499,13 +559,25 @@ export class Agent {
499
559
  if (permissionDecisions.get(call.id) === 'deny') {
500
560
  return { call, content: 'Tool call denied by user.' };
501
561
  }
562
+ // ── tool_call event ──
563
+ const tcEvent = { toolName: call.function.name, toolCallId: call.id, args, block: false, blockReason: undefined };
564
+ await this.events.emit('tool_call', tcEvent);
565
+ if (tcEvent.block) {
566
+ return { call, content: tcEvent.blockReason || 'Blocked by extension' };
567
+ }
568
+ args = tcEvent.args;
502
569
  const summary = args.command ?? args.action ?? JSON.stringify(args).slice(0, 80);
503
570
  const stopToolSpinner = startSpinner(`[${call.function.name}] ${summary}`);
504
571
  try {
505
572
  const rawResult = await tool.execute(args);
506
- const result = truncateToolResult(rawResult);
573
+ let result = truncateToolResult(rawResult);
574
+ let isError = result.startsWith('Error:') || result.startsWith('EXIT ');
575
+ // ── tool_result event ──
576
+ const trEvent = { toolName: call.function.name, toolCallId: call.id, args, content: result, isError };
577
+ await this.events.emit('tool_result', trEvent);
578
+ result = trEvent.content;
579
+ isError = trEvent.isError;
507
580
  stopToolSpinner();
508
- const isError = result.startsWith('Error:') || result.startsWith('EXIT ');
509
581
  const icon = isError ? `${C.red}✗` : `${C.green}✓`;
510
582
  const preview = result.slice(0, 150).split('\n')[0];
511
583
  process.stderr.write(` ${icon} ${C.dim}[${call.function.name}]${C.reset} ${C.dim}${preview}${result.length > 150 ? '...' : ''}${C.reset}\n`);
@@ -523,10 +595,18 @@ export class Agent {
523
595
  this.messages.push({ role: 'tool', tool_call_id: call.id, content });
524
596
  appendHistory({ role: 'tool', tool_call_id: call.id, content }, this.cwd);
525
597
  }
598
+ // ── turn_end event ──
599
+ await this.events.emit('turn_end', { iteration: i, hadToolCalls: true, toolCallCount: msg.tool_calls.length });
526
600
  }
527
601
  return MAX_ITERATIONS_REACHED;
528
602
  }
529
603
  async *chatStream(content, signal) {
604
+ // ── input event ──
605
+ const inputEvent = { content, skip: false };
606
+ await this.events.emit('input', inputEvent);
607
+ if (inputEvent.skip)
608
+ return;
609
+ content = inputEvent.content;
530
610
  const userMessage = { role: 'user', content };
531
611
  this.messages.push(userMessage);
532
612
  appendHistory(userMessage, this.cwd);
@@ -540,37 +620,46 @@ export class Agent {
540
620
  yield { type: 'interrupted' };
541
621
  return;
542
622
  }
623
+ // ── turn_start event ──
624
+ await this.events.emit('turn_start', { iteration: i, maxIterations: this.maxIterations, messageCount: this.messages.length });
543
625
  const iterLabel = this.maxIterations > 1 ? ` [${i + 1}/${this.maxIterations}]` : '';
544
626
  yield { type: 'thinking', content: `thinking${iterLabel}` };
545
- // Compact if needed
627
+ // ── before_compact event ──
546
628
  if (!this.compactionInProgress && this.contextTracker.shouldCompact(COMPACT_THRESHOLD)) {
547
- this.compactionInProgress = true;
548
- try {
549
- await this.compactConversation();
550
- }
551
- catch (e) {
552
- const keep = COMPACT_HEAD_KEEP + COMPACT_TAIL_KEEP;
553
- if (this.messages.length > keep) {
554
- const head = this.messages.slice(0, COMPACT_HEAD_KEEP);
555
- const tail = this.messages.slice(-COMPACT_TAIL_KEEP);
556
- const truncMsg = {
557
- role: 'user',
558
- content: `[Conversation truncated — ${this.messages.length - keep} older messages removed to stay within context limit]`
559
- };
560
- this.messages = [...head, truncMsg, ...tail];
629
+ const compactEvent = { messageCount: this.messages.length, cancel: false, customSummary: undefined };
630
+ await this.events.emit('before_compact', compactEvent);
631
+ if (!compactEvent.cancel) {
632
+ this.compactionInProgress = true;
633
+ try {
634
+ await this.compactConversation(compactEvent.customSummary);
635
+ }
636
+ catch {
637
+ const keep = COMPACT_HEAD_KEEP + COMPACT_TAIL_KEEP;
638
+ if (this.messages.length > keep) {
639
+ const head = this.messages.slice(0, COMPACT_HEAD_KEEP);
640
+ const tail = this.messages.slice(-COMPACT_TAIL_KEEP);
641
+ const truncMsg = {
642
+ role: 'user',
643
+ content: `[Conversation truncated — ${this.messages.length - keep} older messages removed to stay within context limit]`
644
+ };
645
+ this.messages = [...head, truncMsg, ...tail];
646
+ }
647
+ }
648
+ finally {
649
+ this.compactionInProgress = false;
561
650
  }
562
- }
563
- finally {
564
- this.compactionInProgress = false;
565
651
  }
566
652
  }
653
+ // ── before_request event ──
654
+ const reqEvent = { messages: this.messages, systemPrompt: this.systemPrompt, model: this.model, stream: true };
655
+ await this.events.emit('before_request', reqEvent);
567
656
  // ── API call with abort signal ──
568
657
  let res;
569
658
  try {
570
659
  res = await fetch(`${this.baseURL}/chat/completions`, {
571
660
  method: 'POST',
572
661
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` },
573
- body: JSON.stringify(this.buildRequestBody(true)),
662
+ body: JSON.stringify(this.buildRequestBody(true, { messages: reqEvent.messages, systemPrompt: reqEvent.systemPrompt })),
574
663
  signal
575
664
  });
576
665
  }
@@ -684,10 +773,14 @@ export class Agent {
684
773
  function: { name: tc.name, arguments: tc.arguments }
685
774
  }));
686
775
  }
776
+ // ── after_response event ──
777
+ await this.events.emit('after_response', { message: msg, usage: usage ?? undefined });
687
778
  this.messages.push(msg);
688
779
  appendHistory(msg, this.cwd);
689
780
  if (!msg.tool_calls?.length) {
690
- yield { type: 'done', content: assistantContent };
781
+ // ── turn_end event (no tools) ──
782
+ await this.events.emit('turn_end', { iteration: i, hadToolCalls: false, toolCallCount: 0 });
783
+ yield { type: 'done', content: msg.content || '' };
691
784
  return;
692
785
  }
693
786
  // Notify consumers that tools are about to run (shows spinner immediately)
@@ -737,10 +830,22 @@ export class Agent {
737
830
  if (permissionDecisions.get(call.id) === 'deny') {
738
831
  return { call, content: 'Tool call denied by user.', isError: true };
739
832
  }
833
+ // ── tool_call event ──
834
+ const tcEvent = { toolName: call.function.name, toolCallId: call.id, args, block: false, blockReason: undefined };
835
+ await this.events.emit('tool_call', tcEvent);
836
+ if (tcEvent.block) {
837
+ return { call, content: tcEvent.blockReason || 'Blocked by extension', isError: true };
838
+ }
839
+ args = tcEvent.args;
740
840
  try {
741
841
  const rawResult = await tool.execute(args);
742
- const result = truncateToolResult(rawResult);
743
- const isError = result.startsWith('Error:') || result.startsWith('EXIT ');
842
+ let result = truncateToolResult(rawResult);
843
+ let isError = result.startsWith('Error:') || result.startsWith('EXIT ');
844
+ // ── tool_result event ──
845
+ const trEvent = { toolName: call.function.name, toolCallId: call.id, args, content: result, isError };
846
+ await this.events.emit('tool_result', trEvent);
847
+ result = trEvent.content;
848
+ isError = trEvent.isError;
744
849
  return { call, content: result, isError };
745
850
  }
746
851
  catch (error) {
@@ -771,6 +876,8 @@ export class Agent {
771
876
  yield { type: 'interrupted', content: `${completedCallIds.size} completed, ${msg.tool_calls.length - completedCallIds.size} cancelled` };
772
877
  return;
773
878
  }
879
+ // ── turn_end event ──
880
+ await this.events.emit('turn_end', { iteration: i, hadToolCalls: true, toolCallCount: msg.tool_calls.length });
774
881
  }
775
882
  yield { type: 'max_iterations' };
776
883
  }