@blockrun/runcode 2.1.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.
@@ -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/llm.js CHANGED
@@ -294,8 +294,11 @@ export class ModelClient {
294
294
  if (done)
295
295
  break;
296
296
  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
298
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
+ }
299
302
  buffer = buffer.slice(-MAX_BUFFER / 2);
300
303
  }
301
304
  const lines = buffer.split('\n');
@@ -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, forceCompact, microCompact } from './compact.js';
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 fs from 'node:fs';
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.
@@ -222,378 +220,15 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
222
220
  break; // User wants to exit
223
221
  if (input === '')
224
222
  continue; // Empty input → re-prompt
225
- // Handle /stash and /unstash — git stash management
226
- if (input === '/stash') {
227
- try {
228
- const { execSync } = await import('node:child_process');
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
- onEvent({ kind: 'turn_done', reason: 'completed' });
520
- continue;
521
- }
522
- // Handle /plan — enter plan mode (restrict to read-only tools)
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
  }
@@ -64,15 +64,13 @@ export function budgetToolResults(history) {
64
64
  messageTotal += PREVIEW_CHARS + 200;
65
65
  continue;
66
66
  }
67
- // Per-message aggregate cap
67
+ // Per-message aggregate cap — once exceeded, truncate remaining results
68
68
  if (messageTotal + size > MAX_TOOL_RESULTS_PER_MESSAGE_CHARS) {
69
69
  modified = true;
70
- const remaining = Math.max(0, MAX_TOOL_RESULTS_PER_MESSAGE_CHARS - messageTotal);
71
- const preview = content.slice(0, Math.min(PREVIEW_CHARS, remaining));
72
70
  budgeted.push({
73
71
  type: 'tool_result',
74
72
  tool_use_id: part.tool_use_id,
75
- content: `[Output omitted: message budget exceeded (${MAX_TOOL_RESULTS_PER_MESSAGE_CHARS / 1000}K chars/msg)]\n\n${preview}`,
73
+ content: `[Output omitted: message budget exceeded (${MAX_TOOL_RESULTS_PER_MESSAGE_CHARS / 1000}K chars/msg)]`,
76
74
  is_error: part.is_error,
77
75
  });
78
76
  messageTotal = MAX_TOOL_RESULTS_PER_MESSAGE_CHARS;
@@ -32,7 +32,7 @@ async function execute(input, _ctx) {
32
32
  });
33
33
  rl.on('close', () => {
34
34
  if (!answered)
35
- resolve({ output: '(user skipped)' });
35
+ resolve({ output: 'User did not respond (EOF/piped input).', isError: true });
36
36
  });
37
37
  });
38
38
  }
@@ -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 > 20 || results.length >= MAX_RESULTS)
24
+ if (depth > 50 || results.length >= MAX_RESULTS)
25
25
  return;
26
26
  // Symlink loop protection
27
27
  const visitedSet = visited ?? new Set();
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/runcode",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "RunCode — AI coding agent powered by 41+ models. Pay per use with USDC.",
5
5
  "type": "module",
6
6
  "bin": {