@blockrun/franklin 3.0.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.
Files changed (138) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +256 -0
  3. package/dist/agent/commands.d.ts +27 -0
  4. package/dist/agent/commands.js +659 -0
  5. package/dist/agent/compact.d.ts +31 -0
  6. package/dist/agent/compact.js +366 -0
  7. package/dist/agent/context.d.ts +11 -0
  8. package/dist/agent/context.js +184 -0
  9. package/dist/agent/error-classifier.d.ts +10 -0
  10. package/dist/agent/error-classifier.js +61 -0
  11. package/dist/agent/llm.d.ts +63 -0
  12. package/dist/agent/llm.js +448 -0
  13. package/dist/agent/loop.d.ts +12 -0
  14. package/dist/agent/loop.js +346 -0
  15. package/dist/agent/optimize.d.ts +53 -0
  16. package/dist/agent/optimize.js +262 -0
  17. package/dist/agent/permissions.d.ts +39 -0
  18. package/dist/agent/permissions.js +226 -0
  19. package/dist/agent/reduce.d.ts +49 -0
  20. package/dist/agent/reduce.js +317 -0
  21. package/dist/agent/streaming-executor.d.ts +36 -0
  22. package/dist/agent/streaming-executor.js +149 -0
  23. package/dist/agent/tokens.d.ts +53 -0
  24. package/dist/agent/tokens.js +185 -0
  25. package/dist/agent/types.d.ts +125 -0
  26. package/dist/agent/types.js +5 -0
  27. package/dist/banner.d.ts +1 -0
  28. package/dist/banner.js +27 -0
  29. package/dist/commands/balance.d.ts +1 -0
  30. package/dist/commands/balance.js +40 -0
  31. package/dist/commands/config.d.ts +14 -0
  32. package/dist/commands/config.js +107 -0
  33. package/dist/commands/daemon.d.ts +3 -0
  34. package/dist/commands/daemon.js +117 -0
  35. package/dist/commands/history.d.ts +5 -0
  36. package/dist/commands/history.js +31 -0
  37. package/dist/commands/init.d.ts +3 -0
  38. package/dist/commands/init.js +92 -0
  39. package/dist/commands/logs.d.ts +5 -0
  40. package/dist/commands/logs.js +89 -0
  41. package/dist/commands/models.d.ts +1 -0
  42. package/dist/commands/models.js +56 -0
  43. package/dist/commands/plugin.d.ts +14 -0
  44. package/dist/commands/plugin.js +176 -0
  45. package/dist/commands/proxy.d.ts +13 -0
  46. package/dist/commands/proxy.js +106 -0
  47. package/dist/commands/setup.d.ts +1 -0
  48. package/dist/commands/setup.js +49 -0
  49. package/dist/commands/start.d.ts +8 -0
  50. package/dist/commands/start.js +292 -0
  51. package/dist/commands/stats.d.ts +10 -0
  52. package/dist/commands/stats.js +94 -0
  53. package/dist/commands/uninit.d.ts +1 -0
  54. package/dist/commands/uninit.js +63 -0
  55. package/dist/config.d.ts +9 -0
  56. package/dist/config.js +41 -0
  57. package/dist/index.d.ts +2 -0
  58. package/dist/index.js +179 -0
  59. package/dist/mcp/client.d.ts +44 -0
  60. package/dist/mcp/client.js +147 -0
  61. package/dist/mcp/config.d.ts +20 -0
  62. package/dist/mcp/config.js +138 -0
  63. package/dist/plugin-sdk/channel.d.ts +100 -0
  64. package/dist/plugin-sdk/channel.js +10 -0
  65. package/dist/plugin-sdk/index.d.ts +14 -0
  66. package/dist/plugin-sdk/index.js +9 -0
  67. package/dist/plugin-sdk/plugin.d.ts +87 -0
  68. package/dist/plugin-sdk/plugin.js +7 -0
  69. package/dist/plugin-sdk/search.d.ts +13 -0
  70. package/dist/plugin-sdk/search.js +4 -0
  71. package/dist/plugin-sdk/tracker.d.ts +27 -0
  72. package/dist/plugin-sdk/tracker.js +5 -0
  73. package/dist/plugin-sdk/workflow.d.ts +126 -0
  74. package/dist/plugin-sdk/workflow.js +11 -0
  75. package/dist/plugins/registry.d.ts +33 -0
  76. package/dist/plugins/registry.js +155 -0
  77. package/dist/plugins/runner.d.ts +21 -0
  78. package/dist/plugins/runner.js +453 -0
  79. package/dist/plugins-bundled/social/index.d.ts +10 -0
  80. package/dist/plugins-bundled/social/index.js +363 -0
  81. package/dist/plugins-bundled/social/plugin.json +14 -0
  82. package/dist/plugins-bundled/social/prompts.d.ts +19 -0
  83. package/dist/plugins-bundled/social/prompts.js +67 -0
  84. package/dist/plugins-bundled/social/types.d.ts +58 -0
  85. package/dist/plugins-bundled/social/types.js +16 -0
  86. package/dist/pricing.d.ts +21 -0
  87. package/dist/pricing.js +91 -0
  88. package/dist/proxy/fallback.d.ts +38 -0
  89. package/dist/proxy/fallback.js +144 -0
  90. package/dist/proxy/server.d.ts +18 -0
  91. package/dist/proxy/server.js +576 -0
  92. package/dist/proxy/sse-translator.d.ts +29 -0
  93. package/dist/proxy/sse-translator.js +270 -0
  94. package/dist/router/index.d.ts +22 -0
  95. package/dist/router/index.js +269 -0
  96. package/dist/session/search.d.ts +33 -0
  97. package/dist/session/search.js +229 -0
  98. package/dist/session/storage.d.ts +48 -0
  99. package/dist/session/storage.js +173 -0
  100. package/dist/stats/insights.d.ts +55 -0
  101. package/dist/stats/insights.js +195 -0
  102. package/dist/stats/tracker.d.ts +54 -0
  103. package/dist/stats/tracker.js +165 -0
  104. package/dist/tools/askuser.d.ts +6 -0
  105. package/dist/tools/askuser.js +76 -0
  106. package/dist/tools/bash.d.ts +5 -0
  107. package/dist/tools/bash.js +336 -0
  108. package/dist/tools/edit.d.ts +5 -0
  109. package/dist/tools/edit.js +148 -0
  110. package/dist/tools/glob.d.ts +5 -0
  111. package/dist/tools/glob.js +158 -0
  112. package/dist/tools/grep.d.ts +5 -0
  113. package/dist/tools/grep.js +194 -0
  114. package/dist/tools/imagegen.d.ts +6 -0
  115. package/dist/tools/imagegen.js +172 -0
  116. package/dist/tools/index.d.ts +17 -0
  117. package/dist/tools/index.js +30 -0
  118. package/dist/tools/read.d.ts +11 -0
  119. package/dist/tools/read.js +90 -0
  120. package/dist/tools/subagent.d.ts +5 -0
  121. package/dist/tools/subagent.js +116 -0
  122. package/dist/tools/task.d.ts +5 -0
  123. package/dist/tools/task.js +91 -0
  124. package/dist/tools/webfetch.d.ts +5 -0
  125. package/dist/tools/webfetch.js +166 -0
  126. package/dist/tools/websearch.d.ts +5 -0
  127. package/dist/tools/websearch.js +103 -0
  128. package/dist/tools/write.d.ts +5 -0
  129. package/dist/tools/write.js +114 -0
  130. package/dist/ui/app.d.ts +26 -0
  131. package/dist/ui/app.js +545 -0
  132. package/dist/ui/model-picker.d.ts +14 -0
  133. package/dist/ui/model-picker.js +161 -0
  134. package/dist/ui/terminal.d.ts +35 -0
  135. package/dist/ui/terminal.js +337 -0
  136. package/dist/wallet/manager.d.ts +10 -0
  137. package/dist/wallet/manager.js +23 -0
  138. package/package.json +79 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Usage tracking for runcode
