@element47/ag 4.4.5 → 4.5.2

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 (54) hide show
  1. package/README.md +106 -4
  2. package/dist/cli/repl.d.ts +7 -2
  3. package/dist/cli/repl.d.ts.map +1 -1
  4. package/dist/cli/repl.js +390 -95
  5. package/dist/cli/repl.js.map +1 -1
  6. package/dist/cli.js +18 -8
  7. package/dist/cli.js.map +1 -1
  8. package/dist/core/__tests__/agent.test.js +42 -1
  9. package/dist/core/__tests__/agent.test.js.map +1 -1
  10. package/dist/core/__tests__/context.test.js +37 -0
  11. package/dist/core/__tests__/context.test.js.map +1 -1
  12. package/dist/core/__tests__/guardrails.test.d.ts +2 -0
  13. package/dist/core/__tests__/guardrails.test.d.ts.map +1 -0
  14. package/dist/core/__tests__/guardrails.test.js +400 -0
  15. package/dist/core/__tests__/guardrails.test.js.map +1 -0
  16. package/dist/core/__tests__/permission-manager.test.d.ts +2 -0
  17. package/dist/core/__tests__/permission-manager.test.d.ts.map +1 -0
  18. package/dist/core/__tests__/permission-manager.test.js +246 -0
  19. package/dist/core/__tests__/permission-manager.test.js.map +1 -0
  20. package/dist/core/__tests__/streaming.test.js +8 -1
  21. package/dist/core/__tests__/streaming.test.js.map +1 -1
  22. package/dist/core/agent.d.ts +20 -2
  23. package/dist/core/agent.d.ts.map +1 -1
  24. package/dist/core/agent.js +259 -82
  25. package/dist/core/agent.js.map +1 -1
  26. package/dist/core/context.d.ts.map +1 -1
  27. package/dist/core/context.js +17 -2
  28. package/dist/core/context.js.map +1 -1
  29. package/dist/core/guardrails.d.ts +32 -0
  30. package/dist/core/guardrails.d.ts.map +1 -0
  31. package/dist/core/guardrails.js +149 -0
  32. package/dist/core/guardrails.js.map +1 -0
  33. package/dist/core/loader.d.ts +6 -2
  34. package/dist/core/loader.d.ts.map +1 -1
  35. package/dist/core/loader.js +23 -9
  36. package/dist/core/loader.js.map +1 -1
  37. package/dist/core/permissions.d.ts +60 -0
  38. package/dist/core/permissions.d.ts.map +1 -0
  39. package/dist/core/permissions.js +252 -0
  40. package/dist/core/permissions.js.map +1 -0
  41. package/dist/core/registry.d.ts.map +1 -1
  42. package/dist/core/registry.js +16 -0
  43. package/dist/core/registry.js.map +1 -1
  44. package/dist/core/skills.d.ts.map +1 -1
  45. package/dist/core/skills.js +26 -3
  46. package/dist/core/skills.js.map +1 -1
  47. package/dist/core/types.d.ts +20 -2
  48. package/dist/core/types.d.ts.map +1 -1
  49. package/dist/memory/__tests__/memory.test.js +33 -3
  50. package/dist/memory/__tests__/memory.test.js.map +1 -1
  51. package/dist/memory/memory.d.ts.map +1 -1
  52. package/dist/memory/memory.js +25 -3
  53. package/dist/memory/memory.js.map +1 -1
  54. package/package.json +2 -2
package/dist/cli/repl.js CHANGED
@@ -1,8 +1,9 @@
1
- import { createInterface } from 'node:readline';
1
+ import { createInterface, emitKeypressEvents } from 'node:readline';
2
2
  import { loadConfig, saveConfig, configPath } from '../core/config.js';
3
3
  import { searchRegistry, installSkill, removeSkill, formatInstalls } from '../core/registry.js';
4
4
  import { C, renderMarkdown } from '../core/colors.js';
5
5
  import { VERSION } from '../core/version.js';
