Skip to content

Commit 54ea027

Browse files
committed
refactor: extract 60+ commands to registry, fix partial abort save, aggregate cap, depth limit (v2.2.0)
1 parent 2193a26 commit 54ea027

File tree

17 files changed

+661
-777
lines changed

17 files changed

+661
-777
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## 2.2.0 (2026-04-04)
4+
5+
### Architecture
6+
- **Command registry extracted**: 60+ inline slash commands moved from loop.ts (938→564 lines) to dedicated `commands.ts` (240 lines). Uses dispatch pattern: direct-handled, prompt-rewrite, and arg-based commands.
7+
8+
### Bug Fixes
9+
- **Partial response saved on abort**: When user presses Esc mid-generation, streamed content is now saved to session history instead of lost.
10+
- **Tool result aggregate cap**: Once per-message budget exceeded, remaining results are truncated immediately (was continuing to iterate and add bloated messages).
11+
- **AskUser EOF**: Returns error on EOF/piped input instead of misleading "(user skipped)" string.
12+
- **SSE buffer overflow logging**: Debug message now logged when SSE buffer exceeds 1MB (was silent).
13+
- **Glob depth limit**: Increased from 20 to 50 for deep monorepo support.
14+
- **Read tool offset**: offset=0 now treated as offset=1 (1-based as documented).
15+
316
## 2.1.0 (2026-04-04)
417

518
### Security

dist/agent/commands.d.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {};

dist/agent/commands.js

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

dist/agent/llm.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,8 +294,11 @@ export class ModelClient {
294294
if (done)
295295
break;
296296
buffer += decoder.decode(value, { stream: true });
297-
// Safety: if buffer grows too large without newlines, truncate
297+
// Safety: if buffer grows too large without newlines, something is wrong
298298
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+
}
299302
buffer = buffer.slice(-MAX_BUFFER / 2);
300303
}
301304
const lines = buffer.split('\n');

0 commit comments

Comments
 (0)