@blockrun/runcode 2.5.2 → 2.5.3

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.
@@ -12,6 +12,8 @@ import { execSync } from 'node:child_process';
12
12
  import { BLOCKRUN_DIR, VERSION } from '../config.js';
13
13
  import { estimateHistoryTokens, getAnchoredTokenCount, getContextWindow, resetTokenAnchor } from './tokens.js';
14
14
  import { forceCompact } from './compact.js';
15
+ import { getStatsSummary } from '../stats/tracker.js';
16
+ import { resolveModel } from '../ui/model-picker.js';
15
17
  import { listSessions, loadSessionHistory, } from '../session/storage.js';
16
18
  // ─── Git helpers ──────────────────────────────────────────────────────────
17
19
  function gitExec(cmd, cwd, timeout = 5000, maxBuffer) {
@@ -28,7 +30,12 @@ function gitCmd(ctx, cmd, timeout, maxBuffer) {
28
30
  return gitExec(cmd, ctx.config.workingDir || process.cwd(), timeout, maxBuffer);
29
31
  }
30
32
  catch (e) {
31
- ctx.onEvent({ kind: 'text_delta', text: `Git error: ${e.message?.split('\n')[0] || 'unknown'}\n` });
33
+ // Prefer stderr (actual git error message) over the noisy "Command failed: ..." header
34
+ const errObj = e;
35
+ const stderr = errObj.stderr ? String(errObj.stderr).trim() : '';
36
+ // Take only the first meaningful line (git sometimes dumps full usage on errors)
37
+ const firstLine = (stderr || errObj.message || 'unknown').split('\n')[0].trim();
38
+ ctx.onEvent({ kind: 'text_delta', text: `Git: ${firstLine}\n` });
32
39
  return null;
33
40
  }
34
41
  }
@@ -41,34 +48,51 @@ const DIRECT_COMMANDS = {
41
48
  '/stash': (ctx) => {
42
49
  const r = gitCmd(ctx, 'git stash push -m "runcode auto-stash"', 10000);
43
50
  if (r !== null)
44
- ctx.onEvent({ kind: 'text_delta', text: r || 'No changes to stash.\n' });
51
+ ctx.onEvent({ kind: 'text_delta', text: r ? `${r}\n` : 'No changes to stash.\n' });
45
52
  emitDone(ctx);
46
53
  },
47
54
  '/unstash': (ctx) => {
48
55
  const r = gitCmd(ctx, 'git stash pop', 10000);
49
56
  if (r !== null)
50
- ctx.onEvent({ kind: 'text_delta', text: r || 'Stash applied.\n' });
57
+ ctx.onEvent({ kind: 'text_delta', text: r ? `${r}\n` : 'Stash applied.\n' });
51
58
  emitDone(ctx);
52
59
  },
53
60
  '/log': (ctx) => {
54
61
  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' });
62
+ if (r !== null)
63
+ ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'No commits yet.\n' });
56
64
  emitDone(ctx);
57
65
  },
58
66
  '/status': (ctx) => {
59
67
  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' });
68
+ if (r !== null)
69
+ ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'Working tree clean.\n' });
61
70
  emitDone(ctx);
62
71
  },
63
72
  '/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' });
73
+ // git diff with stat header then full diff
74
+ const stat = gitCmd(ctx, 'git diff --stat --no-color');
75
+ if (stat === null) {
76
+ emitDone(ctx);
77
+ return;
78
+ }
79
+ const full = gitCmd(ctx, 'git diff --no-color');
80
+ if (full === null) {
81
+ emitDone(ctx);
82
+ return;
83
+ }
84
+ if (!stat && !full) {
85
+ ctx.onEvent({ kind: 'text_delta', text: 'No unstaged changes.\n' });
86
+ }
87
+ else {
88
+ ctx.onEvent({ kind: 'text_delta', text: `\`\`\`diff\n${[stat, full].filter(Boolean).join('\n---\n')}\n\`\`\`\n` });
89
+ }
66
90
  emitDone(ctx);