6
+ import { inferPattern } from '../core/permissions.js';
6
7
  function truncateCommand(command, maxLen = 80) {
7
8
  const firstLine = command.split('\n')[0];
8
9
  if (firstLine.length <= maxLen) {
@@ -19,29 +20,90 @@ function formatToolSummary(toolName, args) {
19
20
  default: return `${toolName}(${JSON.stringify(args).slice(0, 80)})`;
20
21
  }
21
22
  }
23
+ function promptQuestion(prompt, sharedRl) {
24
+ if (sharedRl) {
25
+ // Shared readline already manages terminal — no raw mode changes needed
26
+ return new Promise(resolve => sharedRl.question(prompt, resolve));
27
+ }
28
+ // Fallback: create a temporary readline; exit raw mode so it can work properly
29
+ const wasRaw = process.stdin.isTTY && process.stdin.isRaw;
30
+ if (wasRaw)
31
+ process.stdin.setRawMode(false);
32
+ return new Promise(resolve => {
33
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
34
+ rl.question(prompt, a => { rl.close(); resolve(a); });
35
+ }).finally(() => { if (wasRaw)
36
+ process.stdin.setRawMode(true); });
37
+ }
38
+ const SIMPLE_OPTS = ` ${C.cyan}y${C.reset}/${C.cyan}n${C.reset} > `;
39
+ const FULL_OPTS = ` ${C.cyan}y${C.reset}/${C.cyan}n${C.reset} ${C.dim}a${C.reset}=${C.dim}always ${C.dim}p${C.reset}=${C.dim}project ${C.dim}d${C.reset}=${C.dim}deny session ${C.dim}D${C.reset}=${C.dim}deny project${C.reset} > `;
22
40
  export function createConfirmCallback(sharedRl) {
23
41
  const cb = async (toolName, args) => {
24
- // Stop any active spinner before prompting
42
+ // Stop any active spinner before prompting — clear line to avoid garbled output
25
43
  if (cb.pauseSpinner) {
26
44
  cb.pauseSpinner();
27
45
  cb.pauseSpinner = null;
28
46
  }
47
+ process.stderr.write('\x1b[K'); // ensure current line is clear
29
48
  const summary = formatToolSummary(toolName, args);
30
- if (sharedRl) {
31
- return new Promise(resolve => {
32
- sharedRl.question(` ${C.yellow}?${C.reset} ${C.dim}${summary}${C.reset} ${C.yellow}(y/n)${C.reset} `, answer => {
33
- resolve(answer.trim().toLowerCase() === 'n' ? 'deny' : 'allow');
34
- });
35
- });
49
+ process.stderr.write(` ${C.yellow}?${C.reset} ${C.dim}${summary}${C.reset}\n`);
50
+ const answer = await promptQuestion(SIMPLE_OPTS, sharedRl);
51
+ if (answer.trim().toLowerCase() === 'n') {
52
+ process.stderr.write(` ${C.red}−${C.reset} ${C.dim}Denied${C.reset}\n`);
53
+ return 'deny';
54
+ }
55
+ return 'allow';
56
+ };
57
+ cb.pauseSpinner = null;
58
+ return cb;
59
+ }
60
+ export function createPermissionCallback(pm, sharedRl) {
61
+ const cb = async (toolName, args, permissionKey) => {
62
+ // Check permission rules first
63
+ const decision = pm.check(toolName, args, permissionKey);
64
+ if (decision === 'allow')
65
+ return 'allow';
66
+ if (decision === 'deny')
67
+ return 'deny';
68
+ // Stop any active spinner before prompting — clear line to avoid garbled output
69
+ if (cb.pauseSpinner) {
70
+ cb.pauseSpinner();
71
+ cb.pauseSpinner = null;
72
+ }
73
+ process.stderr.write('\x1b[K'); // ensure current line is clear
74
+ const summary = formatToolSummary(toolName, args);
75
+ const pattern = inferPattern(toolName, args, permissionKey);
76
+ process.stderr.write(` ${C.yellow}?${C.reset} ${C.dim}${summary}${C.reset}\n`);
77
+ const answer = await promptQuestion(FULL_OPTS, sharedRl);
78
+ // Check for uppercase D before lowercasing
79
+ const raw = answer.trim();
80
+ if (raw === 'D') {
81
+ pm.addRule({ pattern, effect: 'deny' }, 'project');
82
+ pm.save('project');
83
+ process.stderr.write(` ${C.red}−${C.reset} ${C.dim}Saved deny to .ag/permissions.json: ${pattern}${C.reset}\n`);
84
+ return 'deny';
85
+ }
86
+ const choice = raw.toLowerCase();
87
+ switch (choice) {
88
+ case 'a':
89
+ pm.addRule({ pattern, effect: 'allow' }, 'session');
90
+ process.stderr.write(` ${C.green}+${C.reset} ${C.dim}Session rule: ${pattern}${C.reset}\n`);
91
+ return 'allow';
92
+ case 'p':
93
+ pm.addRule({ pattern, effect: 'allow' }, 'project');
94
+ pm.save('project');
95
+ process.stderr.write(` ${C.green}+${C.reset} ${C.dim}Saved to .ag/permissions.json: ${pattern}${C.reset}\n`);
96
+ return 'allow';
97
+ case 'n':
98
+ process.stderr.write(` ${C.red}−${C.reset} ${C.dim}Denied: ${pattern}${C.reset}\n`);
99
+ return 'deny';
100
+ case 'd':
101
+ pm.addRule({ pattern, effect: 'deny' }, 'session');
102
+ process.stderr.write(` ${C.red}−${C.reset} ${C.dim}Session deny: ${pattern}${C.reset}\n`);
103
+ return 'deny';
104
+ default: // 'y' or anything else = allow once
105
+ return 'allow';
36
106
  }
37
- // Fallback: create a temporary readline (non-REPL usage)
38
- const rl = createInterface({ input: process.stdin, output: process.stderr });
39
- return new Promise(resolve => {
40
- rl.question(` ${C.yellow}?${C.reset} ${C.dim}${summary}${C.reset} ${C.yellow}(y/n)${C.reset} `, answer => {
41
- rl.close();
42
- resolve(answer.trim().toLowerCase() === 'n' ? 'deny' : 'allow');
43
- });
44
- });
45
107
  };
46
108
  cb.pauseSpinner = null;
47
109
  return cb;
@@ -57,6 +119,86 @@ function startSpinner(label) {
57
119
  }, 80);
58
120
  return () => { clearInterval(id); process.stderr.write('\x1b[A\x1b[K'); };
59
121
  }
122
+ // ── Context breakdown bar ────────────────────────────────────────────────────
123
+ // ANSI 256-color codes for distinct, readable segment colors
124
+ const SEGMENT_COLORS = [
125
+ '\x1b[38;5;75m', // blue
126
+ '\x1b[38;5;114m', // green
127
+ '\x1b[38;5;179m', // gold
128
+ '\x1b[38;5;204m', // pink
129
+ '\x1b[38;5;141m', // purple
130
+ '\x1b[38;5;80m', // teal
131
+ '\x1b[38;5;215m', // orange
132
+ '\x1b[38;5;109m', // steel
133
+ '\x1b[38;5;168m', // magenta
134
+ '\x1b[38;5;150m', // lime
135
+ ];
136
+ function renderContextBreakdown(parts, contextLength, actualPromptTokens) {
137
+ const nc = 'NO_COLOR' in process.env || !process.stderr.isTTY;
138
+ const R = nc ? '' : '\x1b[0m';
139
+ const DIM = nc ? '' : '\x1b[2m';
140
+ const totalChars = parts.reduce((sum, p) => sum + p.chars, 0);
141
+ if (totalChars === 0)
142
+ return `${DIM}No context data yet.${R}`;
143
+ // Use actual tokens when available; fall back to char estimate
144
+ const hasActual = actualPromptTokens !== null && actualPromptTokens > 0;
145
+ const totalTokens = hasActual ? actualPromptTokens : Math.ceil(totalChars / 4);
146
+ const prefix = hasActual ? '' : '~';
147
+ const BAR_WIDTH = 50;
148
+ const lines = [];
149
+ // ── Compute segments: proportional share of chars → scaled to total tokens ──
150
+ const segments = [];
151
+ let assignedBlocks = 0;
152
+ for (let i = 0; i < parts.length; i++) {
153
+ const pct = (parts[i].chars / totalChars) * 100;
154
+ const tokens = Math.round((parts[i].chars / totalChars) * totalTokens);
155
+ const blocks = Math.max(pct >= 1 ? 1 : 0, Math.round((parts[i].chars / totalChars) * BAR_WIDTH));
156
+ const color = nc ? '' : SEGMENT_COLORS[i % SEGMENT_COLORS.length];
157
+ segments.push({ label: parts[i].label, tokens, pct, color, blockCount: blocks });
158
+ assignedBlocks += blocks;
159
+ }
160
+ // Adjust to fit BAR_WIDTH exactly
161
+ if (assignedBlocks > BAR_WIDTH && segments.length > 0) {
162
+ const largest = segments.reduce((a, b) => a.blockCount > b.blockCount ? a : b);
163
+ largest.blockCount -= (assignedBlocks - BAR_WIDTH);
164
+ }
165
+ else if (assignedBlocks < BAR_WIDTH && segments.length > 0) {
166
+ const largest = segments.reduce((a, b) => a.blockCount > b.blockCount ? a : b);
167
+ largest.blockCount += (BAR_WIDTH - assignedBlocks);
168
+ }
169
+ // ── Segmented bar ──
170
+ let bar = '';
171
+ for (const seg of segments) {
172
+ bar += `${seg.color}${'█'.repeat(Math.max(0, seg.blockCount))}${R}`;
173
+ }
174
+ if (contextLength) {
175
+ const usedPct = Math.round((totalTokens / contextLength) * 100);
176
+ const usedBlocks = segments.reduce((s, seg) => s + seg.blockCount, 0);
177
+ const freeBlocks = BAR_WIDTH - usedBlocks;
178
+ if (freeBlocks > 0)
179
+ bar += `${DIM}${'░'.repeat(freeBlocks)}${R}`;
180
+ lines.push(` ${bar} ${DIM}${prefix}${formatTokensShort(totalTokens)}/${formatTokensShort(contextLength)} (${usedPct}%)${R}`);
181
+ }
182
+ else {
183
+ lines.push(` ${bar} ${DIM}${prefix}${formatTokensShort(totalTokens)} tokens${R}`);
184
+ }
185
+ // ── Legend ──
186
+ lines.push('');
187
+ for (const seg of segments) {
188
+ const pctStr = seg.pct < 1 ? '<1' : Math.round(seg.pct).toString();
189
+ lines.push(` ${seg.color}█${R} ${seg.label.padEnd(18)} ${DIM}${pctStr.padStart(3)}% ${prefix}${formatTokensShort(seg.tokens)}${R}`);
190
+ }
191
+ return lines.join('\n');
192
+ }
193
+ function formatTokensShort(n) {
194
+ if (n < 1000)
195
+ return `${n}`;
196
+ if (n < 10000)
197
+ return `${(n / 1000).toFixed(1)}K`;
198
+ if (n < 1000000)
199
+ return `${Math.round(n / 1000)}K`;
200
+ return `${(n / 1000000).toFixed(1)}M`;
201
+ }
60
202
  function maskKey(key) {
61
203
  if (key.length <= 8)
62
204
  return '****';
@@ -65,12 +207,21 @@ function maskKey(key) {
65
207
  export class REPL {
66
208
  agent;
67
209
  rl;
210
+ pm;
68
211
  confirmCb;
69
- constructor(agent, confirmCb) {
212
+ constructor(agent, pm, confirmCb) {
70
213
  this.agent = agent;
214
+ this.pm = pm ?? null;
71
215
  this.rl = createInterface({ input: process.stdin, output: process.stderr });
72
216
  // Rebind the confirm callback to use the shared readline
73
- if (confirmCb) {
217
+ if (confirmCb && pm) {
218
+ const shared = createPermissionCallback(pm, this.rl);
219
+ shared.pauseSpinner = confirmCb.pauseSpinner;
220
+ this.confirmCb = shared;
221
+ this.agent.setConfirmToolCall(shared);
222
+ }
223
+ else if (confirmCb) {
224
+ // Fallback: no permission manager, use basic confirm
74
225
  const shared = createConfirmCallback(this.rl);
75
226
  shared.pauseSpinner = confirmCb.pauseSpinner;
76
227
  this.confirmCb = shared;
@@ -83,16 +234,28 @@ export class REPL {
83
234
  async start() {
84
235
  const stats = this.agent.getStats();
85
236
  const skills = this.agent.getSkills();
237
+ console.error('');
86
238
  console.error(`${C.bold}ag v${VERSION}${C.reset} ${C.dim}(${this.agent.getModel()} via OpenRouter)${C.reset}`);
239
+ const customTools = this.agent.getTools().filter(t => !t.isBuiltin);
87
240
  const loaded = [
88
241
  stats.globalMemory && 'global',
89
242
  stats.projectMemory && 'project',
90
243
  stats.planCount > 0 && `${stats.planCount} plan(s)`,
91
244
  skills.length > 0 && `${skills.length} skill(s)`,
245
+ customTools.length > 0 && `${customTools.length} tool(s)`,
92
246
  ].filter(Boolean);
93
247
  if (loaded.length > 0) {
94
248
  console.error(`${C.dim}Loaded: ${loaded.join(', ')}${C.reset}`);
95
249
  }
250
+ for (const t of customTools) {
251
+ const desc = t.description.slice(0, 60) + (t.description.length > 60 ? '...' : '');
252
+ console.error(` ${C.green}+${C.reset} ${C.cyan}${t.name}${C.reset} ${C.dim}${desc}${C.reset}`);
253
+ }
254
+ for (const f of this.agent.getToolFailures()) {
255
+ const label = f.name ?? f.file;
256
+ const reason = f.reason.split('\n')[0].slice(0, 60) + (f.reason.length > 60 ? '...' : '');
257
+ console.error(` ${C.red}−${C.reset} ${C.cyan}${label}${C.reset} ${C.red}${reason}${C.reset}`);
258
+ }
96
259
  const activePlan = this.agent.getActivePlanName();
97
260
  if (activePlan) {
98
261
  const label = activePlan.replace(/^\d{4}-\d{2}-\d{2}T[\d-]+-?/, '').replace(/-/g, ' ').trim() || activePlan;
@@ -113,12 +276,15 @@ export class REPL {
113
276
  }
114
277
  catch { /* proceed without — fallback handled by tracker */ }
115
278
  }
116
- tracker.estimateFromChars(this.agent.getSystemPromptSize());
279
+ tracker.estimateFromChars(this.agent.getTotalContextChars());
117
280
  const ctxLine = this.agent.getContextUsage();
118
281
  console.error(`${C.dim}Commands: /help${C.reset}`);
119
282
  if (ctxLine)
120
283
  console.error(ctxLine);
121
284
  console.error('');
285
+ // Enable keypress events on stdin for interrupt detection
286
+ if (process.stdin.isTTY)
287
+ emitKeypressEvents(process.stdin);
122
288
  while (true) {
123
289
  try {
124
290
  const input = await this.ask(`${C.green}you>${C.reset} `);
@@ -129,111 +295,159 @@ export class REPL {
129
295
  continue;
130
296
  }
131
297
  let hitMaxIterations = false;
132
- const processStream = async (stream) => {
298
+ let interrupted = false;
299
+ const runAgent = async (message) => {
300
+ const controller = new AbortController();
301
+ let activeSpinnerStop = null;
302
+ // ── Escape key listener: immediate visual feedback ──
303
+ const onKeypress = (_ch, key) => {
304
+ if (key?.name === 'escape') {
305
+ // 1. Clear whatever spinner is showing
306
+ if (activeSpinnerStop) {
307
+ activeSpinnerStop();
308
+ activeSpinnerStop = null;
309
+ }
310
+ // 2. Show "interrupting..." spinner immediately (sync)
311
+ activeSpinnerStop = startSpinner(`${C.yellow}interrupting...${C.reset}`);
312
+ // 3. Signal abort (async propagation begins)
313
+ controller.abort();
314
+ }
315
+ };
316
+ if (process.stdin.isTTY) {
317
+ process.stdin.setRawMode(true);
318
+ process.stdin.on('keypress', onKeypress);
319
+ process.stdin.resume();
320
+ }
133
321
  let hasText = false;
134
322
  let hadTools = false;
135
- let stopSpinnerFn = null;
136
323
  let lineBuf = '';
137
- // Keep confirm callback's pauseSpinner in sync with current spinner
138
324
  const setSpinner = (fn) => {
139
- stopSpinnerFn = fn;
325
+ activeSpinnerStop = fn;
140
326
  if (this.confirmCb)
141
327
  this.confirmCb.pauseSpinner = fn;
142
328
  };
143
329
  const clearSpinner = () => {
144
- if (stopSpinnerFn) {
145
- stopSpinnerFn();
330
+ if (activeSpinnerStop) {
331
+ activeSpinnerStop();
146
332
  setSpinner(null);
147
333
  }
148
334
  };
149
- // Render completed lines as markdown, write them out
150
335
  const flushLines = (final) => {
151
336
  const parts = lineBuf.split('\n');
152
- // Keep the last partial line in the buffer (unless final flush)
153
337
  lineBuf = final ? '' : (parts.pop() || '');
154
338
  for (let i = 0; i < parts.length; i++) {
155
- const rendered = renderMarkdown(parts[i]);
156
- process.stderr.write(rendered + '\n');
339
+ process.stderr.write(renderMarkdown(parts[i]) + '\n');
157
340
  }
158
341
  if (final && lineBuf) {
159
342
  process.stderr.write(renderMarkdown(lineBuf));
160
343
  lineBuf = '';
161
344
  }
162
345
  };
163
- for await (const chunk of stream) {
164
- switch (chunk.type) {
165
- case 'thinking':
166
- clearSpinner();
167
- if (hasText) {
168
- flushLines(true);
169
- process.stderr.write('\n');
170
- hasText = false;
171
- }
172
- setSpinner(startSpinner(chunk.content || 'thinking'));
173
- break;
174
- case 'text':
175
- clearSpinner();
176
- if (!hasText) {
177
- if (hadTools)
346
+ try {
347
+ for await (const chunk of this.agent.chatStream(message, controller.signal)) {
348
+ switch (chunk.type) {
349
+ case 'thinking':
350
+ clearSpinner();
351
+ if (hasText) {
352
+ flushLines(true);
178
353
  process.stderr.write('\n');
179
- process.stderr.write(`${C.bold}agent>${C.reset} `);
180
- hasText = true;
181
- }
182
- lineBuf += (chunk.content || '');
183
- if (lineBuf.includes('\n'))
184
- flushLines(false);
185
- break;
186
- case 'tool_start': {
187
- clearSpinner();
188
- if (hasText) {
189
- flushLines(true);
190
- process.stderr.write('\n');
191
- hasText = false;
354
+ hasText = false;
355
+ }
356
+ setSpinner(startSpinner(chunk.content || 'thinking'));
357
+ break;
358
+ case 'text':
359
+ clearSpinner();
360
+ if (!hasText) {
361
+ if (hadTools)
362
+ process.stderr.write('\n');
363
+ process.stderr.write(`${C.bold}agent>${C.reset} `);
364
+ hasText = true;
365
+ }
366
+ lineBuf += (chunk.content || '');
367
+ if (lineBuf.includes('\n'))
368
+ flushLines(false);
369
+ break;
370
+ case 'tool_start': {
371
+ clearSpinner();
372
+ if (hasText) {
373
+ flushLines(true);
374
+ process.stderr.write('\n');
375
+ hasText = false;
376
+ }
377
+ const cmdPreview = truncateCommand(chunk.content || '', 60);
378
+ setSpinner(startSpinner(`[${chunk.toolName}] ${cmdPreview}`));
379
+ break;
192
380
  }
193
- const cmdPreview = truncateCommand(chunk.content || '', 60);
194
- setSpinner(startSpinner(`[${chunk.toolName}] ${cmdPreview}`));
195
- break;
196
- }
197
- case 'tool_end': {
198
- clearSpinner();
199
- hadTools = true;
200
- // Resolve label at tool_end time — skills may have been activated during this batch
201
- let endLabel = chunk.toolName || '';
202
- const activeSkills = this.agent.getActiveSkillNames();
203
- if (endLabel === 'bash' && activeSkills.length > 0) {
204
- endLabel = `${endLabel} via ${activeSkills[activeSkills.length - 1]}`;
381
+ case 'tool_end': {
382
+ clearSpinner();
383
+ hadTools = true;
384
+ let endLabel = chunk.toolName || '';
385
+ const activeSkills = this.agent.getActiveSkillNames();
386
+ if (endLabel === 'bash' && activeSkills.length > 0) {
387
+ endLabel = `${endLabel} via ${activeSkills[activeSkills.length - 1]}`;
388
+ }
389
+ const icon = chunk.success ? `${C.green}✓` : `${C.red}✗`;
390
+ const preview = (chunk.content || '').slice(0, 150).split('\n')[0];
391
+ process.stderr.write(` ${icon} ${C.dim}[${endLabel}]${C.reset} ${C.dim}${preview}${(chunk.content || '').length > 150 ? '...' : ''}${C.reset}\n`);
392
+ break;
205
393
  }
206
- const icon = chunk.success ? `${C.green}✓` : `${C.red}✗`;
207
- const preview = (chunk.content || '').slice(0, 150).split('\n')[0];
208
- process.stderr.write(` ${icon} ${C.dim}[${endLabel}]${C.reset} ${C.dim}${preview}${(chunk.content || '').length > 150 ? '...' : ''}${C.reset}\n`);
209
- break;
394
+ case 'done':
395
+ clearSpinner();
396
+ if (hasText) {
397
+ flushLines(true);
398
+ process.stderr.write('\n\n');
399
+ }
400
+ else if (!hadTools)
401
+ process.stderr.write(`${C.bold}agent>${C.reset} ${renderMarkdown(chunk.content || '')}\n\n`);
402
+ break;
403
+ case 'max_iterations':
404
+ clearSpinner();
405
+ hitMaxIterations = true;
406
+ break;
407
+ case 'interrupted':
408
+ clearSpinner();
409
+ interrupted = true;
410
+ if (hasText) {
411
+ flushLines(true);
412
+ process.stderr.write('\n');
413
+ }
414
+ const summary = chunk.content || 'stopped';
415
+ process.stderr.write(` ${C.yellow}⚡ Interrupted${C.reset} ${C.dim}(${summary})${C.reset}\n\n`);
416
+ break;
210
417
  }
211
- case 'done':
212
- clearSpinner();
213
- if (hasText) {
214
- flushLines(true);
215
- process.stderr.write('\n\n');
216
- }
217
- else if (!hadTools)
218
- process.stderr.write(`${C.bold}agent>${C.reset} ${renderMarkdown(chunk.content || '')}\n\n`);
219
- break;
220
- case 'max_iterations':
221
- clearSpinner();
222
- hitMaxIterations = true;
223
- break;
418
+ }
419
+ clearSpinner();
420
+ }
421
+ catch (e) {
422
+ clearSpinner();
423
+ // AbortError from fetch is handled inside chatStream; re-throw others
424
+ if (!(e instanceof DOMException && e.name === 'AbortError'))
425
+ throw e;
426
+ if (!interrupted) {
427
+ interrupted = true;
428
+ process.stderr.write(` ${C.yellow}⚡ Interrupted${C.reset}\n\n`);
429
+ }
430
+ }
431
+ finally {
432
+ if (process.stdin.isTTY) {
433
+ process.stdin.removeListener('keypress', onKeypress);
434
+ process.stdin.setRawMode(false);
224
435
  }
225
436
  }
226
- clearSpinner();
227
437
  };
228
- await processStream(this.agent.chatStream(input));
229
- while (hitMaxIterations) {
438
+ await runAgent(input);
439
+ while (hitMaxIterations && !interrupted) {
230
440
  console.error(`${C.yellow}Reached iteration limit.${C.reset}`);
231
441
  const answer = await this.ask(`${C.yellow}Continue? (y/n)>${C.reset} `);
232
442
  if (answer.trim().toLowerCase() !== 'y')
233
443
  break;
234
444
  hitMaxIterations = false;
235
- await processStream(this.agent.chatStream('continue where you left off'));
445
+ await runAgent('continue where you left off');
236
446
  }
447
+ // Re-estimate if API didn't return usage (ensures bar reflects current state)
448
+ const tracker = this.agent.getContextTracker();
449
+ if (!tracker.getUsedTokens())
450
+ tracker.estimateFromChars(this.agent.getTotalContextChars());
237
451
  const ctx = this.agent.getContextUsage();
238
452
  if (ctx)
239
453
  console.error(ctx);
@@ -261,7 +475,7 @@ export class REPL {
261
475
  console.error(` ${C.cyan}/plan${C.reset} Show current plan`);
262
476
  console.error(` ${C.cyan}/plan list${C.reset} List all plans`);
263
477
  console.error(` ${C.cyan}/plan use <name>${C.reset} Activate an older plan`);
264
- console.error(` ${C.cyan}/context${C.reset} Show context window usage`);
478
+ console.error(` ${C.cyan}/context${C.reset} Show context breakdown + usage`);
265
479
  console.error(` ${C.cyan}/context compact${C.reset} Force context compaction now`);
266
480
  console.error(` ${C.cyan}/config${C.reset} Show config + file paths`);
267
481
  console.error(` ${C.cyan}/config set <k> <v>${C.reset} Set a config value`);
@@ -271,6 +485,11 @@ export class REPL {
271
485
  console.error(` ${C.cyan}/skill search [query]${C.reset} Search skills.sh`);
272
486
  console.error(` ${C.cyan}/skill add <source>${C.reset} Install skill from registry`);
273
487
  console.error(` ${C.cyan}/skill remove <name>${C.reset} Uninstall a skill`);
488
+ console.error(` ${C.cyan}/permissions${C.reset} Show permission rules`);
489
+ console.error(` ${C.cyan}/permissions allow <p>${C.reset} Add allow rule (session)`);
490
+ console.error(` ${C.cyan}/permissions deny <p>${C.reset} Add deny rule (session)`);
491
+ console.error(` ${C.cyan}/permissions save${C.reset} Save session rules to project`);
492
+ console.error(` ${C.cyan}/permissions clear${C.reset} Clear session rules`);
274
493
  console.error(` ${C.cyan}/exit${C.reset} Exit`);
275
494
  console.error('');
276
495
  break;
@@ -318,7 +537,7 @@ export class REPL {
318
537
  }
319
538
  catch { /* proceed without */ }
320
539
  }
321
- tracker.estimateFromChars(this.agent.getSystemPromptSize());
540
+ tracker.estimateFromChars(this.agent.getTotalContextChars());
322
541
  const ctxLine = this.agent.getContextUsage();
323
542
  console.error(`${C.yellow}Model set to: ${this.agent.getModel()} (saved)${C.reset}`);
324
543
  if (ctxLine)
@@ -427,6 +646,11 @@ export class REPL {
427
646
  else {
428
647
  console.error(`${C.bold}Context Window${C.reset}`);
429
648
  console.error(this.agent.getContextDetails());
649
+ console.error('');
650
+ console.error(`${C.bold}Breakdown${C.reset}`);
651
+ const breakdown = this.agent.getContextBreakdown();
652
+ const tracker = this.agent.getContextTracker();
653
+ console.error(renderContextBreakdown(breakdown, tracker.getContextLength(), tracker.getUsedTokens()));
430
654
  }
431
655
  console.error('');
432
656
  break;
@@ -462,12 +686,16 @@ export class REPL {
462
686
  // Apply immediately: toggle confirmation prompts in this session
463
687
  if (parsed) {
464
688
  this.agent.setConfirmToolCall(null);
689
+ this.confirmCb = null;
465
690
  console.error(`${C.green}Auto-approve enabled — tool calls will no longer prompt.${C.reset}`);
466
691
  }
467
692
  else {
468
- const freshCb = createConfirmCallback(this.rl);
693
+ const freshCb = this.pm
694
+ ? createPermissionCallback(this.pm, this.rl)
695
+ : createConfirmCallback(this.rl);
469
696
  if (this.confirmCb)
470
697
  freshCb.pauseSpinner = this.confirmCb.pauseSpinner;
698
+ this.confirmCb = freshCb;
471
699
  this.agent.setConfirmToolCall(freshCb);
472
700
  console.error(`${C.yellow}Auto-approve disabled — tool calls will prompt again.${C.reset}`);
473
701
  }
@@ -491,9 +719,12 @@ export class REPL {
491
719
  this.agent.setModel('anthropic/claude-sonnet-4.6');
492
720
  }
493
721
  if (key === 'autoApprove') {
494
- const freshCb = createConfirmCallback(this.rl);
722
+ const freshCb = this.pm
723
+ ? createPermissionCallback(this.pm, this.rl)
724
+ : createConfirmCallback(this.rl);
495
725
  if (this.confirmCb)
496
726
  freshCb.pauseSpinner = this.confirmCb.pauseSpinner;
727
+ this.confirmCb = freshCb;
497
728
  this.agent.setConfirmToolCall(freshCb);
498
729
  }
499
730
  console.error(`${C.yellow}Config: ${key} removed${C.reset}\n`);
@@ -533,7 +764,8 @@ export class REPL {
533
764
  const tools = this.agent.getTools();
534
765
  console.error(`${C.bold}Tools (${tools.length}):${C.reset}`);
535
766
  for (const t of tools) {
536
- console.error(` ${C.cyan}${t.name}${C.reset} ${C.dim}${t.description.slice(0, 60)}${t.description.length > 60 ? '...' : ''}${C.reset}`);
767
+ const prefix = t.isBuiltin ? ' ' : `${C.green}+${C.reset}`;
768
+ console.error(` ${prefix} ${C.cyan}${t.name}${C.reset} ${C.dim}${t.description.slice(0, 60)}${t.description.length > 60 ? '...' : ''}${C.reset}`);
537
769
  }
538
770
  console.error('');
539
771
  break;
@@ -606,6 +838,69 @@ export class REPL {
606
838
  }
607
839
  break;
608
840
  }
841
+ // ── /permissions ────────────────────────────────────────────────────
842
+ case 'permissions':
843
+ case 'perms': {
844
+ if (!this.pm) {
845
+ console.error(`${C.dim}Permissions not available (auto-approve mode).${C.reset}\n`);
846
+ break;
847
+ }
848
+ const subCmd = args[0]?.toLowerCase();
849
+ if (subCmd === 'allow' && args[1]) {
850
+ const pattern = args.slice(1).join(' ');
851
+ this.pm.addRule({ pattern, effect: 'allow' }, 'session');
852
+ console.error(`${C.green}+ Session allow: ${pattern}${C.reset}\n`);
853
+ }
854
+ else if (subCmd === 'deny' && args[1]) {
855
+ const pattern = args.slice(1).join(' ');
856
+ this.pm.addRule({ pattern, effect: 'deny' }, 'session');
857
+ console.error(`${C.green}+ Session deny: ${pattern}${C.reset}\n`);
858
+ }
859
+ else if (subCmd === 'save') {
860
+ const scope = args[1]?.toLowerCase() === 'global' ? 'global' : 'project';
861
+ this.pm.save(scope);
862
+ console.error(`${C.yellow}Saved to ${scope} permissions.${C.reset}\n`);
863
+ }
864
+ else if (subCmd === 'clear') {
865
+ const scope = args[1]?.toLowerCase();
866
+ if (scope === 'project' || scope === 'global') {
867
+ this.pm.clear(scope);
868
+ this.pm.save(scope);
869
+ console.error(`${C.yellow}Cleared ${scope} permissions.${C.reset}\n`);
870
+ }
871
+ else {
872
+ this.pm.clear('session');
873
+ console.error(`${C.yellow}Cleared session permissions.${C.reset}\n`);
874
+ }
875
+ }
876
+ else if (subCmd === 'remove' && args[1]) {
877
+ const pattern = args.slice(1).join(' ');
878
+ const removed = this.pm.removeRule(pattern, 'session')
879
+ || this.pm.removeRule(pattern, 'project')
880
+ || this.pm.removeRule(pattern, 'global');
881
+ if (removed) {
882
+ console.error(`${C.yellow}Removed: ${pattern}${C.reset}\n`);
883
+ }
884
+ else {
885
+ console.error(`${C.dim}No matching rule found.${C.reset}\n`);
886
+ }
887
+ }
888
+ else {
889
+ // List all rules
890
+ const rules = this.pm.getRules();
891
+ if (rules.length === 0) {
892
+ console.error(`${C.dim}No permission rules. Approve with (a)lways or (p)roject to add rules.${C.reset}\n`);
893
+ break;
894
+ }
895
+ console.error(`${C.bold}Permission Rules:${C.reset}`);
896
+ for (const r of rules) {
897
+ const icon = r.effect === 'allow' ? `${C.green}✓` : `${C.red}✗`;
898
+ console.error(` ${icon} ${C.dim}[${r.scope}]${C.reset} ${r.effect} ${C.cyan}${r.pattern}${C.reset}`);
899
+ }
900
+ console.error('');
901
+ }
902
+ break;
903
+ }
609
904
  // ── /exit ─────────────────────────────────────────────────────────
610
905
  case 'exit':
611
906
  case 'quit':