@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.
- package/README.md +106 -4
- package/dist/cli/repl.d.ts +7 -2
- package/dist/cli/repl.d.ts.map +1 -1
- package/dist/cli/repl.js +390 -95
- package/dist/cli/repl.js.map +1 -1
- package/dist/cli.js +18 -8
- package/dist/cli.js.map +1 -1
- package/dist/core/__tests__/agent.test.js +42 -1
- package/dist/core/__tests__/agent.test.js.map +1 -1
- package/dist/core/__tests__/context.test.js +37 -0
- package/dist/core/__tests__/context.test.js.map +1 -1
- package/dist/core/__tests__/guardrails.test.d.ts +2 -0
- package/dist/core/__tests__/guardrails.test.d.ts.map +1 -0
- package/dist/core/__tests__/guardrails.test.js +400 -0
- package/dist/core/__tests__/guardrails.test.js.map +1 -0
- package/dist/core/__tests__/permission-manager.test.d.ts +2 -0
- package/dist/core/__tests__/permission-manager.test.d.ts.map +1 -0
- package/dist/core/__tests__/permission-manager.test.js +246 -0
- package/dist/core/__tests__/permission-manager.test.js.map +1 -0
- package/dist/core/__tests__/streaming.test.js +8 -1
- package/dist/core/__tests__/streaming.test.js.map +1 -1
- package/dist/core/agent.d.ts +20 -2
- package/dist/core/agent.d.ts.map +1 -1
- package/dist/core/agent.js +259 -82
- package/dist/core/agent.js.map +1 -1
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +17 -2
- package/dist/core/context.js.map +1 -1
- package/dist/core/guardrails.d.ts +32 -0
- package/dist/core/guardrails.d.ts.map +1 -0
- package/dist/core/guardrails.js +149 -0
- package/dist/core/guardrails.js.map +1 -0
- package/dist/core/loader.d.ts +6 -2
- package/dist/core/loader.d.ts.map +1 -1
- package/dist/core/loader.js +23 -9
- package/dist/core/loader.js.map +1 -1
- package/dist/core/permissions.d.ts +60 -0
- package/dist/core/permissions.d.ts.map +1 -0
- package/dist/core/permissions.js +252 -0
- package/dist/core/permissions.js.map +1 -0
- package/dist/core/registry.d.ts.map +1 -1
- package/dist/core/registry.js +16 -0
- package/dist/core/registry.js.map +1 -1
- package/dist/core/skills.d.ts.map +1 -1
- package/dist/core/skills.js +26 -3
- package/dist/core/skills.js.map +1 -1
- package/dist/core/types.d.ts +20 -2
- package/dist/core/types.d.ts.map +1 -1
- package/dist/memory/__tests__/memory.test.js +33 -3
- package/dist/memory/__tests__/memory.test.js.map +1 -1
- package/dist/memory/memory.d.ts.map +1 -1
- package/dist/memory/memory.js +25 -3
- package/dist/memory/memory.js.map +1 -1
- 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
325
|
+
activeSpinnerStop = fn;
|
|
140
326
|
if (this.confirmCb)
|
|
141
327
|
this.confirmCb.pauseSpinner = fn;
|
|
142
328
|
};
|
|
143
329
|
const clearSpinner = () => {
|
|
144
|
-
if (
|
|
145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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':
|