67
91
  },
68
92
  '/undo': (ctx) => {
69
93
  const r = gitCmd(ctx, 'git reset --soft HEAD~1');
70
94
  if (r !== null)
71
- ctx.onEvent({ kind: 'text_delta', text: 'Last commit undone. Changes preserved in staging.\n' });
95
+ ctx.onEvent({ kind: 'text_delta', text: `Last commit undone. Changes preserved in staging.\n` });
72
96
  emitDone(ctx);
73
97
  },
74
98
  '/tokens': (ctx) => {
@@ -256,6 +280,64 @@ const DIRECT_COMMANDS = {
256
280
  }
257
281
  emitDone(ctx);
258
282
  },
283
+ '/cost': async (ctx) => {
284
+ const { stats, saved } = getStatsSummary();
285
+ ctx.onEvent({ kind: 'text_delta', text: `**Session Cost**\n` +
286
+ ` Requests: ${stats.totalRequests}\n` +
287
+ ` Cost: $${stats.totalCostUsd.toFixed(4)} USDC\n` +
288
+ ` Saved: $${saved.toFixed(2)} vs Claude Opus\n` +
289
+ ` Tokens: ${stats.totalInputTokens.toLocaleString()} in / ${stats.totalOutputTokens.toLocaleString()} out\n`
290
+ });
291
+ emitDone(ctx);
292
+ },
293
+ '/wallet': async (ctx) => {
294
+ const chain = (await import('../config.js')).loadChain();
295
+ try {
296
+ let address;
297
+ let balance;
298
+ if (chain === 'solana') {
299
+ const { getOrCreateSolanaWallet, setupAgentSolanaWallet } = await import('@blockrun/llm');
300
+ const w = await getOrCreateSolanaWallet();
301
+ address = w.address;
302
+ try {
303
+ const client = await setupAgentSolanaWallet({ silent: true });
304
+ const bal = await client.getBalance();
305
+ balance = `$${bal.toFixed(2)} USDC`;
306
+ }
307
+ catch {
308
+ balance = '(fetch failed)';
309
+ }
310
+ }
311
+ else {
312
+ const { getOrCreateWallet, setupAgentWallet } = await import('@blockrun/llm');
313
+ const w = getOrCreateWallet();
314
+ address = w.address;
315
+ try {
316
+ const client = setupAgentWallet({ silent: true });
317
+ const bal = await client.getBalance();
318
+ balance = `$${bal.toFixed(2)} USDC`;
319
+ }
320
+ catch {
321
+ balance = '(fetch failed)';
322
+ }
323
+ }
324
+ ctx.onEvent({ kind: 'text_delta', text: `**Wallet**\n` +
325
+ ` Chain: ${chain}\n` +
326
+ ` Address: ${address}\n` +
327
+ ` Balance: ${balance}\n`
328
+ });
329
+ }
330
+ catch (err) {
331
+ ctx.onEvent({ kind: 'text_delta', text: `Wallet error: ${err.message}\n` });
332
+ }
333
+ emitDone(ctx);
334
+ },
335
+ '/clear': (ctx) => {
336
+ ctx.history.length = 0;
337
+ resetTokenAnchor();
338
+ ctx.onEvent({ kind: 'text_delta', text: 'Conversation history cleared.\n' });
339
+ emitDone(ctx);
340
+ },
259
341
  '/compact': async (ctx) => {
260
342
  const beforeTokens = estimateHistoryTokens(ctx.history);
261
343
  const { history: compacted, compacted: didCompact } = await forceCompact(ctx.history, ctx.config.model, ctx.client, ctx.config.debug);
@@ -320,12 +402,28 @@ export async function handleSlashCommand(input, ctx) {
320
402
  await DIRECT_COMMANDS[input](ctx);
321
403
  return { handled: true };
322
404
  }
405
+ // /model — show current model or switch with /model <name>
406
+ if (input === '/model' || input.startsWith('/model ')) {
407
+ if (input === '/model') {
408
+ ctx.onEvent({ kind: 'text_delta', text: `Current model: **${ctx.config.model}**\n` +
409
+ `Switch with: \`/model <name>\` (e.g. \`/model sonnet\`, \`/model free\`, \`/model gemini\`)\n`
410
+ });
411
+ }
412
+ else {
413
+ const newModel = resolveModel(input.slice(7).trim());
414
+ ctx.config.model = newModel;
415
+ ctx.onEvent({ kind: 'text_delta', text: `Model → **${newModel}**\n` });
416
+ }
417
+ emitDone(ctx);
418
+ return { handled: true };
419
+ }
323
420
  // /branch has both no-arg and with-arg forms
324
421
  if (input === '/branch' || input.startsWith('/branch ')) {
325
422
  const cwd = ctx.config.workingDir || process.cwd();
326
423
  if (input === '/branch') {
327
424
  const r = gitCmd(ctx, 'git branch -v --no-color');
328
- ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'Not a git repo.\n' });
425
+ if (r !== null)
426
+ ctx.onEvent({ kind: 'text_delta', text: r ? `\`\`\`\n${r}\n\`\`\`\n` : 'No branches yet.\n' });
329
427
  }
