@blockrun/runcode 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/commands.d.ts +27 -0
- package/dist/agent/commands.js +276 -0
- package/dist/agent/compact.js +5 -4
- package/dist/agent/llm.js +10 -2
- package/dist/agent/loop.js +18 -378
- package/dist/agent/optimize.js +8 -5
- package/dist/agent/tokens.js +19 -12
- package/dist/mcp/client.d.ts +1 -0
- package/dist/mcp/client.js +26 -11
- package/dist/mcp/config.d.ts +4 -4
- package/dist/mcp/config.js +47 -5
- package/dist/session/storage.d.ts +5 -1
- package/dist/session/storage.js +19 -3
- package/dist/tools/askuser.js +1 -1
- package/dist/tools/glob.js +1 -1
- package/dist/tools/imagegen.js +5 -2
- package/dist/tools/read.js +1 -1
- package/dist/tools/write.js +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash command registry for runcode.
|
|
3
|
+
* Extracted from loop.ts for maintainability.
|
|
4
|
+
*
|
|
5
|
+
* Two types of commands:
|
|
6
|
+
* 1. "Handled" — execute directly, emit events, return { handled: true }
|
|
7
|
+
* 2. "Rewrite" — transform input into a prompt for the agent, return { handled: false, rewritten }
|
|
8
|
+
*/
|
|
9
|
+
import type { ModelClient } from './llm.js';
|
|
10
|
+
import type { AgentConfig, Dialogue, StreamEvent } from './types.js';
|
|
11
|
+
type EventEmitter = (event: StreamEvent) => void;
|
|
12
|
+
interface CommandContext {
|
|
13
|
+
history: Dialogue[];
|
|
14
|
+
config: AgentConfig;
|
|
15
|
+
client: ModelClient;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
onEvent: EventEmitter;
|
|
18
|
+
}
|
|
19
|
+
interface CommandResult {
|
|
20
|
+
handled: boolean;
|
|
21
|
+
rewritten?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Handle a slash command. Returns result indicating what happened.
|
|
25
|
+
*/
|
|
26
|
+
export declare function handleSlashCommand(input: string, ctx: CommandContext): Promise<CommandResult>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash command registry for runcode.
|
|
3
|
+
* Extracted from loop.ts for maintainability.
|
|
4
|
+
*
|
|
5
|
+
* Two types of commands:
|
|
6
|
+
* 1. "Handled" — execute directly, emit events, return { handled: true }
|
|
7
|
+
* 2. "Rewrite" — transform input into a prompt for the agent, return { handled: false, rewritten }
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { execSync } from 'node:child_process';
|
|
12
|
+
import { BLOCKRUN_DIR, VERSION } from '../config.js';
|
|
13
|
+
import { estimateHistoryTokens, getAnchoredTokenCount, getContextWindow, resetTokenAnchor } from './tokens.js';
|
|
14
|
+
import { forceCompact } from './compact.js';
|
|
15
|
+
import { listSessions, loadSessionHistory, } from '../session/storage.js';
|
|
16
|
+
// ─── Git helpers ──────────────────────────────────────────────────────────
|
|
17
|
+
function gitExec(cmd, cwd, timeout = 5000, maxBuffer) {
|
|
18
|
+
return execSync(cmd, {
|
|
19
|
+
cwd,
|
|
20
|
+
encoding: 'utf-8',
|
|
21
|
+
timeout,
|
|
22
|
+
maxBuffer: maxBuffer || 1024 * 1024,
|
|
23
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
24
|
+
}).trim();
|
|
25
|
+
}
|
|
26
|
+
function gitCmd(ctx, cmd, timeout, maxBuffer) {
|
|
27
|
+
try {
|
|
28
|
+
return gitExec(cmd, ctx.config.workingDir || process.cwd(), timeout, maxBuffer);
|
|
29
|
+
}
|
|
30
|
+
catch (e) {
|
|
31
|
+
ctx.onEvent({ kind: 'text_delta', text: `Git error: ${e.message?.split('\n')[0] || 'unknown'}\n` });
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function emitDone(ctx) {
|
|
36
|
+
ctx.onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
37
|
+
}
|
|
38
|
+
// ─── Command Definitions ──────────────────────────────────────────────────
|
|
39
|
+
// Direct-handled commands (don't go to agent)
|
|
40
|
+
const DIRECT_COMMANDS = {
|
|
41
|
+
'/stash': (ctx) => {
|
|
42
|
+
const r = gitCmd(ctx, 'git stash push -m "runcode auto-stash"', 10000);
|
|
43
|
+
if (r !== null)
|
|
44
|
+
ctx.onEvent({ kind: 'text_delta', text: r || 'No changes to stash.\n' });
|
|
45
|
+
emitDone(ctx);
|
|
46
|
+
},
|
|
47
|
+
'/unstash': (ctx) => {
|
|
48
|
+
const r = gitCmd(ctx, 'git stash pop', 10000);
|
|
49
|
+
if (r !== null)
|
|
50
|
+
ctx.onEvent({ kind: 'text_delta', text: r || 'Stash applied.\n' });
|
|
51
|
+
emitDone(ctx);
|
|
52
|
+
},
|
|
53
|
+
'/log': (ctx) => {
|
|
54
|
+
const r = gitCmd(ctx, 'git log --oneline -15 --no-color');
|
|
55
|
+
ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'No commits or not a git repo.\n' });
|
|
56
|
+
emitDone(ctx);
|
|
57
|
+
},
|
|
58
|
+
'/status': (ctx) => {
|
|
59
|
+
const r = gitCmd(ctx, 'git status --short --branch');
|
|
60
|
+
ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'Not a git repo.\n' });
|
|
61
|
+
emitDone(ctx);
|
|
62
|
+
},
|
|
63
|
+
'/diff': (ctx) => {
|
|
64
|
+
const r = gitCmd(ctx, 'git diff --stat && echo "---" && git diff', 10000, 512 * 1024);
|
|
65
|
+
ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`diff\n${r}\n\`\`\`\n` : 'No changes.\n' });
|
|
66
|
+
emitDone(ctx);
|
|
67
|
+
},
|
|
68
|
+
'/undo': (ctx) => {
|
|
69
|
+
const r = gitCmd(ctx, 'git reset --soft HEAD~1');
|
|
70
|
+
if (r !== null)
|
|
71
|
+
ctx.onEvent({ kind: 'text_delta', text: 'Last commit undone. Changes preserved in staging.\n' });
|
|
72
|
+
emitDone(ctx);
|
|
73
|
+
},
|
|
74
|
+
'/bug': (ctx) => {
|
|
75
|
+
ctx.onEvent({ kind: 'text_delta', text: 'Report issues at: https://github.com/BlockRunAI/runcode/issues\n' });
|
|
76
|
+
emitDone(ctx);
|
|
77
|
+
},
|
|
78
|
+
'/version': (ctx) => {
|
|
79
|
+
ctx.onEvent({ kind: 'text_delta', text: `RunCode v${VERSION}\n` });
|
|
80
|
+
emitDone(ctx);
|
|
81
|
+
},
|
|
82
|
+
'/mcp': async (ctx) => {
|
|
83
|
+
const { listMcpServers } = await import('../mcp/client.js');
|
|
84
|
+
const servers = listMcpServers();
|
|
85
|
+
if (servers.length === 0) {
|
|
86
|
+
ctx.onEvent({ kind: 'text_delta', text: 'No MCP servers connected.\nAdd servers to `~/.blockrun/mcp.json` or `.mcp.json` in your project.\n' });
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
let text = `**${servers.length} MCP server(s) connected:**\n\n`;
|
|
90
|
+
for (const s of servers) {
|
|
91
|
+
text += ` **${s.name}** — ${s.toolCount} tools\n`;
|
|
92
|
+
for (const t of s.tools)
|
|
93
|
+
text += ` · ${t}\n`;
|
|
94
|
+
}
|
|
95
|
+
ctx.onEvent({ kind: 'text_delta', text });
|
|
96
|
+
}
|
|
97
|
+
emitDone(ctx);
|
|
98
|
+
},
|
|
99
|
+
'/context': async (ctx) => {
|
|
100
|
+
const { estimated, apiAnchored } = getAnchoredTokenCount(ctx.history);
|
|
101
|
+
const contextWindow = getContextWindow(ctx.config.model);
|
|
102
|
+
const usagePct = ((estimated / contextWindow) * 100).toFixed(1);
|
|
103
|
+
ctx.onEvent({ kind: 'text_delta', text: `**Session Context**\n` +
|
|
104
|
+
` Model: ${ctx.config.model}\n` +
|
|
105
|
+
` Mode: ${ctx.config.permissionMode || 'default'}\n` +
|
|
106
|
+
` Messages: ${ctx.history.length}\n` +
|
|
107
|
+
` Tokens: ~${estimated.toLocaleString()} / ${(contextWindow / 1000).toFixed(0)}k (${usagePct}%)${apiAnchored ? ' ✓' : ' ~'}\n` +
|
|
108
|
+
` Session: ${ctx.sessionId}\n` +
|
|
109
|
+
` Directory: ${ctx.config.workingDir || process.cwd()}\n`
|
|
110
|
+
});
|
|
111
|
+
emitDone(ctx);
|
|
112
|
+
},
|
|
113
|
+
'/doctor': (ctx) => {
|
|
114
|
+
const checks = [];
|
|
115
|
+
try {
|
|
116
|
+
execSync('git --version', { stdio: 'pipe' });
|
|
117
|
+
checks.push('✓ git available');
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
checks.push('✗ git not found');
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
execSync('rg --version', { stdio: 'pipe' });
|
|
124
|
+
checks.push('✓ ripgrep available');
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
checks.push('⚠ ripgrep not found (using native grep fallback)');
|
|
128
|
+
}
|
|
129
|
+
checks.push(fs.existsSync(path.join(BLOCKRUN_DIR, 'wallet.json')) ? '✓ wallet configured' : '⚠ no wallet — run: runcode setup');
|
|
130
|
+
checks.push(fs.existsSync(path.join(BLOCKRUN_DIR, 'runcode-config.json')) ? '✓ config file exists' : '⚠ no config — using defaults');
|
|
131
|
+
checks.push(`✓ model: ${ctx.config.model}`);
|
|
132
|
+
checks.push(`✓ history: ${ctx.history.length} messages, ~${estimateHistoryTokens(ctx.history).toLocaleString()} tokens`);
|
|
133
|
+
checks.push(`✓ session: ${ctx.sessionId}`);
|
|
134
|
+
checks.push(`✓ version: v${VERSION}`);
|
|
135
|
+
ctx.onEvent({ kind: 'text_delta', text: `**Health Check**\n${checks.map(c => ' ' + c).join('\n')}\n` });
|
|
136
|
+
emitDone(ctx);
|
|
137
|
+
},
|
|
138
|
+
'/plan': (ctx) => {
|
|
139
|
+
if (ctx.config.permissionMode === 'plan') {
|
|
140
|
+
ctx.onEvent({ kind: 'text_delta', text: 'Already in plan mode. Use /execute to exit.\n' });
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
ctx.config.permissionMode = 'plan';
|
|
144
|
+
ctx.onEvent({ kind: 'text_delta', text: '**Plan mode active.** Tools restricted to read-only. Use /execute when ready to implement.\n' });
|
|
145
|
+
}
|
|
146
|
+
emitDone(ctx);
|
|
147
|
+
},
|
|
148
|
+
'/execute': (ctx) => {
|
|
149
|
+
if (ctx.config.permissionMode !== 'plan') {
|
|
150
|
+
ctx.onEvent({ kind: 'text_delta', text: 'Not in plan mode. Use /plan to enter.\n' });
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
ctx.config.permissionMode = 'default';
|
|
154
|
+
ctx.onEvent({ kind: 'text_delta', text: '**Execution mode.** All tools enabled with permissions.\n' });
|
|
155
|
+
}
|
|
156
|
+
emitDone(ctx);
|
|
157
|
+
},
|
|
158
|
+
'/sessions': (ctx) => {
|
|
159
|
+
const sessions = listSessions();
|
|
160
|
+
if (sessions.length === 0) {
|
|
161
|
+
ctx.onEvent({ kind: 'text_delta', text: 'No saved sessions.\n' });
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
let text = `**${sessions.length} saved sessions:**\n\n`;
|
|
165
|
+
for (const s of sessions.slice(0, 10)) {
|
|
166
|
+
const date = new Date(s.updatedAt).toLocaleString();
|
|
167
|
+
const dir = s.workDir ? ` — ${s.workDir.split('/').pop()}` : '';
|
|
168
|
+
text += ` ${s.id} ${s.model} ${s.turnCount} turns ${date}${dir}\n`;
|
|
169
|
+
}
|
|
170
|
+
if (sessions.length > 10)
|
|
171
|
+
text += ` ... and ${sessions.length - 10} more\n`;
|
|
172
|
+
text += '\nUse /resume <session-id> to continue a session.\n';
|
|
173
|
+
ctx.onEvent({ kind: 'text_delta', text });
|
|
174
|
+
}
|
|
175
|
+
emitDone(ctx);
|
|
176
|
+
},
|
|
177
|
+
'/compact': async (ctx) => {
|
|
178
|
+
const beforeTokens = estimateHistoryTokens(ctx.history);
|
|
179
|
+
const { history: compacted, compacted: didCompact } = await forceCompact(ctx.history, ctx.config.model, ctx.client, ctx.config.debug);
|
|
180
|
+
if (didCompact) {
|
|
181
|
+
ctx.history.length = 0;
|
|
182
|
+
ctx.history.push(...compacted);
|
|
183
|
+
resetTokenAnchor();
|
|
184
|
+
}
|
|
185
|
+
const afterTokens = estimateHistoryTokens(ctx.history);
|
|
186
|
+
ctx.onEvent({ kind: 'text_delta', text: didCompact
|
|
187
|
+
? `Compacted: ~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens\n`
|
|
188
|
+
: `History too short to compact (${beforeTokens.toLocaleString()} tokens, ${ctx.history.length} messages).\n`
|
|
189
|
+
});
|
|
190
|
+
emitDone(ctx);
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
// Prompt-rewrite commands (transformed into agent prompts)
|
|
194
|
+
const REWRITE_COMMANDS = {
|
|
195
|
+
'/commit': 'Review the current git diff and staged changes. Stage relevant files with `git add`, then create a commit with a concise message summarizing the changes. Do NOT push to remote.',
|
|
196
|
+
'/push': 'Push the current branch to the remote repository using `git push`. Show the result.',
|
|
197
|
+
'/pr': 'Create a pull request for the current branch. First check `git log --oneline main..HEAD` to see commits, then use `gh pr create` with a descriptive title and body summarizing the changes. If gh CLI is not available, show the manual steps.',
|
|
198
|
+
'/review': 'Review the current git diff. For each changed file, check for: bugs, security issues, missing error handling, performance problems, and style issues. Provide a brief summary of findings.',
|
|
199
|
+
'/fix': 'Look at the most recent error or issue we discussed and fix it. Check the relevant files, identify the root cause, and apply the fix.',
|
|
200
|
+
'/test': 'Detect the project test framework (look for package.json scripts, pytest, etc.) and run the test suite. Show a summary of results.',
|
|
201
|
+
'/debug': 'Look at the most recent error in this session. Read the relevant source files, analyze the root cause, and suggest a fix with specific code changes.',
|
|
202
|
+
'/init': 'Read the project structure: check package.json (or equivalent), README, and key config files. Summarize: what this project is, main language/framework, entry points, and how to run/test it.',
|
|
203
|
+
'/todo': 'Search the codebase for TODO, FIXME, HACK, and XXX comments using Grep. Show the results grouped by file.',
|
|
204
|
+
'/deps': 'Read the project dependency file (package.json, requirements.txt, go.mod, Cargo.toml, etc.) and list key dependencies with their versions.',
|
|
205
|
+
'/optimize': 'Analyze the codebase for performance issues. Check for: unnecessary re-renders, N+1 queries, missing indexes, unoptimized loops, large bundle sizes, and memory leaks. Provide specific recommendations.',
|
|
206
|
+
'/security': 'Audit the codebase for security issues. Check for: SQL injection, XSS, command injection, hardcoded secrets, insecure dependencies, OWASP top 10 vulnerabilities. Report findings with severity.',
|
|
207
|
+
'/lint': 'Check for code quality issues: unused imports, inconsistent naming, missing type annotations, long functions, duplicated code. Suggest improvements.',
|
|
208
|
+
'/migrate': 'Check for pending database migrations, outdated dependencies, or breaking changes that need addressing. List required migration steps.',
|
|
209
|
+
'/clean': 'Find and remove dead code: unused imports, unreachable code, commented-out blocks, unused variables and functions. Show what would be removed before making changes.',
|
|
210
|
+
'/tasks': 'List all current tasks using the Task tool.',
|
|
211
|
+
};
|
|
212
|
+
// Commands with arguments (prefix match → rewrite)
|
|
213
|
+
const ARG_COMMANDS = [
|
|
214
|
+
{ prefix: '/explain ', rewrite: (a) => `Read and explain the code in ${a}. Cover: what it does, key functions/classes, how it connects to the rest of the codebase.` },
|
|
215
|
+
{ prefix: '/search ', rewrite: (a) => `Search the codebase for "${a}" using Grep. Show the matching files and relevant code context.` },
|
|
216
|
+
{ prefix: '/find ', rewrite: (a) => `Find files matching the pattern "${a}" using Glob. Show the results.` },
|
|
217
|
+
{ prefix: '/refactor ', rewrite: (a) => `Refactor: ${a}. Read the relevant code first, then make targeted changes. Explain each change.` },
|
|
218
|
+
{ prefix: '/scaffold ', rewrite: (a) => `Create the scaffolding/boilerplate for: ${a}. Generate the file structure and initial code. Ask me if you need clarification on requirements.` },
|
|
219
|
+
{ prefix: '/doc ', rewrite: (a) => `Generate documentation for ${a}. Include: purpose, API/interface description, usage examples, and important notes.` },
|
|
220
|
+
];
|
|
221
|
+
// ─── Main dispatch ────────────────────────────────────────────────────────
|
|
222
|
+
/**
|
|
223
|
+
* Handle a slash command. Returns result indicating what happened.
|
|
224
|
+
*/
|
|
225
|
+
export async function handleSlashCommand(input, ctx) {
|
|
226
|
+
// Direct-handled commands
|
|
227
|
+
if (input in DIRECT_COMMANDS) {
|
|
228
|
+
await DIRECT_COMMANDS[input](ctx);
|
|
229
|
+
return { handled: true };
|
|
230
|
+
}
|
|
231
|
+
// /branch has both no-arg and with-arg forms
|
|
232
|
+
if (input === '/branch' || input.startsWith('/branch ')) {
|
|
233
|
+
const cwd = ctx.config.workingDir || process.cwd();
|
|
234
|
+
if (input === '/branch') {
|
|
235
|
+
const r = gitCmd(ctx, 'git branch -v --no-color');
|
|
236
|
+
ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'Not a git repo.\n' });
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
const branchName = input.slice(8).trim();
|
|
240
|
+
const r = gitCmd(ctx, `git checkout -b ${branchName}`);
|
|
241
|
+
if (r !== null)
|
|
242
|
+
ctx.onEvent({ kind: 'text_delta', text: `Created and switched to branch: **${branchName}**\n` });
|
|
243
|
+
}
|
|
244
|
+
emitDone(ctx);
|
|
245
|
+
return { handled: true };
|
|
246
|
+
}
|
|
247
|
+
// /resume <id>
|
|
248
|
+
if (input.startsWith('/resume ')) {
|
|
249
|
+
const targetId = input.slice(8).trim();
|
|
250
|
+
const restored = loadSessionHistory(targetId);
|
|
251
|
+
if (restored.length === 0) {
|
|
252
|
+
ctx.onEvent({ kind: 'text_delta', text: `Session "${targetId}" not found or empty.\n` });
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
ctx.history.length = 0;
|
|
256
|
+
ctx.history.push(...restored);
|
|
257
|
+
resetTokenAnchor();
|
|
258
|
+
ctx.onEvent({ kind: 'text_delta', text: `Restored ${restored.length} messages from ${targetId}. Continue where you left off.\n` });
|
|
259
|
+
}
|
|
260
|
+
emitDone(ctx);
|
|
261
|
+
return { handled: true };
|
|
262
|
+
}
|
|
263
|
+
// Simple rewrite commands (exact match)
|
|
264
|
+
if (input in REWRITE_COMMANDS) {
|
|
265
|
+
return { handled: false, rewritten: REWRITE_COMMANDS[input] };
|
|
266
|
+
}
|
|
267
|
+
// Argument-based rewrite commands (prefix match)
|
|
268
|
+
for (const { prefix, rewrite } of ARG_COMMANDS) {
|
|
269
|
+
if (input.startsWith(prefix)) {
|
|
270
|
+
const arg = input.slice(prefix.length).trim();
|
|
271
|
+
return { handled: false, rewritten: rewrite(arg) };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Not a recognized command
|
|
275
|
+
return { handled: false };
|
|
276
|
+
}
|
package/dist/agent/compact.js
CHANGED
|
@@ -136,10 +136,11 @@ async function compactHistory(history, model, client, debug) {
|
|
|
136
136
|
* Keeps the most recent tool exchange + the last few user/assistant turns.
|
|
137
137
|
*/
|
|
138
138
|
function findKeepBoundary(history) {
|
|
139
|
-
// Keep
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
139
|
+
// Keep the last 8-20 messages (absolute range, not percentage)
|
|
140
|
+
// Prevents "never compacts" bug when history grows large
|
|
141
|
+
const minKeep = Math.min(8, history.length);
|
|
142
|
+
const maxKeep = Math.min(20, history.length - 1);
|
|
143
|
+
let keep = Math.max(minKeep, Math.min(maxKeep, Math.ceil(history.length * 0.3)));
|
|
143
144
|
// Make sure we don't split in the middle of a tool exchange
|
|
144
145
|
// (assistant with tool_use must be followed by user with tool_result)
|
|
145
146
|
while (keep < history.length) {
|
package/dist/agent/llm.js
CHANGED
|
@@ -129,7 +129,12 @@ export class ModelClient {
|
|
|
129
129
|
try {
|
|
130
130
|
parsedInput = JSON.parse(currentToolInput || '{}');
|
|
131
131
|
}
|
|
132
|
-
catch {
|
|
132
|
+
catch (parseErr) {
|
|
133
|
+
// Log malformed JSON instead of silently defaulting to {}
|
|
134
|
+
if (this.debug) {
|
|
135
|
+
console.error(`[runcode] Malformed tool input JSON for ${currentToolName}: ${parseErr.message}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
133
138
|
const toolInvocation = {
|
|
134
139
|
type: 'tool_use',
|
|
135
140
|
id: currentToolId,
|
|
@@ -289,8 +294,11 @@ export class ModelClient {
|
|
|
289
294
|
if (done)
|
|
290
295
|
break;
|
|
291
296
|
buffer += decoder.decode(value, { stream: true });
|
|
292
|
-
// Safety: if buffer grows too large without newlines,
|
|
297
|
+
// Safety: if buffer grows too large without newlines, something is wrong
|
|
293
298
|
if (buffer.length > MAX_BUFFER) {
|
|
299
|
+
if (this.debug) {
|
|
300
|
+
console.error(`[runcode] SSE buffer overflow (${(buffer.length / 1024).toFixed(0)}KB) — truncating to prevent OOM`);
|
|
301
|
+
}
|
|
294
302
|
buffer = buffer.slice(-MAX_BUFFER / 2);
|
|
295
303
|
}
|
|
296
304
|
const lines = buffer.split('\n');
|
package/dist/agent/loop.js
CHANGED
|
@@ -4,17 +4,15 @@
|
|
|
4
4
|
* Original implementation with different architecture from any reference codebase.
|
|
5
5
|
*/
|
|
6
6
|
import { ModelClient } from './llm.js';
|
|
7
|
-
import { autoCompactIfNeeded,
|
|
7
|
+
import { autoCompactIfNeeded, microCompact } from './compact.js';
|
|
8
8
|
import { estimateHistoryTokens, updateActualTokens, resetTokenAnchor } from './tokens.js';
|
|
9
|
+
import { handleSlashCommand } from './commands.js';
|
|
9
10
|
import { PermissionManager } from './permissions.js';
|
|
10
11
|
import { StreamingExecutor } from './streaming-executor.js';
|
|
11
12
|
import { optimizeHistory, CAPPED_MAX_TOKENS, ESCALATED_MAX_TOKENS } from './optimize.js';
|
|
12
13
|
import { recordUsage } from '../stats/tracker.js';
|
|
13
14
|
import { estimateCost } from '../pricing.js';
|
|
14
|
-
import
|
|
15
|
-
import path from 'node:path';
|
|
16
|
-
import { BLOCKRUN_DIR, VERSION } from '../config.js';
|
|
17
|
-
import { createSessionId, appendToSession, updateSessionMeta, pruneOldSessions, listSessions, loadSessionHistory, } from '../session/storage.js';
|
|
15
|
+
import { createSessionId, appendToSession, updateSessionMeta, pruneOldSessions, } from '../session/storage.js';
|
|
18
16
|
// ─── Main Entry Point ──────────────────────────────────────────────────────
|
|
19
17
|
/**
|
|
20
18
|
* Run the agent loop.
|
|
@@ -215,385 +213,22 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
215
213
|
// Session persistence
|
|
216
214
|
const sessionId = createSessionId();
|
|
217
215
|
let turnCount = 0;
|
|
218
|
-
pruneOldSessions(); // Cleanup old sessions on start
|
|
216
|
+
pruneOldSessions(sessionId); // Cleanup old sessions on start, protect current
|
|
219
217
|
while (true) {
|
|
220
218
|
let input = await getUserInput();
|
|
221
219
|
if (input === null)
|
|
222
220
|
break; // User wants to exit
|
|
223
221
|
if (input === '')
|
|
224
222
|
continue; // Empty input → re-prompt
|
|
225
|
-
//
|
|
226
|
-
if (input
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const result = execSync('git stash push -m "runcode auto-stash"', {
|
|
230
|
-
cwd: config.workingDir || process.cwd(), encoding: 'utf-8', timeout: 10000
|
|
231
|
-
}).trim();
|
|
232
|
-
onEvent({ kind: 'text_delta', text: result || 'No changes to stash.\n' });
|
|
233
|
-
}
|
|
234
|
-
catch (e) {
|
|
235
|
-
onEvent({ kind: 'text_delta', text: `Stash error: ${e.message?.split('\n')[0]}\n` });
|
|
236
|
-
}
|
|
237
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
if (input === '/unstash') {
|
|
241
|
-
try {
|
|
242
|
-
const { execSync } = await import('node:child_process');
|
|
243
|
-
const result = execSync('git stash pop', {
|
|
244
|
-
cwd: config.workingDir || process.cwd(), encoding: 'utf-8', timeout: 10000
|
|
245
|
-
}).trim();
|
|
246
|
-
onEvent({ kind: 'text_delta', text: result || 'Stash applied.\n' });
|
|
247
|
-
}
|
|
248
|
-
catch (e) {
|
|
249
|
-
onEvent({ kind: 'text_delta', text: `Unstash error: ${e.message?.split('\n')[0]}\n` });
|
|
250
|
-
}
|
|
251
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
252
|
-
continue;
|
|
253
|
-
}
|
|
254
|
-
// Handle /branch — show current branch or create new
|
|
255
|
-
if (input === '/branch' || input.startsWith('/branch ')) {
|
|
256
|
-
try {
|
|
257
|
-
const { execSync } = await import('node:child_process');
|
|
258
|
-
const cwd = config.workingDir || process.cwd();
|
|
259
|
-
if (input === '/branch') {
|
|
260
|
-
const branches = execSync('git branch -v --no-color', { cwd, encoding: 'utf-8', timeout: 5000 }).trim();
|
|
261
|
-
onEvent({ kind: 'text_delta', text: `\`\`\`\n${branches}\n\`\`\`\n` });
|
|
262
|
-
}
|
|
263
|
-
else {
|
|
264
|
-
const branchName = input.slice(8).trim();
|
|
265
|
-
execSync(`git checkout -b ${branchName}`, { cwd, encoding: 'utf-8', timeout: 5000 });
|
|
266
|
-
onEvent({ kind: 'text_delta', text: `Created and switched to branch: **${branchName}**\n` });
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
catch (e) {
|
|
270
|
-
onEvent({ kind: 'text_delta', text: `Git error: ${e.message?.split('\n')[0] || 'unknown'}\n` });
|
|
271
|
-
}
|
|
272
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
// Handle /log — show recent git log
|
|
276
|
-
if (input === '/log') {
|
|
277
|
-
try {
|
|
278
|
-
const { execSync } = await import('node:child_process');
|
|
279
|
-
const log = execSync('git log --oneline -15 --no-color', {
|
|
280
|
-
cwd: config.workingDir || process.cwd(), encoding: 'utf-8', timeout: 5000
|
|
281
|
-
}).trim();
|
|
282
|
-
onEvent({ kind: 'text_delta', text: log ? `\`\`\`\n${log}\n\`\`\`\n` : 'No commits.\n' });
|
|
283
|
-
}
|
|
284
|
-
catch {
|
|
285
|
-
onEvent({ kind: 'text_delta', text: 'Not a git repo.\n' });
|
|
286
|
-
}
|
|
287
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
// Handle /mcp — show connected MCP servers
|
|
291
|
-
if (input === '/mcp') {
|
|
292
|
-
const { listMcpServers } = await import('../mcp/client.js');
|
|
293
|
-
const servers = listMcpServers();
|
|
294
|
-
if (servers.length === 0) {
|
|
295
|
-
onEvent({ kind: 'text_delta', text: 'No MCP servers connected.\nAdd servers to `~/.blockrun/mcp.json` or `.mcp.json` in your project.\n' });
|
|
296
|
-
}
|
|
297
|
-
else {
|
|
298
|
-
let text = `**${servers.length} MCP server(s) connected:**\n\n`;
|
|
299
|
-
for (const s of servers) {
|
|
300
|
-
text += ` **${s.name}** — ${s.toolCount} tools\n`;
|
|
301
|
-
for (const t of s.tools) {
|
|
302
|
-
text += ` · ${t}\n`;
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
onEvent({ kind: 'text_delta', text });
|
|
306
|
-
}
|
|
307
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
308
|
-
continue;
|
|
309
|
-
}
|
|
310
|
-
// Handle /bug — open issue tracker
|
|
311
|
-
if (input === '/bug') {
|
|
312
|
-
onEvent({ kind: 'text_delta', text: 'Report issues at: https://github.com/BlockRunAI/runcode/issues\n' });
|
|
313
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
|
-
// Handle /version — show version
|
|
317
|
-
if (input === '/version') {
|
|
318
|
-
onEvent({ kind: 'text_delta', text: `RunCode v${VERSION}\n` });
|
|
319
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
320
|
-
continue;
|
|
321
|
-
}
|
|
322
|
-
// Handle /tasks — show task list (shortcut for Task list action)
|
|
323
|
-
if (input === '/tasks') {
|
|
324
|
-
input = 'List all current tasks using the Task tool.';
|
|
325
|
-
}
|
|
326
|
-
// Handle /doctor — diagnose setup issues
|
|
327
|
-
if (input === '/doctor') {
|
|
328
|
-
const checks = [];
|
|
329
|
-
const { execSync } = await import('node:child_process');
|
|
330
|
-
// Check git
|
|
331
|
-
try {
|
|
332
|
-
execSync('git --version', { stdio: 'pipe' });
|
|
333
|
-
checks.push('✓ git available');
|
|
334
|
-
}
|
|
335
|
-
catch {
|
|
336
|
-
checks.push('✗ git not found');
|
|
337
|
-
}
|
|
338
|
-
// Check rg
|
|
339
|
-
try {
|
|
340
|
-
execSync('rg --version', { stdio: 'pipe' });
|
|
341
|
-
checks.push('✓ ripgrep available');
|
|
342
|
-
}
|
|
343
|
-
catch {
|
|
344
|
-
checks.push('⚠ ripgrep not found (using native grep fallback)');
|
|
345
|
-
}
|
|
346
|
-
// Check wallet
|
|
347
|
-
const walletFile = path.join(BLOCKRUN_DIR, 'wallet.json');
|
|
348
|
-
checks.push(fs.existsSync(walletFile) ? '✓ wallet configured' : '⚠ no wallet — run: runcode setup');
|
|
349
|
-
// Check config
|
|
350
|
-
const configFile = path.join(BLOCKRUN_DIR, 'runcode-config.json');
|
|
351
|
-
checks.push(fs.existsSync(configFile) ? '✓ config file exists' : '⚠ no config — using defaults');
|
|
352
|
-
// Model & tokens
|
|
353
|
-
checks.push(`✓ model: ${config.model}`);
|
|
354
|
-
checks.push(`✓ history: ${history.length} messages, ~${estimateHistoryTokens(history).toLocaleString()} tokens`);
|
|
355
|
-
checks.push(`✓ session: ${sessionId}`);
|
|
356
|
-
checks.push(`✓ version: v${VERSION}`);
|
|
357
|
-
onEvent({ kind: 'text_delta', text: `**Health Check**\n${checks.map(c => ' ' + c).join('\n')}\n` });
|
|
358
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
// Handle /commit — rewrite as a prompt for the agent
|
|
362
|
-
if (input === '/commit') {
|
|
363
|
-
input = 'Review the current git diff and staged changes. Stage relevant files with `git add`, then create a commit with a concise message summarizing the changes. Do NOT push to remote.';
|
|
364
|
-
}
|
|
365
|
-
// Handle /undo — undo last commit (keep changes)
|
|
366
|
-
if (input === '/undo') {
|
|
367
|
-
try {
|
|
368
|
-
const { execSync } = await import('node:child_process');
|
|
369
|
-
const result = execSync('git reset --soft HEAD~1', {
|
|
370
|
-
cwd: config.workingDir || process.cwd(), encoding: 'utf-8', timeout: 5000
|
|
371
|
-
}).trim();
|
|
372
|
-
onEvent({ kind: 'text_delta', text: result || 'Last commit undone. Changes preserved in staging.\n' });
|
|
373
|
-
}
|
|
374
|
-
catch (e) {
|
|
375
|
-
onEvent({ kind: 'text_delta', text: `Undo error: ${e.message?.split('\n')[0]}\n` });
|
|
376
|
-
}
|
|
377
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
378
|
-
continue;
|
|
379
|
-
}
|
|
380
|
-
// Handle /push — push to remote
|
|
381
|
-
if (input === '/push') {
|
|
382
|
-
input = 'Push the current branch to the remote repository using `git push`. Show the result.';
|
|
383
|
-
}
|
|
384
|
-
// Handle /pr — create pull request
|
|
385
|
-
if (input === '/pr') {
|
|
386
|
-
input = 'Create a pull request for the current branch. First check `git log --oneline main..HEAD` to see commits, then use `gh pr create` with a descriptive title and body summarizing the changes. If gh CLI is not available, show the manual steps.';
|
|
387
|
-
}
|
|
388
|
-
// Handle /review — ask agent to review current changes
|
|
389
|
-
if (input === '/review') {
|
|
390
|
-
input = 'Review the current git diff. For each changed file, check for: bugs, security issues, missing error handling, performance problems, and style issues. Provide a brief summary of findings.';
|
|
391
|
-
}
|
|
392
|
-
// Handle /fix — ask agent to fix the last error or issue
|
|
393
|
-
if (input === '/fix') {
|
|
394
|
-
input = 'Look at the most recent error or issue we discussed and fix it. Check the relevant files, identify the root cause, and apply the fix.';
|
|
395
|
-
}
|
|
396
|
-
// Handle /test — run project tests
|
|
397
|
-
if (input === '/test') {
|
|
398
|
-
input = 'Detect the project test framework (look for package.json scripts, pytest, etc.) and run the test suite. Show a summary of results.';
|
|
399
|
-
}
|
|
400
|
-
// Handle /explain <file> — explain code
|
|
401
|
-
if (input.startsWith('/explain ')) {
|
|
402
|
-
const target = input.slice(9).trim();
|
|
403
|
-
input = `Read and explain the code in ${target}. Cover: what it does, key functions/classes, how it connects to the rest of the codebase.`;
|
|
404
|
-
}
|
|
405
|
-
// Handle /search <query> — search codebase
|
|
406
|
-
if (input.startsWith('/search ')) {
|
|
407
|
-
const query = input.slice(8).trim();
|
|
408
|
-
input = `Search the codebase for "${query}" using Grep. Show the matching files and relevant code context.`;
|
|
409
|
-
}
|
|
410
|
-
// Handle /find <pattern> — find files
|
|
411
|
-
if (input.startsWith('/find ')) {
|
|
412
|
-
const pattern = input.slice(6).trim();
|
|
413
|
-
input = `Find files matching the pattern "${pattern}" using Glob. Show the results.`;
|
|
414
|
-
}
|
|
415
|
-
// Handle /refactor <description> — code refactoring
|
|
416
|
-
if (input.startsWith('/refactor ')) {
|
|
417
|
-
const desc = input.slice(10).trim();
|
|
418
|
-
input = `Refactor: ${desc}. Read the relevant code first, then make targeted changes. Explain each change.`;
|
|
419
|
-
}
|
|
420
|
-
// Handle /debug — analyze recent error
|
|
421
|
-
if (input === '/debug') {
|
|
422
|
-
input = 'Look at the most recent error in this session. Read the relevant source files, analyze the root cause, and suggest a fix with specific code changes.';
|
|
423
|
-
}
|
|
424
|
-
// Handle /init — initialize project context
|
|
425
|
-
if (input === '/init') {
|
|
426
|
-
input = 'Read the project structure: check package.json (or equivalent), README, and key config files. Summarize: what this project is, main language/framework, entry points, and how to run/test it.';
|
|
427
|
-
}
|
|
428
|
-
// Handle /todo — find TODOs in codebase
|
|
429
|
-
if (input === '/todo') {
|
|
430
|
-
input = 'Search the codebase for TODO, FIXME, HACK, and XXX comments using Grep. Show the results grouped by file.';
|
|
431
|
-
}
|
|
432
|
-
// Handle /deps — show project dependencies
|
|
433
|
-
if (input === '/deps') {
|
|
434
|
-
input = 'Read the project dependency file (package.json, requirements.txt, go.mod, Cargo.toml, etc.) and list key dependencies with their versions.';
|
|
435
|
-
}
|
|
436
|
-
// Handle /scaffold <desc> — generate boilerplate
|
|
437
|
-
if (input.startsWith('/scaffold ')) {
|
|
438
|
-
const desc = input.slice(10).trim();
|
|
439
|
-
input = `Create the scaffolding/boilerplate for: ${desc}. Generate the file structure and initial code. Ask me if you need clarification on requirements.`;
|
|
440
|
-
}
|
|
441
|
-
// Handle /optimize — performance optimization
|
|
442
|
-
if (input === '/optimize') {
|
|
443
|
-
input = 'Analyze the codebase for performance issues. Check for: unnecessary re-renders, N+1 queries, missing indexes, unoptimized loops, large bundle sizes, and memory leaks. Provide specific recommendations.';
|
|
444
|
-
}
|
|
445
|
-
// Handle /security — security audit
|
|
446
|
-
if (input === '/security') {
|
|
447
|
-
input = 'Audit the codebase for security issues. Check for: SQL injection, XSS, command injection, hardcoded secrets, insecure dependencies, OWASP top 10 vulnerabilities. Report findings with severity.';
|
|
448
|
-
}
|
|
449
|
-
// Handle /lint — code quality
|
|
450
|
-
if (input === '/lint') {
|
|
451
|
-
input = 'Check for code quality issues: unused imports, inconsistent naming, missing type annotations, long functions, duplicated code. Suggest improvements.';
|
|
452
|
-
}
|
|
453
|
-
// Handle /doc <target> — generate documentation
|
|
454
|
-
if (input.startsWith('/doc ')) {
|
|
455
|
-
const target = input.slice(5).trim();
|
|
456
|
-
input = `Generate documentation for ${target}. Include: purpose, API/interface description, usage examples, and important notes.`;
|
|
457
|
-
}
|
|
458
|
-
// Handle /migrate — migration helper
|
|
459
|
-
if (input === '/migrate') {
|
|
460
|
-
input = 'Check for pending database migrations, outdated dependencies, or breaking changes that need addressing. List required migration steps.';
|
|
461
|
-
}
|
|
462
|
-
// Handle /clean — cleanup dead code
|
|
463
|
-
if (input === '/clean') {
|
|
464
|
-
input = 'Find and remove dead code: unused imports, unreachable code, commented-out blocks, unused variables and functions. Show what would be removed before making changes.';
|
|
465
|
-
}
|
|
466
|
-
// Handle /status — show git status
|
|
467
|
-
if (input === '/status') {
|
|
468
|
-
try {
|
|
469
|
-
const { execSync } = await import('node:child_process');
|
|
470
|
-
const status = execSync('git status --short --branch', {
|
|
471
|
-
cwd: config.workingDir || process.cwd(),
|
|
472
|
-
encoding: 'utf-8',
|
|
473
|
-
timeout: 5_000,
|
|
474
|
-
}).trim();
|
|
475
|
-
onEvent({ kind: 'text_delta', text: status ? `\`\`\`\n${status}\n\`\`\`\n` : 'No git status.\n' });
|
|
476
|
-
}
|
|
477
|
-
catch {
|
|
478
|
-
onEvent({ kind: 'text_delta', text: 'Not a git repo.\n' });
|
|
479
|
-
}
|
|
480
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
// Handle /diff — show git diff of current changes
|
|
484
|
-
if (input === '/diff') {
|
|
485
|
-
try {
|
|
486
|
-
const { execSync } = await import('node:child_process');
|
|
487
|
-
const diff = execSync('git diff --stat && echo "---" && git diff', {
|
|
488
|
-
cwd: config.workingDir || process.cwd(),
|
|
489
|
-
encoding: 'utf-8',
|
|
490
|
-
timeout: 10_000,
|
|
491
|
-
maxBuffer: 512 * 1024,
|
|
492
|
-
}).trim();
|
|
493
|
-
onEvent({ kind: 'text_delta', text: diff ? `\`\`\`diff\n${diff}\n\`\`\`\n` : 'No changes.\n' });
|
|
494
|
-
}
|
|
495
|
-
catch {
|
|
496
|
-
onEvent({ kind: 'text_delta', text: 'Not a git repository or git not available.\n' });
|
|
497
|
-
}
|
|
498
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
501
|
-
// Handle /context — show current session context info
|
|
502
|
-
if (input === '/context') {
|
|
503
|
-
const { getAnchoredTokenCount, getContextWindow } = await import('./tokens.js');
|
|
504
|
-
const { estimated, apiAnchored } = getAnchoredTokenCount(history);
|
|
505
|
-
const contextWindow = getContextWindow(config.model);
|
|
506
|
-
const usagePct = ((estimated / contextWindow) * 100).toFixed(1);
|
|
507
|
-
const msgs = history.length;
|
|
508
|
-
const model = config.model;
|
|
509
|
-
const dir = config.workingDir || process.cwd();
|
|
510
|
-
const mode = config.permissionMode || 'default';
|
|
511
|
-
onEvent({ kind: 'text_delta', text: `**Session Context**\n` +
|
|
512
|
-
` Model: ${model}\n` +
|
|
513
|
-
` Mode: ${mode}\n` +
|
|
514
|
-
` Messages: ${msgs}\n` +
|
|
515
|
-
` Tokens: ~${estimated.toLocaleString()} / ${(contextWindow / 1000).toFixed(0)}k (${usagePct}%)${apiAnchored ? ' ✓' : ' ~'}\n` +
|
|
516
|
-
` Session: ${sessionId}\n` +
|
|
517
|
-
` Directory: ${dir}\n`
|
|
223
|
+
// ── Slash command dispatch ──
|
|
224
|
+
if (input.startsWith('/')) {
|
|
225
|
+
const cmdResult = await handleSlashCommand(input, {
|
|
226
|
+
history, config, client, sessionId, onEvent,
|
|
518
227
|
});
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
if (input === '/plan') {
|
|
524
|
-
if (config.permissionMode === 'plan') {
|
|
525
|
-
onEvent({ kind: 'text_delta', text: 'Already in plan mode. Use /execute to exit.\n' });
|
|
526
|
-
}
|
|
527
|
-
else {
|
|
528
|
-
config.permissionMode = 'plan';
|
|
529
|
-
onEvent({ kind: 'text_delta', text: '**Plan mode active.** Tools restricted to read-only. Use /execute when ready to implement.\n' });
|
|
530
|
-
}
|
|
531
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
532
|
-
continue;
|
|
533
|
-
}
|
|
534
|
-
// Handle /execute — exit plan mode
|
|
535
|
-
if (input === '/execute') {
|
|
536
|
-
if (config.permissionMode !== 'plan') {
|
|
537
|
-
onEvent({ kind: 'text_delta', text: 'Not in plan mode. Use /plan to enter.\n' });
|
|
538
|
-
}
|
|
539
|
-
else {
|
|
540
|
-
config.permissionMode = 'default';
|
|
541
|
-
onEvent({ kind: 'text_delta', text: '**Execution mode.** All tools enabled with permissions.\n' });
|
|
542
|
-
}
|
|
543
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
544
|
-
continue;
|
|
545
|
-
}
|
|
546
|
-
// Handle /sessions — list saved sessions
|
|
547
|
-
if (input === '/sessions') {
|
|
548
|
-
const sessions = listSessions();
|
|
549
|
-
if (sessions.length === 0) {
|
|
550
|
-
onEvent({ kind: 'text_delta', text: 'No saved sessions.\n' });
|
|
551
|
-
}
|
|
552
|
-
else {
|
|
553
|
-
let text = `**${sessions.length} saved sessions:**\n\n`;
|
|
554
|
-
for (const s of sessions.slice(0, 10)) {
|
|
555
|
-
const date = new Date(s.updatedAt).toLocaleString();
|
|
556
|
-
const dir = s.workDir ? ` — ${s.workDir.split('/').pop()}` : '';
|
|
557
|
-
text += ` ${s.id} ${s.model} ${s.turnCount} turns ${date}${dir}\n`;
|
|
558
|
-
}
|
|
559
|
-
if (sessions.length > 10)
|
|
560
|
-
text += ` ... and ${sessions.length - 10} more\n`;
|
|
561
|
-
text += '\nUse /resume <session-id> to continue a session.\n';
|
|
562
|
-
onEvent({ kind: 'text_delta', text });
|
|
563
|
-
}
|
|
564
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
565
|
-
continue;
|
|
566
|
-
}
|
|
567
|
-
// Handle /resume <id> — restore session history
|
|
568
|
-
if (input.startsWith('/resume ')) {
|
|
569
|
-
const targetId = input.slice(8).trim();
|
|
570
|
-
const restored = loadSessionHistory(targetId);
|
|
571
|
-
if (restored.length === 0) {
|
|
572
|
-
onEvent({ kind: 'text_delta', text: `Session "${targetId}" not found or empty.\n` });
|
|
573
|
-
}
|
|
574
|
-
else {
|
|
575
|
-
history.length = 0;
|
|
576
|
-
history.push(...restored);
|
|
577
|
-
onEvent({ kind: 'text_delta', text: `Restored ${restored.length} messages from ${targetId}. Continue where you left off.\n` });
|
|
578
|
-
}
|
|
579
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
580
|
-
continue;
|
|
581
|
-
}
|
|
582
|
-
// Handle /compact command — force compaction without sending to model
|
|
583
|
-
if (input === '/compact') {
|
|
584
|
-
const beforeTokens = estimateHistoryTokens(history);
|
|
585
|
-
const { history: compacted, compacted: didCompact } = await forceCompact(history, config.model, client, config.debug);
|
|
586
|
-
if (didCompact) {
|
|
587
|
-
history.length = 0;
|
|
588
|
-
history.push(...compacted);
|
|
589
|
-
}
|
|
590
|
-
const afterTokens = estimateHistoryTokens(history);
|
|
591
|
-
onEvent({ kind: 'text_delta', text: didCompact
|
|
592
|
-
? `Compacted: ~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens\n`
|
|
593
|
-
: `History too short to compact (${beforeTokens.toLocaleString()} tokens, ${history.length} messages).\n`
|
|
594
|
-
});
|
|
595
|
-
onEvent({ kind: 'turn_done', reason: 'completed' });
|
|
596
|
-
continue;
|
|
228
|
+
if (cmdResult.handled)
|
|
229
|
+
continue;
|
|
230
|
+
if (cmdResult.rewritten)
|
|
231
|
+
input = cmdResult.rewritten;
|
|
597
232
|
}
|
|
598
233
|
history.push({ role: 'user', content: input });
|
|
599
234
|
appendToSession(sessionId, { role: 'user', content: input });
|
|
@@ -635,7 +270,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
635
270
|
}
|
|
636
271
|
const systemPrompt = config.systemInstructions.join('\n\n');
|
|
637
272
|
let maxTokens = maxTokensOverride ?? CAPPED_MAX_TOKENS;
|
|
638
|
-
let responseParts;
|
|
273
|
+
let responseParts = [];
|
|
639
274
|
let usage;
|
|
640
275
|
let stopReason;
|
|
641
276
|
// Create streaming executor for concurrent tool execution
|
|
@@ -672,6 +307,11 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
672
307
|
catch (err) {
|
|
673
308
|
// ── User abort (Esc key) ──
|
|
674
309
|
if (err.name === 'AbortError' || abort.signal.aborted) {
|
|
310
|
+
// Save any partial response that was streamed before abort
|
|
311
|
+
if (responseParts && responseParts.length > 0) {
|
|
312
|
+
history.push({ role: 'assistant', content: responseParts });
|
|
313
|
+
appendToSession(sessionId, { role: 'assistant', content: responseParts });
|
|
314
|
+
}
|
|
675
315
|
onEvent({ kind: 'turn_done', reason: 'aborted' });
|
|
676
316
|
break;
|
|
677
317
|
}
|
package/dist/agent/optimize.js
CHANGED
|
@@ -49,7 +49,12 @@ export function budgetToolResults(history) {
|
|
|
49
49
|
// Per-tool cap
|
|
50
50
|
if (size > MAX_TOOL_RESULT_CHARS) {
|
|
51
51
|
modified = true;
|
|
52
|
-
|
|
52
|
+
// Truncate at line boundary for cleaner output
|
|
53
|
+
let preview = content.slice(0, PREVIEW_CHARS);
|
|
54
|
+
const lastNewline = preview.lastIndexOf('\n');
|
|
55
|
+
if (lastNewline > PREVIEW_CHARS * 0.5) {
|
|
56
|
+
preview = preview.slice(0, lastNewline);
|
|
57
|
+
}
|
|
53
58
|
budgeted.push({
|
|
54
59
|
type: 'tool_result',
|
|
55
60
|
tool_use_id: part.tool_use_id,
|
|
@@ -59,15 +64,13 @@ export function budgetToolResults(history) {
|
|
|
59
64
|
messageTotal += PREVIEW_CHARS + 200;
|
|
60
65
|
continue;
|
|
61
66
|
}
|
|
62
|
-
// Per-message aggregate cap
|
|
67
|
+
// Per-message aggregate cap — once exceeded, truncate remaining results
|
|
63
68
|
if (messageTotal + size > MAX_TOOL_RESULTS_PER_MESSAGE_CHARS) {
|
|
64
69
|
modified = true;
|
|
65
|
-
const remaining = Math.max(0, MAX_TOOL_RESULTS_PER_MESSAGE_CHARS - messageTotal);
|
|
66
|
-
const preview = content.slice(0, Math.min(PREVIEW_CHARS, remaining));
|
|
67
70
|
budgeted.push({
|
|
68
71
|
type: 'tool_result',
|
|
69
72
|
tool_use_id: part.tool_use_id,
|
|
70
|
-
content: `[Output omitted: message budget exceeded (${MAX_TOOL_RESULTS_PER_MESSAGE_CHARS / 1000}K chars/msg)]
|
|
73
|
+
content: `[Output omitted: message budget exceeded (${MAX_TOOL_RESULTS_PER_MESSAGE_CHARS / 1000}K chars/msg)]`,
|
|
71
74
|
is_error: part.is_error,
|
|
72
75
|
});
|
|
73
76
|
messageTotal = MAX_TOOL_RESULTS_PER_MESSAGE_CHARS;
|
package/dist/agent/tokens.js
CHANGED
|
@@ -23,19 +23,26 @@ export function updateActualTokens(inputTokens, outputTokens, messageCount) {
|
|
|
23
23
|
* More accurate than pure estimation because it's grounded in actual API counts.
|
|
24
24
|
*/
|
|
25
25
|
export function getAnchoredTokenCount(history) {
|
|
26
|
-
if (lastApiInputTokens > 0 && history.length >= lastApiMessageCount) {
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
if (lastApiInputTokens > 0 && lastApiMessageCount > 0 && history.length >= lastApiMessageCount) {
|
|
27
|
+
// Sanity check: if history was mutated (compaction, micro-compact), anchor may be stale.
|
|
28
|
+
// Detect by checking if new messages were only appended (length grew), not if content changed.
|
|
29
|
+
// If history grew by more than expected (e.g., resume injected many messages), fall through to estimation.
|
|
30
|
+
const growth = history.length - lastApiMessageCount;
|
|
31
|
+
if (growth <= 20) { // Reasonable growth since last API call
|
|
32
|
+
const newMessages = history.slice(lastApiMessageCount);
|
|
33
|
+
let newTokens = 0;
|
|
34
|
+
for (const msg of newMessages) {
|
|
35
|
+
newTokens += estimateDialogueTokens(msg);
|
|
36
|
+
}
|
|
37
|
+
const total = lastApiInputTokens + newTokens;
|
|
38
|
+
return {
|
|
39
|
+
estimated: total,
|
|
40
|
+
apiAnchored: true,
|
|
41
|
+
contextUsagePct: 0,
|
|
42
|
+
};
|
|
32
43
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
estimated: total,
|
|
36
|
-
apiAnchored: true,
|
|
37
|
-
contextUsagePct: 0, // Will be calculated by caller with model context window
|
|
38
|
-
};
|
|
44
|
+
// Too much growth — anchor is unreliable, fall through to estimation
|
|
45
|
+
resetTokenAnchor();
|
|
39
46
|
}
|
|
40
47
|
// No anchor — pure estimation
|
|
41
48
|
return {
|
package/dist/mcp/client.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface McpConfig {
|
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
29
|
* Connect to all configured MCP servers and return discovered tools.
|
|
30
|
+
* Each connection has a 5s timeout to avoid blocking startup.
|
|
30
31
|
*/
|
|
31
32
|
export declare function connectMcpServers(config: McpConfig, debug?: boolean): Promise<CapabilityHandler[]>;
|
|
32
33
|
/**
|
package/dist/mcp/client.js
CHANGED
|
@@ -21,7 +21,17 @@ async function connectStdio(name, config) {
|
|
|
21
21
|
env: { ...process.env, ...(config.env || {}) },
|
|
22
22
|
});
|
|
23
23
|
const client = new Client({ name: `runcode-mcp-${name}`, version: '1.0.0' }, { capabilities: {} });
|
|
24
|
-
|
|
24
|
+
try {
|
|
25
|
+
await client.connect(transport);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
// Clean up transport if connect fails to prevent resource leak
|
|
29
|
+
try {
|
|
30
|
+
await transport.close();
|
|
31
|
+
}
|
|
32
|
+
catch { /* ignore */ }
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
25
35
|
// Discover tools
|
|
26
36
|
const { tools: mcpTools } = await client.listTools();
|
|
27
37
|
const capabilities = [];
|
|
@@ -38,11 +48,12 @@ async function connectStdio(name, config) {
|
|
|
38
48
|
},
|
|
39
49
|
},
|
|
40
50
|
execute: async (input, _ctx) => {
|
|
51
|
+
const MCP_TOOL_TIMEOUT = 30_000;
|
|
41
52
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
// Timeout protection: if tool hangs, don't block the agent forever
|
|
54
|
+
const callPromise = client.callTool({ name: tool.name, arguments: input });
|
|
55
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`MCP tool timeout after ${MCP_TOOL_TIMEOUT / 1000}s`)), MCP_TOOL_TIMEOUT));
|
|
56
|
+
const result = await Promise.race([callPromise, timeoutPromise]);
|
|
46
57
|
// Extract text content from MCP response
|
|
47
58
|
const output = result.content
|
|
48
59
|
?.filter(c => c.type === 'text')
|
|
@@ -70,6 +81,11 @@ async function connectStdio(name, config) {
|
|
|
70
81
|
/**
|
|
71
82
|
* Connect to all configured MCP servers and return discovered tools.
|
|
72
83
|
*/
|
|
84
|
+
const MCP_CONNECT_TIMEOUT = 5_000; // 5s per server connection
|
|
85
|
+
/**
|
|
86
|
+
* Connect to all configured MCP servers and return discovered tools.
|
|
87
|
+
* Each connection has a 5s timeout to avoid blocking startup.
|
|
88
|
+
*/
|
|
73
89
|
export async function connectMcpServers(config, debug) {
|
|
74
90
|
const allTools = [];
|
|
75
91
|
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
|
@@ -79,17 +95,16 @@ export async function connectMcpServers(config, debug) {
|
|
|
79
95
|
if (debug) {
|
|
80
96
|
console.error(`[runcode] Connecting to MCP server: ${name}...`);
|
|
81
97
|
}
|
|
82
|
-
|
|
83
|
-
if (serverConfig.transport === 'stdio') {
|
|
84
|
-
connected = await connectStdio(name, serverConfig);
|
|
85
|
-
}
|
|
86
|
-
else {
|
|
87
|
-
// HTTP transport — TODO: implement SSE/HTTP transport
|
|
98
|
+
if (serverConfig.transport !== 'stdio') {
|
|
88
99
|
if (debug) {
|
|
89
100
|
console.error(`[runcode] MCP HTTP transport not yet supported for ${name}`);
|
|
90
101
|
}
|
|
91
102
|
continue;
|
|
92
103
|
}
|
|
104
|
+
// Timeout: don't let a slow server block startup
|
|
105
|
+
const connectPromise = connectStdio(name, serverConfig);
|
|
106
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('connection timeout (5s)')), MCP_CONNECT_TIMEOUT));
|
|
107
|
+
const connected = await Promise.race([connectPromise, timeoutPromise]);
|
|
93
108
|
allTools.push(...connected.tools);
|
|
94
109
|
if (debug) {
|
|
95
110
|
console.error(`[runcode] MCP ${name}: ${connected.tools.length} tools discovered`);
|
package/dist/mcp/config.d.ts
CHANGED
|
@@ -5,10 +5,6 @@
|
|
|
5
5
|
* 2. Project: .mcp.json in working directory
|
|
6
6
|
*/
|
|
7
7
|
import type { McpConfig, McpServerConfig } from './client.js';
|
|
8
|
-
/**
|
|
9
|
-
* Load MCP server configurations from global + project files.
|
|
10
|
-
* Project config overrides global for same server name.
|
|
11
|
-
*/
|
|
12
8
|
export declare function loadMcpConfig(workDir: string): McpConfig;
|
|
13
9
|
/**
|
|
14
10
|
* Save a server config to the global MCP config.
|
|
@@ -18,3 +14,7 @@ export declare function saveMcpServer(name: string, config: McpServerConfig): vo
|
|
|
18
14
|
* Remove a server from the global MCP config.
|
|
19
15
|
*/
|
|
20
16
|
export declare function removeMcpServer(name: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Trust a project directory to load its .mcp.json.
|
|
19
|
+
*/
|
|
20
|
+
export declare function trustProjectDir(workDir: string): void;
|
package/dist/mcp/config.js
CHANGED
|
@@ -12,8 +12,18 @@ const GLOBAL_MCP_FILE = path.join(BLOCKRUN_DIR, 'mcp.json');
|
|
|
12
12
|
* Load MCP server configurations from global + project files.
|
|
13
13
|
* Project config overrides global for same server name.
|
|
14
14
|
*/
|
|
15
|
+
// Built-in MCP server: @blockrun/mcp is always available (zero config)
|
|
16
|
+
const BUILTIN_MCP_SERVERS = {
|
|
17
|
+
blockrun: {
|
|
18
|
+
transport: 'stdio',
|
|
19
|
+
command: 'npx',
|
|
20
|
+
args: ['-y', '@blockrun/mcp'],
|
|
21
|
+
label: 'BlockRun (built-in)',
|
|
22
|
+
},
|
|
23
|
+
};
|
|
15
24
|
export function loadMcpConfig(workDir) {
|
|
16
|
-
|
|
25
|
+
// Start with built-in servers
|
|
26
|
+
const servers = { ...BUILTIN_MCP_SERVERS };
|
|
17
27
|
// 1. Global config
|
|
18
28
|
try {
|
|
19
29
|
if (fs.existsSync(GLOBAL_MCP_FILE)) {
|
|
@@ -27,14 +37,28 @@ export function loadMcpConfig(workDir) {
|
|
|
27
37
|
// Ignore corrupt global config
|
|
28
38
|
}
|
|
29
39
|
// 2. Project config (.mcp.json in working directory)
|
|
40
|
+
// Security: project configs can execute arbitrary commands via stdio transport.
|
|
41
|
+
// Only load if a trust marker exists (user has explicitly opted in).
|
|
30
42
|
const projectMcpFile = path.join(workDir, '.mcp.json');
|
|
43
|
+
const trustMarker = path.join(BLOCKRUN_DIR, 'trusted-projects.json');
|
|
31
44
|
try {
|
|
32
45
|
if (fs.existsSync(projectMcpFile)) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
46
|
+
// Check if this project directory is trusted
|
|
47
|
+
let trusted = false;
|
|
48
|
+
try {
|
|
49
|
+
if (fs.existsSync(trustMarker)) {
|
|
50
|
+
const trustedDirs = JSON.parse(fs.readFileSync(trustMarker, 'utf-8'));
|
|
51
|
+
trusted = Array.isArray(trustedDirs) && trustedDirs.includes(workDir);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { /* not trusted */ }
|
|
55
|
+
if (trusted) {
|
|
56
|
+
const raw = JSON.parse(fs.readFileSync(projectMcpFile, 'utf-8'));
|
|
57
|
+
if (raw.mcpServers && typeof raw.mcpServers === 'object') {
|
|
58
|
+
Object.assign(servers, raw.mcpServers);
|
|
59
|
+
}
|
|
37
60
|
}
|
|
61
|
+
// If not trusted, silently skip project config (user must run /mcp trust)
|
|
38
62
|
}
|
|
39
63
|
}
|
|
40
64
|
catch {
|
|
@@ -62,6 +86,24 @@ export function removeMcpServer(name) {
|
|
|
62
86
|
fs.writeFileSync(GLOBAL_MCP_FILE, JSON.stringify(existing, null, 2) + '\n');
|
|
63
87
|
return true;
|
|
64
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Trust a project directory to load its .mcp.json.
|
|
91
|
+
*/
|
|
92
|
+
export function trustProjectDir(workDir) {
|
|
93
|
+
const trustMarker = path.join(BLOCKRUN_DIR, 'trusted-projects.json');
|
|
94
|
+
let trusted = [];
|
|
95
|
+
try {
|
|
96
|
+
if (fs.existsSync(trustMarker)) {
|
|
97
|
+
trusted = JSON.parse(fs.readFileSync(trustMarker, 'utf-8'));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch { /* fresh */ }
|
|
101
|
+
if (!trusted.includes(workDir)) {
|
|
102
|
+
trusted.push(workDir);
|
|
103
|
+
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
|
|
104
|
+
fs.writeFileSync(trustMarker, JSON.stringify(trusted, null, 2));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
65
107
|
function loadGlobalMcpConfig() {
|
|
66
108
|
try {
|
|
67
109
|
if (fs.existsSync(GLOBAL_MCP_FILE)) {
|
|
@@ -39,4 +39,8 @@ export declare function listSessions(): SessionMeta[];
|
|
|
39
39
|
/**
|
|
40
40
|
* Prune old sessions beyond MAX_SESSIONS.
|
|
41
41
|
*/
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Prune old sessions beyond MAX_SESSIONS.
|
|
44
|
+
* Accepts optional activeSessionId to protect from deletion.
|
|
45
|
+
*/
|
|
46
|
+
export declare function pruneOldSessions(activeSessionId?: string): void;
|
package/dist/session/storage.js
CHANGED
|
@@ -67,7 +67,17 @@ export function loadSessionHistory(sessionId) {
|
|
|
67
67
|
try {
|
|
68
68
|
const content = fs.readFileSync(sessionPath(sessionId), 'utf-8');
|
|
69
69
|
const lines = content.trim().split('\n').filter(Boolean);
|
|
70
|
-
|
|
70
|
+
const results = [];
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
try {
|
|
73
|
+
results.push(JSON.parse(line));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Skip corrupted lines — partial writes from crashes
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return results;
|
|
71
81
|
}
|
|
72
82
|
catch {
|
|
73
83
|
return [];
|
|
@@ -98,11 +108,17 @@ export function listSessions() {
|
|
|
98
108
|
/**
|
|
99
109
|
* Prune old sessions beyond MAX_SESSIONS.
|
|
100
110
|
*/
|
|
101
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Prune old sessions beyond MAX_SESSIONS.
|
|
113
|
+
* Accepts optional activeSessionId to protect from deletion.
|
|
114
|
+
*/
|
|
115
|
+
export function pruneOldSessions(activeSessionId) {
|
|
102
116
|
const sessions = listSessions();
|
|
103
117
|
if (sessions.length <= MAX_SESSIONS)
|
|
104
118
|
return;
|
|
105
|
-
const toDelete = sessions
|
|
119
|
+
const toDelete = sessions
|
|
120
|
+
.slice(MAX_SESSIONS)
|
|
121
|
+
.filter(s => s.id !== activeSessionId); // Never delete active session
|
|
106
122
|
for (const s of toDelete) {
|
|
107
123
|
try {
|
|
108
124
|
fs.unlinkSync(sessionPath(s.id));
|
package/dist/tools/askuser.js
CHANGED
package/dist/tools/glob.js
CHANGED
|
@@ -21,7 +21,7 @@ function globMatch(pattern, text) {
|
|
|
21
21
|
return regex.test(text.replace(/\\/g, '/'));
|
|
22
22
|
}
|
|
23
23
|
function walkDirectory(dir, baseDir, pattern, results, depth, visited) {
|
|
24
|
-
if (depth >
|
|
24
|
+
if (depth > 50 || results.length >= MAX_RESULTS)
|
|
25
25
|
return;
|
|
26
26
|
// Symlink loop protection
|
|
27
27
|
const visitedSet = visited ?? new Set();
|
package/dist/tools/imagegen.js
CHANGED
|
@@ -70,8 +70,11 @@ async function execute(input, ctx) {
|
|
|
70
70
|
fs.writeFileSync(outPath, buffer);
|
|
71
71
|
}
|
|
72
72
|
else if (imageData.url) {
|
|
73
|
-
// Download from URL
|
|
74
|
-
const
|
|
73
|
+
// Download from URL (with 30s timeout)
|
|
74
|
+
const dlCtrl = new AbortController();
|
|
75
|
+
const dlTimeout = setTimeout(() => dlCtrl.abort(), 30_000);
|
|
76
|
+
const imgResp = await fetch(imageData.url, { signal: dlCtrl.signal });
|
|
77
|
+
clearTimeout(dlTimeout);
|
|
75
78
|
const buffer = Buffer.from(await imgResp.arrayBuffer());
|
|
76
79
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
77
80
|
fs.writeFileSync(outPath, buffer);
|
package/dist/tools/read.js
CHANGED
|
@@ -33,7 +33,7 @@ async function execute(input, ctx) {
|
|
|
33
33
|
}
|
|
34
34
|
const raw = fs.readFileSync(resolved, 'utf-8');
|
|
35
35
|
const allLines = raw.split('\n');
|
|
36
|
-
const startLine = Math.max(0, (offset ?? 1) - 1);
|
|
36
|
+
const startLine = Math.max(0, (Math.max(1, offset ?? 1)) - 1);
|
|
37
37
|
const maxLines = limit ?? 2000;
|
|
38
38
|
const endLine = Math.min(allLines.length, startLine + maxLines);
|
|
39
39
|
const slice = allLines.slice(startLine, endLine);
|
package/dist/tools/write.js
CHANGED
|
@@ -40,6 +40,16 @@ async function execute(input, ctx) {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
catch { /* parent doesn't exist yet, will be created */ }
|
|
43
|
+
// Also check if target file itself is a symlink to a sensitive location
|
|
44
|
+
try {
|
|
45
|
+
if (fs.existsSync(resolved) && fs.lstatSync(resolved).isSymbolicLink()) {
|
|
46
|
+
const realTarget = fs.realpathSync(resolved);
|
|
47
|
+
if (checkPath(realTarget)) {
|
|
48
|
+
return { output: `Error: refusing to write — symlink resolves to sensitive location: ${realTarget}`, isError: true };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch { /* file doesn't exist yet, ok */ }
|
|
43
53
|
try {
|
|
44
54
|
// Ensure parent directory exists
|
|
45
55
|
const parentDir = path.dirname(resolved);
|