3
+ * Records all requests with cost, tokens, and latency for stats display
4
+ */
5
+ export interface UsageRecord {
6
+ timestamp: number;
7
+ model: string;
8
+ inputTokens: number;
9
+ outputTokens: number;
10
+ costUsd: number;
11
+ latencyMs: number;
12
+ fallback?: boolean;
13
+ }
14
+ export interface ModelStats {
15
+ requests: number;
16
+ costUsd: number;
17
+ inputTokens: number;
18
+ outputTokens: number;
19
+ fallbackCount: number;
20
+ avgLatencyMs: number;
21
+ totalLatencyMs: number;
22
+ }
23
+ export interface Stats {
24
+ version: number;
25
+ totalRequests: number;
26
+ totalCostUsd: number;
27
+ totalInputTokens: number;
28
+ totalOutputTokens: number;
29
+ totalFallbacks: number;
30
+ byModel: Record<string, ModelStats>;
31
+ history: UsageRecord[];
32
+ firstRequest?: number;
33
+ lastRequest?: number;
34
+ }
35
+ export declare function loadStats(): Stats;
36
+ export declare function saveStats(stats: Stats): void;
37
+ export declare function clearStats(): void;
38
+ /** Flush stats to disk immediately (call on process exit) */
39
+ export declare function flushStats(): void;
40
+ /**
41
+ * Record a completed request for stats tracking
42
+ */
43
+ export declare function recordUsage(model: string, inputTokens: number, outputTokens: number, costUsd: number, latencyMs: number, fallback?: boolean): void;
44
+ /**
45
+ * Get stats summary for display
46
+ */
47
+ export declare function getStatsSummary(): {
48
+ stats: Stats;
49
+ opusCost: number;
50
+ saved: number;
51
+ savedPct: number;
52
+ avgCostPerRequest: number;
53
+ period: string;
54
+ };
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Usage tracking for runcode
3
+ * Records all requests with cost, tokens, and latency for stats display
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import os from 'node:os';
8
+ import { OPUS_PRICING } from '../pricing.js';
9
+ const STATS_FILE = path.join(os.homedir(), '.blockrun', 'runcode-stats.json');
10
+ const EMPTY_STATS = {
11
+ version: 1,
12
+ totalRequests: 0,
13
+ totalCostUsd: 0,
14
+ totalInputTokens: 0,
15
+ totalOutputTokens: 0,
16
+ totalFallbacks: 0,
17
+ byModel: {},
18
+ history: [],
19
+ };
20
+ export function loadStats() {
21
+ try {
22
+ if (fs.existsSync(STATS_FILE)) {
23
+ const data = JSON.parse(fs.readFileSync(STATS_FILE, 'utf-8'));
24
+ // Migration: add missing fields
25
+ return {
26
+ ...EMPTY_STATS,
27
+ ...data,
28
+ version: 1,
29
+ };
30
+ }
31
+ }
32
+ catch {
33
+ /* ignore parse errors, return empty */
34
+ }
35
+ return { ...EMPTY_STATS };
36
+ }
37
+ export function saveStats(stats) {
38
+ try {
39
+ fs.mkdirSync(path.dirname(STATS_FILE), { recursive: true });
40
+ // Keep only last 1000 history records
41
+ stats.history = stats.history.slice(-1000);
42
+ fs.writeFileSync(STATS_FILE, JSON.stringify(stats, null, 2));
43
+ }
44
+ catch {
45
+ /* ignore write errors */
46
+ }
47
+ }
48
+ export function clearStats() {
49
+ cachedStats = null;
50
+ if (flushTimer) {
51
+ clearTimeout(flushTimer);
52
+ flushTimer = null;
53
+ }
54
+ try {
55
+ if (fs.existsSync(STATS_FILE)) {
56
+ fs.unlinkSync(STATS_FILE);
57
+ }
58
+ }
59
+ catch {
60
+ /* ignore */
61
+ }
62
+ }
63
+ // ─── In-memory stats cache with debounced write ─────────────────────────
64
+ // Prevents concurrent load→modify→save from losing data in proxy mode
65
+ let cachedStats = null;
66
+ let flushTimer = null;
67
+ const FLUSH_DELAY_MS = 2000;
68
+ function getCachedStats() {
69
+ if (!cachedStats) {
70
+ cachedStats = loadStats();
71
+ }
72
+ return cachedStats;
73
+ }
74
+ function scheduleSave() {
75
+ if (flushTimer)
76
+ return; // Already scheduled
77
+ flushTimer = setTimeout(() => {
78
+ flushTimer = null;
79
+ if (cachedStats)
80
+ saveStats(cachedStats);
81
+ }, FLUSH_DELAY_MS);
82
+ }
83
+ /** Flush stats to disk immediately (call on process exit) */
84
+ export function flushStats() {
85
+ if (flushTimer) {
86
+ clearTimeout(flushTimer);
87
+ flushTimer = null;
88
+ }
89
+ if (cachedStats)
90
+ saveStats(cachedStats);
91
+ }
92
+ /**
93
+ * Record a completed request for stats tracking
94
+ */
95
+ export function recordUsage(model, inputTokens, outputTokens, costUsd, latencyMs, fallback = false) {
96
+ const stats = getCachedStats();
97
+ const now = Date.now();
98
+ // Update totals
99
+ stats.totalRequests++;
100
+ stats.totalCostUsd += costUsd;
101
+ stats.totalInputTokens += inputTokens;
102
+ stats.totalOutputTokens += outputTokens;
103
+ if (fallback)
104
+ stats.totalFallbacks++;
105
+ // Update timestamps
106
+ if (!stats.firstRequest)
107
+ stats.firstRequest = now;
108
+ stats.lastRequest = now;
109
+ // Update per-model stats
110
+ if (!stats.byModel[model]) {
111
+ stats.byModel[model] = {
112
+ requests: 0,
113
+ costUsd: 0,
114
+ inputTokens: 0,
115
+ outputTokens: 0,
116
+ fallbackCount: 0,
117
+ avgLatencyMs: 0,
118
+ totalLatencyMs: 0,
119
+ };
120
+ }
121
+ const modelStats = stats.byModel[model];
122
+ modelStats.requests++;
123
+ modelStats.costUsd += costUsd;
124
+ modelStats.inputTokens += inputTokens;
125
+ modelStats.outputTokens += outputTokens;
126
+ modelStats.totalLatencyMs += latencyMs;
127
+ modelStats.avgLatencyMs = modelStats.totalLatencyMs / modelStats.requests;
128
+ if (fallback)
129
+ modelStats.fallbackCount++;
130
+ // Add to history
131
+ stats.history.push({
132
+ timestamp: now,
133
+ model,
134
+ inputTokens,
135
+ outputTokens,
136
+ costUsd,
137
+ latencyMs,
138
+ fallback,
139
+ });
140
+ scheduleSave();
141
+ }
142
+ /**
143
+ * Get stats summary for display
144
+ */
145
+ export function getStatsSummary() {
146
+ const stats = loadStats();
147
+ // Calculate what it would cost with Claude Opus
148
+ const opusCost = (stats.totalInputTokens / 1_000_000) * OPUS_PRICING.input +
149
+ (stats.totalOutputTokens / 1_000_000) * OPUS_PRICING.output;
150
+ const saved = opusCost - stats.totalCostUsd;
151
+ const savedPct = opusCost > 0 ? (saved / opusCost) * 100 : 0;
152
+ const avgCostPerRequest = stats.totalRequests > 0 ? stats.totalCostUsd / stats.totalRequests : 0;
153
+ // Calculate period
154
+ let period = 'No data';
155
+ if (stats.firstRequest && stats.lastRequest) {
156
+ const days = Math.ceil((stats.lastRequest - stats.firstRequest) / (1000 * 60 * 60 * 24));
157
+ if (days === 0)
158
+ period = 'Today';
159
+ else if (days === 1)
160
+ period = '1 day';
161
+ else
162
+ period = `${days} days`;
163
+ }
164
+ return { stats, opusCost, saved, savedPct, avgCostPerRequest, period };
165
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * AskUser capability — let the agent ask the user a clarifying question.
3
+ * The question is displayed and the response is returned as tool output.
4
+ */
5
+ import type { CapabilityHandler } from '../agent/types.js';
6
+ export declare const askUserCapability: CapabilityHandler;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * AskUser capability — let the agent ask the user a clarifying question.
3
+ * The question is displayed and the response is returned as tool output.
4
+ */
5
+ import readline from 'node:readline';
6
+ import chalk from 'chalk';
7
+ async function execute(input, ctx) {
8
+ const { question, options } = input;
9
+ if (!question) {
10
+ return { output: 'Error: question is required', isError: true };
11
+ }
12
+ // Ink UI path: use the provided callback to avoid raw-mode stdin conflict
13
+ if (ctx.onAskUser) {
14
+ try {
15
+ const answer = await ctx.onAskUser(question, options);
16
+ return { output: answer || '(no response)' };
17
+ }
18
+ catch {
19
+ return { output: 'User did not respond.', isError: false };
20
+ }
21
+ }
22
+ // Non-ink fallback (CLI piped / scripted mode)
23
+ if (!process.stdin.isTTY) {
24
+ return {
25
+ output: `[Non-interactive mode] Cannot prompt user. Proceed with a reasonable assumption. Question was: ${question}`,
26
+ isError: false,
27
+ };
28
+ }
29
+ // Bare TTY fallback (no ink UI) — use readline
30
+ console.error('');
31
+ console.error(chalk.yellow(' ╭─ Question ────────────────────────────'));
32
+ console.error(chalk.yellow(` │ ${question}`));
33
+ if (options && options.length > 0) {
34
+ for (let i = 0; i < options.length; i++) {
35
+ console.error(chalk.dim(` │ ${i + 1}. ${options[i]}`));
36
+ }
37
+ }
38
+ console.error(chalk.yellow(' ╰───────────────────────────────────────'));
39
+ const rl = readline.createInterface({
40
+ input: process.stdin,
41
+ output: process.stderr,
42
+ terminal: true,
43
+ });
44
+ return new Promise((resolve) => {
45
+ let answered = false;
46
+ rl.question(chalk.bold(' answer> '), (answer) => {
47
+ answered = true;
48
+ rl.close();
49
+ resolve({ output: answer.trim() || '(no response)' });
50
+ });
51
+ rl.on('close', () => {
52
+ if (!answered)
53
+ resolve({ output: 'User closed input without responding.', isError: false });
54
+ });
55
+ });
56
+ }
57
+ export const askUserCapability = {
58
+ spec: {
59
+ name: 'AskUser',
60
+ description: 'Ask the user a clarifying question. Use when you need more information before proceeding.',
61
+ input_schema: {
62
+ type: 'object',
63
+ properties: {
64
+ question: { type: 'string', description: 'The question to ask the user' },
65
+ options: {
66
+ type: 'array',
67
+ items: { type: 'string' },
68
+ description: 'Optional list of suggested answers to present',
69
+ },
70
+ },
71
+ required: ['question'],
72
+ },
73
+ },
74
+ execute,
75
+ concurrent: false,
76
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Bash capability — execute shell commands with timeout and output capture.
3
+ */
4
+ import type { CapabilityHandler } from '../agent/types.js';
5
+ export declare const bashCapability: CapabilityHandler;
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Bash capability — execute shell commands with timeout and output capture.
3
+ */
4
+ import { spawn } from 'node:child_process';
5
+ // ─── Smart Output Compression ─────────────────────────────────────────────
6
+ // Learned from RTK (Rust Token Killer): strip noise before sending to LLM.
7
+ // Applied after capture, before the 32KB cap — reduces tokens on verbose commands.
8
+ const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
9
+ function stripAnsi(s) {
10
+ return s.replace(ANSI_RE, '');
11
+ }
12
+ function collapseBlankLines(s) {
13
+ // Collapse 3+ consecutive blank lines → 1 blank line
14
+ return s.replace(/\n{3,}/g, '\n\n');
15
+ }
16
+ /** Extract the base command word (first non-env token). */
17
+ function baseCmd(command) {
18
+ // Strip leading env var assignments (FOO=bar cmd → cmd)
19
+ const stripped = command.replace(/^(?:[A-Z_][A-Z0-9_]*=\S*\s+)*/, '').trimStart();
20
+ return stripped.split(/\s+/)[0] ?? '';
21
+ }
22
+ function compressOutput(command, output) {
23
+ // 1. Always strip ANSI escape codes
24
+ let out = stripAnsi(output);
25
+ const cmd = baseCmd(command);
26
+ const fullCmd = command.trimStart();
27
+ // 2. Git command-aware compression
28
+ if (cmd === 'git') {
29
+ const sub = fullCmd.split(/\s+/)[1] ?? '';
30
+ out = compressGit(sub, out);
31
+ }
32
+ // 3. Package manager installs — keep only errors + final summary
33
+ else if (/^(npm|pnpm|yarn|bun)\s+(install|i|add|ci)\b/.test(fullCmd)) {
34
+ out = compressInstall(out);
35
+ }
36
+ // 4. Test runners — keep only failures + summary line
37
+ else if (/^(npm|pnpm|bun)\s+test\b|^(jest|vitest|mocha)\b/.test(fullCmd)) {
38
+ out = compressTests(out);
39
+ }
40
+ // 5. Build commands — keep errors/warnings, drop verbose compile lines
41
+ else if (/^(npm|pnpm|bun)\s+(run\s+)?(build|compile)\b|^tsc\b/.test(fullCmd)) {
42
+ out = compressBuild(out);
43
+ }
44
+ // 6. cargo
45
+ else if (cmd === 'cargo') {
46
+ const sub = fullCmd.split(/\s+/)[1] ?? '';
47
+ if (sub === 'test' || sub === 'nextest')
48
+ out = compressTests(out);
49
+ else if (sub === 'build' || sub === 'check' || sub === 'clippy')
50
+ out = compressBuild(out);
51
+ else if (sub === 'install')
52
+ out = compressInstall(out);
53
+ }
54
+ // 7. Always collapse excessive blank lines
55
+ out = collapseBlankLines(out);
56
+ return out;
57
+ }
58
+ function compressGit(sub, out) {
59
+ switch (sub) {
60
+ case 'add': {
61
+ // git add is usually silent. Strip any blank output.
62
+ const trimmed = out.trim();
63
+ return trimmed || 'ok';
64
+ }
65
+ case 'commit': {
66
+ // Keep: [branch abc1234] message + stats line. Strip verbose output.
67
+ const lines = out.split('\n');
68
+ const kept = lines.filter(l => /^\[.+\]/.test(l) || // [main abc1234] commit msg
69
+ /\d+ file/.test(l) || // 2 files changed, 10 insertions
70
+ /^\s*(create|delete) mode/.test(l) ||
71
+ l.trim() === '');
72
+ return kept.join('\n').trim() || out.trim();
73
+ }
74
+ case 'push': {
75
+ // Strip verbose remote "enumerating/counting/compressing" lines
76
+ const lines = out.split('\n').filter(l => !/^remote:\s*(Enumerating|Counting|Compressing|Writing|Total|Delta)/.test(l) &&
77
+ !/^Counting objects|^Compressing objects|^Writing objects/.test(l) &&
78
+ l.trim() !== '');
79
+ return lines.join('\n').trim() || 'ok';
80
+ }
81
+ case 'pull': {
82
+ // Strip "remote: Counting..." lines, keep summary
83
+ const lines = out.split('\n').filter(l => !/^remote:\s*(Enumerating|Counting|Compressing|Writing|Total|Delta)/.test(l) &&
84
+ !/^Counting objects|^Compressing objects/.test(l));
85
+ return collapseBlankLines(lines.join('\n')).trim();
86
+ }
87
+ case 'fetch': {
88
+ const lines = out.split('\n').filter(l => !/^remote:\s*(Enumerating|Counting|Compressing|Writing|Total|Delta)/.test(l));
89
+ return lines.join('\n').trim();
90
+ }
91
+ case 'log': {
92
+ // Already terse if user uses --oneline; just collapse blanks
93
+ return out.trim();
94
+ }
95
+ default:
96
+ return out;
97
+ }
98
+ }
99
+ function compressInstall(out) {
100
+ const lines = out.split('\n');
101
+ const kept = [];
102
+ for (const line of lines) {
103
+ const l = line.trim();
104
+ // Drop pure progress lines
105
+ if (/^(Downloading|Fetching|Resolving|Progress|Preparing|Caching)/.test(l))
106
+ continue;
107
+ if (/^[\s.]*$/.test(l))
108
+ continue;
109
+ // Keep errors, warnings, and summary lines
110
+ kept.push(line);
111
+ }
112
+ // If no lines kept, return original trimmed (don't lose error info)
113
+ const result = kept.join('\n').trim();
114
+ return result || out.trim();
115
+ }
116
+ function compressTests(out) {
117
+ const lines = out.split('\n');
118
+ // Look for failure sections and summary
119
+ const kept = [];
120
+ let inFailure = false;
121
+ for (const line of lines) {
122
+ const l = line.trim();
123
+ // Detect failure/error blocks
124
+ if (/^(FAIL|FAILED|Error:|●|✕|✗|×|error\[)/.test(l)) {
125
+ inFailure = true;
126
+ }
127
+ // Summary lines (always keep)
128
+ if (/^(Tests?|Test Suites?|Suites?|PASS|FAIL|ok\s|error|warning|\d+ (test|spec|example))/.test(l) ||
129
+ /\d+\s*(passed|failed|skipped|pending|todo)/.test(l)) {
130
+ kept.push(line);
131
+ inFailure = false;
132
+ continue;
133
+ }
134
+ if (inFailure) {
135
+ kept.push(line);
136
+ // End failure block on blank line after content
137
+ if (l === '' && kept[kept.length - 2]?.trim() !== '')
138
+ inFailure = false;
139
+ }
140
+ }
141
+ // If nothing matched (e.g. all passed with no verbose output), return original
142
+ if (kept.length === 0)
143
+ return out.trim();
144
+ return collapseBlankLines(kept.join('\n')).trim();
145
+ }
146
+ function compressBuild(out) {
147
+ const lines = out.split('\n');
148
+ const kept = lines.filter(l => {
149
+ const t = l.trim();
150
+ if (t === '')
151
+ return false;
152
+ // Drop pure progress/info lines from bundlers/compilers
153
+ if (/^(Compiling|Finished|Checking|warning: unused import)/.test(t) &&
154
+ !/^(Compiling.*error|Finished.*error)/.test(t)) {
155
+ // Keep "Finished" summary
156
+ if (/^Finished/.test(t))
157
+ return true;
158
+ return false;
159
+ }
160
+ return true;
161
+ });
162
+ return collapseBlankLines(kept.join('\n')).trim() || out.trim();
163
+ }
164
+ const MAX_OUTPUT_BYTES = 512 * 1024; // 512KB capture buffer (prevents OOM)
165
+ const MAX_RETURN_CHARS = 32_000; // 32KB return cap (~8,000 tokens) — prevents context bloat
166
+ const DEFAULT_TIMEOUT_MS = 120_000; // 2 minutes
167
+ async function execute(input, ctx) {
168
+ const { command, timeout } = input;
169
+ if (!command || typeof command !== 'string') {
170
+ return { output: 'Error: command is required', isError: true };
171
+ }
172
+ const timeoutMs = Math.min(timeout ?? DEFAULT_TIMEOUT_MS, 600_000);
173
+ return new Promise((resolve) => {
174
+ const shell = process.env.SHELL || '/bin/bash';
175
+ let child;
176
+ try {
177
+ child = spawn(shell, ['-c', command], {
178
+ cwd: ctx.workingDir,
179
+ env: {
180
+ ...process.env,
181
+ RUNCODE: '1', // Let scripts detect they're running inside runcode
182
+ RUNCODE_WORKDIR: ctx.workingDir,
183
+ },
184
+ stdio: ['ignore', 'pipe', 'pipe'],
185
+ });
186
+ }
187
+ catch (spawnErr) {
188
+ resolve({ output: `Error spawning shell: ${spawnErr.message}`, isError: true });
189
+ return;
190
+ }
191
+ let stdout = '';
192
+ let stderr = '';
193
+ let outputBytes = 0;
194
+ let truncated = false;
195
+ let killed = false;
196
+ const timer = setTimeout(() => {
197
+ killed = true;
198
+ child.kill('SIGTERM');
199
+ setTimeout(() => {
200
+ try {
201
+ child.kill('SIGKILL');
202
+ }
203
+ catch { /* already dead */ }
204
+ }, 5000); // Give 5s for graceful shutdown before SIGKILL
205
+ }, timeoutMs);
206
+ // Handle abort signal
207
+ const onAbort = () => {
208
+ killed = true;
209
+ child.kill('SIGTERM');
210
+ };
211
+ ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
212
+ // Emit last non-empty line to UI progress (throttled to avoid flooding)
213
+ let lastProgressEmit = 0;
214
+ const emitProgress = (text) => {
215
+ if (!ctx.onProgress)
216
+ return;
217
+ const now = Date.now();
218
+ if (now - lastProgressEmit < 500)
219
+ return; // max 2 updates/sec
220
+ lastProgressEmit = now;
221
+ const lastLine = text.split('\n').map(l => l.trim()).filter(Boolean).pop();
222
+ if (lastLine)
223
+ ctx.onProgress(lastLine.slice(0, 120));
224
+ };
225
+ child.stdout?.on('data', (chunk) => {
226
+ if (truncated)
227
+ return;
228
+ const remaining = MAX_OUTPUT_BYTES - outputBytes;
229
+ if (remaining <= 0) {
230
+ truncated = true;
231
+ return;
232
+ }
233
+ const text = chunk.toString('utf-8');
234
+ if (chunk.length <= remaining) {
235
+ stdout += text;
236
+ outputBytes += chunk.length;
237
+ }
238
+ else {
239
+ stdout += text.slice(0, remaining);
240
+ outputBytes = MAX_OUTPUT_BYTES;
241
+ truncated = true;
242
+ }
243
+ emitProgress(text);
244
+ });
245
+ child.stderr?.on('data', (chunk) => {
246
+ if (truncated)
247
+ return;
248
+ const remaining = MAX_OUTPUT_BYTES - outputBytes;
249
+ if (remaining <= 0) {
250
+ truncated = true;
251
+ return;
252
+ }
253
+ const text = chunk.toString('utf-8');
254
+ if (chunk.length <= remaining) {
255
+ stderr += text;
256
+ outputBytes += chunk.length;
257
+ }
258
+ else {
259
+ stderr += text.slice(0, remaining);
260
+ outputBytes = MAX_OUTPUT_BYTES;
261
+ truncated = true;
262
+ }
263
+ emitProgress(text);
264
+ });
265
+ child.on('close', (code) => {
266
+ clearTimeout(timer);
267
+ ctx.abortSignal.removeEventListener('abort', onAbort);
268
+ let result = '';
269
+ if (stdout)
270
+ result += stdout;
271
+ if (stderr) {
272
+ if (result)
273
+ result += '\n';
274
+ result += stderr;
275
+ }
276
+ if (truncated) {
277
+ result += '\n\n... (output truncated — command produced >512KB)';
278
+ }
279
+ // Smart compression: strip ANSI, collapse blank lines, command-aware filters
280
+ result = compressOutput(command, result);
281
+ // Cap returned output to prevent context bloat.
282
+ // Keep the LAST part (most relevant for errors/test failures/build output).
283
+ if (result.length > MAX_RETURN_CHARS) {
284
+ const lines = result.split('\n');
285
+ let trimmed = '';
286
+ for (let i = lines.length - 1; i >= 0; i--) {
287
+ const candidate = lines[i] + '\n' + trimmed;
288
+ if (candidate.length > MAX_RETURN_CHARS)
289
+ break;
290
+ trimmed = candidate;
291
+ }
292
+ const omitted = result.length - trimmed.length;
293
+ result = `... (${omitted.toLocaleString()} chars omitted from start)\n${trimmed}`;
294
+ }
295
+ if (killed) {
296
+ resolve({
297
+ output: result + `\n\n(command killed — timeout after ${timeoutMs / 1000}s. Set timeout param up to 600000ms for longer.)`,
298
+ isError: true,
299
+ });
300
+ return;
301
+ }
302
+ if (code !== 0 && code !== null) {
303
+ resolve({
304
+ output: result || `Command exited with code ${code}`,
305
+ isError: true,
306
+ });
307
+ return;
308
+ }
309
+ resolve({ output: result || '(no output)' });
310
+ });
311
+ child.on('error', (err) => {
312
+ clearTimeout(timer);
313
+ ctx.abortSignal.removeEventListener('abort', onAbort);
314
+ resolve({
315
+ output: `Error spawning command: ${err.message}`,
316
+ isError: true,
317
+ });
318
+ });
319
+ });
320
+ }
321
+ export const bashCapability = {
322
+ spec: {
323
+ name: 'Bash',
324
+ description: 'Execute a shell command and return stdout+stderr. Runs in working directory with user env. Output capped at 512KB. Default timeout: 2min, max: 10min (set via timeout param in ms).',
325
+ input_schema: {
326
+ type: 'object',
327
+ properties: {
328
+ command: { type: 'string', description: 'The shell command to execute' },
329
+ timeout: { type: 'number', description: 'Timeout in milliseconds (default: 120000, max: 600000)' },
330
+ },
331
+ required: ['command'],
332
+ },
333
+ },
334
+ execute,
335
+ concurrent: false,
336
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Edit capability — targeted string replacement in files.
3
+ */
4
+ import type { CapabilityHandler } from '../agent/types.js';
5
+ export declare const editCapability: CapabilityHandler;