330
428
  else {
331
429
  const branchName = input.slice(8).trim();
@@ -4,11 +4,6 @@
4
4
  * Original implementation with different architecture from any reference codebase.
5
5
  */
6
6
  import type { AgentConfig, Dialogue, StreamEvent } from './types.js';
7
- /**
8
- * Run the agent loop.
9
- * Yields StreamEvents for the UI to render. Returns when the conversation ends.
10
- */
11
- export declare function runAgent(config: AgentConfig, initialPrompt: string): AsyncGenerator<StreamEvent, void>;
12
7
  /**
13
8
  * Run a multi-turn interactive session.
14
9
  * Each user message triggers a full agent loop.
@@ -14,182 +14,6 @@ import { optimizeHistory, CAPPED_MAX_TOKENS, ESCALATED_MAX_TOKENS, getMaxOutputT
14
14
  import { recordUsage } from '../stats/tracker.js';
15
15
  import { estimateCost } from '../pricing.js';
16
16
  import { createSessionId, appendToSession, updateSessionMeta, pruneOldSessions, } from '../session/storage.js';
17
- // ─── Main Entry Point ──────────────────────────────────────────────────────
18
- /**
19
- * Run the agent loop.
20
- * Yields StreamEvents for the UI to render. Returns when the conversation ends.
21
- */
22
- export async function* runAgent(config, initialPrompt) {
23
- const client = new ModelClient({
24
- apiUrl: config.apiUrl,
25
- chain: config.chain,
26
- debug: config.debug,
27
- });
28
- const capabilityMap = new Map();
29
- for (const cap of config.capabilities) {
30
- capabilityMap.set(cap.spec.name, cap);
31
- }
32
- const toolDefs = config.capabilities.map((c) => c.spec);
33
- const maxTurns = config.maxTurns ?? 100;
34
- const workDir = config.workingDir ?? process.cwd();
35
- const state = {
36
- history: [
37
- { role: 'user', content: initialPrompt },
38
- ],
39
- turnIndex: 0,
40
- abort: new AbortController(),
41
- };
42
- // ─── Reasoning-Action Cycle ────────────────────────────────────────────
43
- while (state.turnIndex < maxTurns) {
44
- state.turnIndex++;
45
- // 1. Call model
46
- const { content: responseParts, usage } = await callModel(client, config, state, toolDefs);
47
- // Emit usage
48
- yield {
49
- kind: 'usage',
50
- inputTokens: usage.inputTokens,
51
- outputTokens: usage.outputTokens,
52
- model: config.model,
53
- };
54
- // 2. Classify response parts
55
- const textParts = [];
56
- const invocations = [];
57
- for (const part of responseParts) {
58
- if (part.type === 'text') {
59
- textParts.push(part.text);
60
- yield { kind: 'text_delta', text: part.text };
61
- }
62
- else if (part.type === 'tool_use') {
63
- invocations.push(part);
64
- }
65
- else if (part.type === 'thinking') {
66
- yield { kind: 'thinking_delta', text: part.thinking };
67
- }
68
- }
69
- // 3. Append assistant response to history
70
- state.history.push({
71
- role: 'assistant',
72
- content: responseParts,
73
- });
74
- // 4. If no capability invocations, the agent is done
75
- if (invocations.length === 0) {
76
- yield { kind: 'turn_done', reason: 'completed' };
77
- return;
78
- }
79
- // 5. Execute capabilities
80
- const outcomes = await executeCapabilities(invocations, capabilityMap, workDir, state.abort, (evt) => { config.onEvent?.(evt); });
81
- // Emit capability results
82
- for (const [invocation, result] of outcomes) {
83
- yield {
84
- kind: 'capability_done',
85
- id: invocation.id,
86
- result,
87
- };
88
- }
89
- // 6. Append capability outcomes as user message
90
- const outcomeContent = outcomes.map(([invocation, result]) => ({
91
- type: 'tool_result',
92
- tool_use_id: invocation.id,
93
- content: result.output,
94
- is_error: result.isError,
95
- }));
96
- state.history.push({
97
- role: 'user',
98
- content: outcomeContent,
99
- });
100
- // Continue to next cycle...
101
- }
102
- yield { kind: 'turn_done', reason: 'max_turns' };
103
- }
104
- // ─── Model Call ────────────────────────────────────────────────────────────
105
- async function callModel(client, config, state, tools) {
106
- const systemPrompt = config.systemInstructions.join('\n\n');
107
- return client.complete({
108
- model: config.model,
109
- messages: state.history,
110
- system: systemPrompt,
111
- tools,
112
- max_tokens: 16384,
113
- stream: true,
114
- }, state.abort.signal);
115
- }
116
- // ─── Capability Execution ──────────────────────────────────────────────────
117
- async function executeCapabilities(invocations, handlers, workDir, abort, emitEvent, permissions) {
118
- // Partition into concurrent-safe and sequential
119
- const concurrent = [];
120
- const sequential = [];
121
- for (const inv of invocations) {
122
- const handler = handlers.get(inv.name);
123
- if (handler?.concurrent) {
124
- concurrent.push(inv);
125
- }
126
- else {
127
- sequential.push(inv);
128
- }
129
- }
130
- const results = [];
131
- const scope = {
132
- workingDir: workDir,
133
- abortSignal: abort.signal,
134
- };
135
- // Run concurrent capabilities in parallel
136
- if (concurrent.length > 0) {
137
- const batch = concurrent.map(async (inv) => {
138
- const result = await checkAndRun(inv, handlers, scope, permissions, emitEvent);
139
- return [inv, result];
140
- });
141
- const batchResults = await Promise.all(batch);
142
- results.push(...batchResults);
143
- }
144
- // Run sequential capabilities one at a time
145
- for (const inv of sequential) {
146
- const result = await checkAndRun(inv, handlers, scope, permissions, emitEvent);
147
- results.push([inv, result]);
148
- }
149
- return results;
150
- }
151
- async function checkAndRun(invocation, handlers, scope, permissions, emitEvent) {
152
- // Permission check
153
- if (permissions) {
154
- const decision = await permissions.check(invocation.name, invocation.input);
155
- if (decision.behavior === 'deny') {
156
- return {
157
- output: `Permission denied for ${invocation.name}: ${decision.reason || 'denied by policy'}`,
158
- isError: true,
159
- };
160
- }
161
- if (decision.behavior === 'ask') {
162
- const allowed = await permissions.promptUser(invocation.name, invocation.input);
163
- if (!allowed) {
164
- return {
165
- output: `User denied permission for ${invocation.name}`,
166
- isError: true,
167
- };
168
- }
169
- }
170
- }
171
- emitEvent({ kind: 'capability_start', id: invocation.id, name: invocation.name });
172
- return runSingleCapability(invocation, handlers, scope);
173
- }
174
- async function runSingleCapability(invocation, handlers, scope) {
175
- const handler = handlers.get(invocation.name);
176
- if (!handler) {
177
- return {
178
- output: `Unknown capability: ${invocation.name}`,
179
- isError: true,
180
- };
181
- }
182
- try {
183
- return await handler.execute(invocation.input, scope);
184
- }
185
- catch (err) {
186
- const message = err instanceof Error ? err.message : String(err);
187
- return {
188
- output: `Error executing ${invocation.name}: ${message}`,
189
- isError: true,
190
- };
191
- }
192
- }
193
17
  // ─── Interactive Session ───────────────────────────────────────────────────
194
18
  /**
195
19
  * Run a multi-turn interactive session.
@@ -211,6 +35,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
211
35
  const workDir = config.workingDir ?? process.cwd();
212
36
  const permissions = new PermissionManager(config.permissionMode ?? 'default');
213
37
  const history = [];
38
+ let lastUserInput = ''; // For /retry
214
39
  // Session persistence
215
40
  const sessionId = createSessionId();
216
41
  let turnCount = 0;
@@ -224,14 +49,26 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
224
49
  continue; // Empty input → re-prompt
225
50
  // ── Slash command dispatch ──
226
51
  if (input.startsWith('/')) {
227
- const cmdResult = await handleSlashCommand(input, {
228
- history, config, client, sessionId, onEvent,
229
- });
230
- if (cmdResult.handled)
231
- continue;
232
- if (cmdResult.rewritten)
233
- input = cmdResult.rewritten;
52
+ // /retry re-sends the last user message
53
+ if (input === '/retry') {
54
+ if (!lastUserInput) {
55
+ onEvent({ kind: 'text_delta', text: 'No previous message to retry.\n' });
56
+ onEvent({ kind: 'turn_done', reason: 'completed' });
57
+ continue;
58
+ }
59
+ input = lastUserInput;
60
+ }
61
+ else {
62
+ const cmdResult = await handleSlashCommand(input, {
63
+ history, config, client, sessionId, onEvent,
64
+ });
65
+ if (cmdResult.handled)
66
+ continue;
67
+ if (cmdResult.rewritten)
68
+ input = cmdResult.rewritten;
69
+ }
234
70
  }
71
+ lastUserInput = input;
235
72
  history.push({ role: 'user', content: input });
236
73
  appendToSession(sessionId, { role: 'user', content: input });
237
74
  turnCount++;
package/dist/ui/app.js CHANGED
@@ -171,8 +171,10 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
171
171
  setStreamText('');
172
172
  setTools(new Map());
173
173
  setTurnTokens({ input: 0, output: 0 });
174
- setStatusMsg('Conversation cleared');
175
- setTimeout(() => setStatusMsg(''), 3000);
174
+ setWaiting(true);
175
+ setReady(false);
176
+ // Pass through to agent loop to clear the actual conversation history
177
+ onSubmit('/clear');
176
178
  return;
177
179
  case '/retry':
178
180
  if (!lastPrompt) {
@@ -154,7 +154,9 @@ export class TerminalUI {
154
154
  });
155
155
  rl.on('close', () => {
156
156
  this.stdinEOF = true;
157
- this.lineQueue = []; // Don't deliver buffered lines after EOF signal exit cleanly
157
+ // Keep lineQueue intact buffered lines should still drain before signaling EOF.
158
+ // If there are active waiters, queue is already empty (nextLine checks queue first),
159
+ // so it's safe to resolve them with null now.
158
160
  for (const waiter of this.lineWaiters)
159
161
  waiter(null);
160
162
  this.lineWaiters = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/runcode",
3
- "version": "2.5.2",
3
+ "version": "2.5.3",
4
4
  "description": "RunCode — AI coding agent powered by 41+ models. Pay per use with USDC.",
5
5
  "type": "module",
6
6
  "bin": {