@blockrun/franklin 3.6.1 → 3.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/bash-guard.d.ts +17 -0
- package/dist/agent/bash-guard.js +158 -0
- package/dist/agent/permissions.js +41 -2
- package/dist/agent/tokens.js +1 -1
- package/dist/mcp/client.js +36 -0
- package/dist/pricing.js +1 -1
- package/dist/tools/bash.js +56 -1
- package/dist/ui/app.js +79 -20
- package/dist/ui/model-picker.js +2 -2
- package/dist/ui/mouse.d.ts +29 -0
- package/dist/ui/mouse.js +89 -0
- package/package.json +1 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash Risk Classifier — lightweight Guardian for Franklin.
|
|
3
|
+
*
|
|
4
|
+
* Classifies bash commands into three risk levels:
|
|
5
|
+
* safe — read-only or standard dev commands → auto-approve
|
|
6
|
+
* normal — typical mutations (file writes, installs) → default ask behavior
|
|
7
|
+
* dangerous — destructive/irreversible operations → always ask, with warning
|
|
8
|
+
*
|
|
9
|
+
* Inspired by OpenAI Codex's Guardian system, but deterministic pattern matching
|
|
10
|
+
* instead of an LLM call. Fast, predictable, zero-cost.
|
|
11
|
+
*/
|
|
12
|
+
export type BashRiskLevel = 'safe' | 'normal' | 'dangerous';
|
|
13
|
+
export interface BashRiskResult {
|
|
14
|
+
level: BashRiskLevel;
|
|
15
|
+
reason?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function classifyBashRisk(command: string): BashRiskResult;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash Risk Classifier — lightweight Guardian for Franklin.
|
|
3
|
+
*
|
|
4
|
+
* Classifies bash commands into three risk levels:
|
|
5
|
+
* safe — read-only or standard dev commands → auto-approve
|
|
6
|
+
* normal — typical mutations (file writes, installs) → default ask behavior
|
|
7
|
+
* dangerous — destructive/irreversible operations → always ask, with warning
|
|
8
|
+
*
|
|
9
|
+
* Inspired by OpenAI Codex's Guardian system, but deterministic pattern matching
|
|
10
|
+
* instead of an LLM call. Fast, predictable, zero-cost.
|
|
11
|
+
*/
|
|
12
|
+
// ─── Dangerous Patterns ──────────────────────────────────────────────────
|
|
13
|
+
// Checked first. If ANY pattern matches, the command is dangerous.
|
|
14
|
+
const DANGEROUS_PATTERNS = [
|
|
15
|
+
// Destructive file operations
|
|
16
|
+
[/\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*\s+[/~]/, 'recursive delete on root/home'],
|
|
17
|
+
[/\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*f/, 'forced recursive delete'],
|
|
18
|
+
[/\brm\s+-[a-zA-Z]*f[a-zA-Z]*[rR]/, 'forced recursive delete'],
|
|
19
|
+
[/\bmkfs\b/, 'format filesystem'],
|
|
20
|
+
[/\bdd\s+.*of=/, 'raw disk write'],
|
|
21
|
+
// Git irreversible operations
|
|
22
|
+
[/\bgit\s+push\s+.*--force\b/, 'force push'],
|
|
23
|
+
[/\bgit\s+push\s+-f\b/, 'force push'],
|
|
24
|
+
[/\bgit\s+reset\s+--hard\b/, 'hard reset — discards uncommitted changes'],
|
|
25
|
+
[/\bgit\s+clean\s+-[a-zA-Z]*f/, 'git clean — deletes untracked files'],
|
|
26
|
+
[/\bgit\s+checkout\s+--\s+\./, 'discard all working changes'],
|
|
27
|
+
[/\bgit\s+branch\s+-D\b/, 'force delete branch'],
|
|
28
|
+
// Database destructive
|
|
29
|
+
[/\bDROP\s+(TABLE|DATABASE|SCHEMA)\b/i, 'drop database objects'],
|
|
30
|
+
[/\bTRUNCATE\s+TABLE\b/i, 'truncate table'],
|
|
31
|
+
// System-level danger
|
|
32
|
+
[/\bchmod\s+(-R\s+)?777\b/, 'world-writable permissions'],
|
|
33
|
+
[/\bcurl\s+.*\|\s*(sudo\s+)?(ba)?sh\b/, 'pipe URL to shell'],
|
|
34
|
+
[/\bwget\s+.*\|\s*(sudo\s+)?(ba)?sh\b/, 'pipe URL to shell'],
|
|
35
|
+
[/\bsudo\s+rm\b/, 'sudo delete'],
|
|
36
|
+
// Kill/shutdown
|
|
37
|
+
[/\bkill\s+-9\s+-1\b/, 'kill all processes'],
|
|
38
|
+
[/\bshutdown\b/, 'system shutdown'],
|
|
39
|
+
[/\breboot\b/, 'system reboot'],
|
|
40
|
+
];
|
|
41
|
+
// ─── Safe Commands ────────────────────────────────────────────────────────
|
|
42
|
+
// If ALL segments use these commands, auto-approve.
|
|
43
|
+
const SAFE_COMMANDS = new Set([
|
|
44
|
+
// Filesystem read-only
|
|
45
|
+
'ls', 'cat', 'head', 'tail', 'wc', 'du', 'df', 'file', 'stat', 'tree',
|
|
46
|
+
'find', 'grep', 'rg', 'ag', 'ack', 'which', 'whereis', 'type',
|
|
47
|
+
'echo', 'printf', 'date', 'whoami', 'hostname', 'uname', 'printenv',
|
|
48
|
+
'pwd', 'realpath', 'dirname', 'basename',
|
|
49
|
+
// Text processing (read-only when not redirecting)
|
|
50
|
+
'jq', 'yq', 'sort', 'uniq', 'cut', 'tr', 'diff', 'comm', 'less', 'more',
|
|
51
|
+
'wc', 'tee', 'xargs',
|
|
52
|
+
]);
|
|
53
|
+
const SAFE_GIT_SUBCOMMANDS = new Set([
|
|
54
|
+
'status', 'log', 'diff', 'show', 'branch', 'tag', 'remote',
|
|
55
|
+
'blame', 'shortlog', 'describe', 'rev-parse', 'rev-list',
|
|
56
|
+
'ls-files', 'ls-tree', 'ls-remote', 'config', 'reflog',
|
|
57
|
+
]);
|
|
58
|
+
const SAFE_PKG_SUBCOMMANDS = new Set([
|
|
59
|
+
'test', 'run', 'list', 'ls', 'info', 'view', 'show',
|
|
60
|
+
'outdated', 'audit', 'start', 'dev', 'serve', 'lint', 'check',
|
|
61
|
+
'why', 'explain', 'doctor',
|
|
62
|
+
]);
|
|
63
|
+
const SAFE_CARGO_SUBCOMMANDS = new Set([
|
|
64
|
+
'test', 'check', 'clippy', 'build', 'run', 'bench', 'doc',
|
|
65
|
+
'fmt', 'tree', 'metadata', 'verify-project',
|
|
66
|
+
]);
|
|
67
|
+
// ─── Classifier ──────────────────────────────────────────────────────────
|
|
68
|
+
export function classifyBashRisk(command) {
|
|
69
|
+
// 1. Check dangerous patterns first (highest priority)
|
|
70
|
+
for (const [pattern, reason] of DANGEROUS_PATTERNS) {
|
|
71
|
+
if (pattern.test(command)) {
|
|
72
|
+
return { level: 'dangerous', reason };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// 2. Check if every segment is a known-safe command
|
|
76
|
+
const segments = command.split(/\s*(?:&&|\|\||[;|])\s*/);
|
|
77
|
+
let allSafe = true;
|
|
78
|
+
for (const segment of segments) {
|
|
79
|
+
const trimmed = segment.trim();
|
|
80
|
+
if (!trimmed)
|
|
81
|
+
continue;
|
|
82
|
+
if (!isSegmentSafe(trimmed)) {
|
|
83
|
+
allSafe = false;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (allSafe && segments.some(s => s.trim().length > 0)) {
|
|
88
|
+
return { level: 'safe' };
|
|
89
|
+
}
|
|
90
|
+
return { level: 'normal' };
|
|
91
|
+
}
|
|
92
|
+
function isSegmentSafe(segment) {
|
|
93
|
+
// Parse: strip env vars, extract command and args
|
|
94
|
+
const words = segment.split(/\s+/).filter(w => !w.includes('='));
|
|
95
|
+
let idx = 0;
|
|
96
|
+
let cmd = words[idx] || '';
|
|
97
|
+
// Strip harmless prefixes
|
|
98
|
+
while (['time', 'nice'].includes(cmd) && idx < words.length - 1) {
|
|
99
|
+
cmd = words[++idx] || '';
|
|
100
|
+
}
|
|
101
|
+
// sudo → not safe (even if the underlying command is safe)
|
|
102
|
+
if (cmd === 'sudo')
|
|
103
|
+
return false;
|
|
104
|
+
const baseName = cmd.split('/').pop() || cmd;
|
|
105
|
+
const argIdx = idx + 1;
|
|
106
|
+
const subCmd = words[argIdx] || '';
|
|
107
|
+
// git
|
|
108
|
+
if (baseName === 'git') {
|
|
109
|
+
return SAFE_GIT_SUBCOMMANDS.has(subCmd);
|
|
110
|
+
}
|
|
111
|
+
// npm / yarn / pnpm / bun / npx
|
|
112
|
+
if (['npm', 'npx', 'yarn', 'pnpm', 'bun'].includes(baseName)) {
|
|
113
|
+
// "npm run <script>" — safe (dev servers, linters, etc.)
|
|
114
|
+
if (subCmd === 'run')
|
|
115
|
+
return true;
|
|
116
|
+
return SAFE_PKG_SUBCOMMANDS.has(subCmd);
|
|
117
|
+
}
|
|
118
|
+
// cargo
|
|
119
|
+
if (baseName === 'cargo') {
|
|
120
|
+
return SAFE_CARGO_SUBCOMMANDS.has(subCmd);
|
|
121
|
+
}
|
|
122
|
+
// rtk (RTK wrapper — safe, it's a proxy)
|
|
123
|
+
if (baseName === 'rtk')
|
|
124
|
+
return true;
|
|
125
|
+
// Known safe base command
|
|
126
|
+
if (SAFE_COMMANDS.has(baseName)) {
|
|
127
|
+
// sed -i is not read-only
|
|
128
|
+
if (baseName === 'sed' && segment.includes(' -i'))
|
|
129
|
+
return false;
|
|
130
|
+
// Output redirection means writing — not safe
|
|
131
|
+
if (/>\s*[^&|]/.test(segment))
|
|
132
|
+
return false;
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
// Version/help checks are always safe
|
|
136
|
+
if (/\s+(-v|--version|-V)\s*$/.test(segment))
|
|
137
|
+
return true;
|
|
138
|
+
if (/\s+(-h|--help)\s*$/.test(segment))
|
|
139
|
+
return true;
|
|
140
|
+
// gh (GitHub CLI) read-only commands
|
|
141
|
+
if (baseName === 'gh') {
|
|
142
|
+
const ghAction = words.slice(argIdx, argIdx + 2).join(' ');
|
|
143
|
+
if (/^(pr|issue|repo|release|run)\s+(view|list|status|diff|checks|comments)/.test(ghAction))
|
|
144
|
+
return true;
|
|
145
|
+
if (subCmd === 'api')
|
|
146
|
+
return true; // gh api is read-only (GET)
|
|
147
|
+
if (subCmd === 'auth' && words[argIdx + 1] === 'status')
|
|
148
|
+
return true;
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
// docker/podman read-only
|
|
152
|
+
if (baseName === 'docker' || baseName === 'podman') {
|
|
153
|
+
if (['ps', 'images', 'inspect', 'logs', 'stats', 'top', 'port', 'version', 'info'].includes(subCmd))
|
|
154
|
+
return true;
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
@@ -7,6 +7,31 @@ import path from 'node:path';
|
|
|
7
7
|
import readline from 'node:readline';
|
|
8
8
|
import chalk from 'chalk';
|
|
9
9
|
import { BLOCKRUN_DIR } from '../config.js';
|
|
10
|
+
import { classifyBashRisk } from './bash-guard.js';
|
|
11
|
+
// ─── Common dev command patterns (auto-allow without prompting) ──────────
|
|
12
|
+
// These are "normal" risk commands that are too common to interrupt the user.
|
|
13
|
+
// Only applied when --trust flag is set (user explicitly opted into auto-mode).
|
|
14
|
+
const COMMON_DEV_PATTERNS = [
|
|
15
|
+
/^npm\s+(install|i|ci|run|exec|test|start|build|lint|format|outdated|ls|list|info|view|pack)\b/,
|
|
16
|
+
/^(pnpm|yarn|bun)\s+(install|add|run|test|build|lint|exec)\b/,
|
|
17
|
+
/^pip3?\s+install\b/,
|
|
18
|
+
/^python3?\s+/,
|
|
19
|
+
/^node\s+/,
|
|
20
|
+
/^(pytest|jest|vitest|mocha)\b/,
|
|
21
|
+
/^(tsc|eslint|prettier|biome)\b/,
|
|
22
|
+
/^git\s+(add|commit|push|pull|fetch|status|diff|log|branch|checkout|switch|merge|rebase|stash|tag|remote|show)\b/,
|
|
23
|
+
/^(cat|head|tail|wc|sort|uniq|diff|file|which|whoami|hostname|uname|date|echo)\b/,
|
|
24
|
+
/^(ls|pwd|cd|mkdir|touch)\b/,
|
|
25
|
+
/^(docker|docker-compose)\s+(ps|logs|images|inspect|stats|exec|build|run|pull)\b/,
|
|
26
|
+
/^(curl|wget)\s+/,
|
|
27
|
+
/^make\b/,
|
|
28
|
+
/^cargo\s+(build|test|check|clippy|run|bench|doc|fmt)\b/,
|
|
29
|
+
/^go\s+(build|test|run|vet|fmt|mod)\b/,
|
|
30
|
+
];
|
|
31
|
+
function isCommonDevCommand(cmd) {
|
|
32
|
+
const trimmed = cmd.trim();
|
|
33
|
+
return COMMON_DEV_PATTERNS.some(p => p.test(trimmed));
|
|
34
|
+
}
|
|
10
35
|
// ─── Default Rules ─────────────────────────────────────────────────────────
|
|
11
36
|
const READ_ONLY_TOOLS = new Set(['Read', 'Glob', 'Grep', 'WebSearch', 'Task', 'AskUser', 'ImageGen', 'TradingSignal', 'TradingMarket', 'SearchX']);
|
|
12
37
|
const DESTRUCTIVE_TOOLS = new Set(['Write', 'Edit', 'Bash']);
|
|
@@ -61,8 +86,17 @@ export class PermissionManager {
|
|
|
61
86
|
if (this.matchesRule(toolName, input, this.rules.allow)) {
|
|
62
87
|
return { behavior: 'allow', reason: 'allowed by rule' };
|
|
63
88
|
}
|
|
64
|
-
// Check explicit ask rules
|
|
89
|
+
// Check explicit ask rules — with Bash risk classification
|
|
65
90
|
if (this.matchesRule(toolName, input, this.rules.ask)) {
|
|
91
|
+
// Bash Guardian: classify risk before blindly asking
|
|
92
|
+
if (toolName === 'Bash') {
|
|
93
|
+
const cmd = input.command || '';
|
|
94
|
+
const risk = classifyBashRisk(cmd);
|
|
95
|
+
if (risk.level === 'safe') {
|
|
96
|
+
return { behavior: 'allow', reason: 'safe command' };
|
|
97
|
+
}
|
|
98
|
+
// dangerous and normal both ask, but dangerous gets a warning in describeAction
|
|
99
|
+
}
|
|
66
100
|
return { behavior: 'ask' };
|
|
67
101
|
}
|
|
68
102
|
// Default: read-only tools are auto-allowed, others ask
|
|
@@ -179,7 +213,12 @@ export class PermissionManager {
|
|
|
179
213
|
switch (toolName) {
|
|
180
214
|
case 'Bash': {
|
|
181
215
|
const cmd = input.command || '';
|
|
182
|
-
|
|
216
|
+
const preview = cmd.length > 100 ? cmd.slice(0, 100) + '...' : cmd;
|
|
217
|
+
const risk = classifyBashRisk(cmd);
|
|
218
|
+
if (risk.level === 'dangerous') {
|
|
219
|
+
return `\x1b[31m⚠ DANGEROUS: ${risk.reason}\x1b[0m\n │ Execute: ${preview}`;
|
|
220
|
+
}
|
|
221
|
+
return `Execute: ${preview}`;
|
|
183
222
|
}
|
|
184
223
|
case 'Write': {
|
|
185
224
|
const fp = input.file_path || '';
|
package/dist/agent/tokens.js
CHANGED
|
@@ -180,7 +180,7 @@ const MODEL_CONTEXT_WINDOWS = {
|
|
|
180
180
|
'xai/grok-4-0709': 131_072,
|
|
181
181
|
'xai/grok-4-1-fast-reasoning': 131_072,
|
|
182
182
|
// Others
|
|
183
|
-
'zai/glm-5.1':
|
|
183
|
+
'zai/glm-5.1': 200_000,
|
|
184
184
|
'moonshot/kimi-k2.5': 128_000,
|
|
185
185
|
'minimax/minimax-m2.7': 128_000,
|
|
186
186
|
};
|
package/dist/mcp/client.js
CHANGED
|
@@ -79,6 +79,42 @@ async function connectStdio(name, config) {
|
|
|
79
79
|
concurrent: true, // MCP tools are safe to run concurrently
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
|
+
// Discover resources (optional — not all servers expose resources)
|
|
83
|
+
try {
|
|
84
|
+
const { resources: mcpResources } = await client.listResources();
|
|
85
|
+
for (const resource of mcpResources) {
|
|
86
|
+
const resourceToolName = `mcp__${name}__read_${resource.name.replace(/[^a-zA-Z0-9_]/g, '_')}`;
|
|
87
|
+
const resourceDesc = resource.description
|
|
88
|
+
? `Read resource: ${resource.description}`.slice(0, 2048)
|
|
89
|
+
: `Read MCP resource "${resource.name}" from ${name}`;
|
|
90
|
+
capabilities.push({
|
|
91
|
+
spec: {
|
|
92
|
+
name: resourceToolName,
|
|
93
|
+
description: resourceDesc,
|
|
94
|
+
input_schema: { type: 'object', properties: {}, required: [] },
|
|
95
|
+
},
|
|
96
|
+
execute: async () => {
|
|
97
|
+
try {
|
|
98
|
+
const result = await client.readResource({ uri: resource.uri });
|
|
99
|
+
const output = result.contents
|
|
100
|
+
?.map(c => c.text ?? `[resource: ${c.uri}]`)
|
|
101
|
+
?.join('\n') || JSON.stringify(result.contents);
|
|
102
|
+
return { output, isError: false };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return {
|
|
106
|
+
output: `MCP resource error (${name}/${resource.name}): ${err.message}`,
|
|
107
|
+
isError: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
concurrent: true,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Server doesn't support resources — that's fine, tools-only mode
|
|
117
|
+
}
|
|
82
118
|
const connected = { name, client, transport, tools: capabilities };
|
|
83
119
|
connections.set(name, connected);
|
|
84
120
|
return connected;
|
package/dist/pricing.js
CHANGED
|
@@ -73,7 +73,7 @@ export const MODEL_PRICING = {
|
|
|
73
73
|
'zai/glm-5': { input: 0, output: 0, perCall: 0.001 },
|
|
74
74
|
'zai/glm-5.1': { input: 0, output: 0, perCall: 0.001 },
|
|
75
75
|
'zai/glm-5-turbo': { input: 0, output: 0, perCall: 0.001 },
|
|
76
|
-
'zai/glm-5.1-turbo': { input: 0, output: 0, perCall: 0.001 },
|
|
76
|
+
'zai/glm-5.1-turbo': { input: 0, output: 0, perCall: 0.001 }, // client alias for zai/glm-5-turbo
|
|
77
77
|
};
|
|
78
78
|
/** Opus pricing for savings calculations */
|
|
79
79
|
export const OPUS_PRICING = MODEL_PRICING['anthropic/claude-opus-4.6'];
|
package/dist/tools/bash.js
CHANGED
|
@@ -51,7 +51,26 @@ function compressOutput(command, output) {
|
|
|
51
51
|
else if (sub === 'install')
|
|
52
52
|
out = compressInstall(out);
|
|
53
53
|
}
|
|
54
|
-
// 7.
|
|
54
|
+
// 7. Python — pip install, pytest, python scripts
|
|
55
|
+
else if (/^(pip|pip3)\s+install\b/.test(fullCmd)) {
|
|
56
|
+
out = compressInstall(out);
|
|
57
|
+
}
|
|
58
|
+
else if (/^(pytest|python.*-m\s+pytest)\b/.test(fullCmd)) {
|
|
59
|
+
out = compressTests(out);
|
|
60
|
+
}
|
|
61
|
+
// 8. Docker — strip layer hashes, progress bars, keep errors + summary
|
|
62
|
+
else if (/^docker\s+(build|run|pull|push|compose)\b/.test(fullCmd)) {
|
|
63
|
+
out = compressDocker(out);
|
|
64
|
+
}
|
|
65
|
+
// 9. curl/wget — strip progress bars, keep response
|
|
66
|
+
else if (/^(curl|wget)\b/.test(fullCmd)) {
|
|
67
|
+
out = compressDownload(out);
|
|
68
|
+
}
|
|
69
|
+
// 10. Make — keep errors/warnings, drop recipe lines
|
|
70
|
+
else if (cmd === 'make') {
|
|
71
|
+
out = compressBuild(out);
|
|
72
|
+
}
|
|
73
|
+
// 11. Always collapse excessive blank lines
|
|
55
74
|
out = collapseBlankLines(out);
|
|
56
75
|
return out;
|
|
57
76
|
}
|
|
@@ -161,6 +180,42 @@ function compressBuild(out) {
|
|
|
161
180
|
});
|
|
162
181
|
return collapseBlankLines(kept.join('\n')).trim() || out.trim();
|
|
163
182
|
}
|
|
183
|
+
function compressDocker(out) {
|
|
184
|
+
const lines = out.split('\n');
|
|
185
|
+
const kept = lines.filter(l => {
|
|
186
|
+
const t = l.trim();
|
|
187
|
+
// Drop layer progress: "sha256:abc123: Pulling fs layer" / "Downloading [==> ]"
|
|
188
|
+
if (/^[a-f0-9]{12}:\s*(Pull|Wait|Download|Extract|Verif|Already)/.test(t))
|
|
189
|
+
return false;
|
|
190
|
+
// Drop download/upload progress bars
|
|
191
|
+
if (/^\[[\s=>#]+\]/.test(t) || /\d+(\.\d+)?%/.test(t) && t.length < 80)
|
|
192
|
+
return false;
|
|
193
|
+
// Drop "Sending build context" progress
|
|
194
|
+
if (/^Sending build context/.test(t))
|
|
195
|
+
return false;
|
|
196
|
+
return true;
|
|
197
|
+
});
|
|
198
|
+
return collapseBlankLines(kept.join('\n')).trim() || out.trim();
|
|
199
|
+
}
|
|
200
|
+
function compressDownload(out) {
|
|
201
|
+
const lines = out.split('\n');
|
|
202
|
+
const kept = lines.filter(l => {
|
|
203
|
+
const t = l.trim();
|
|
204
|
+
// Drop curl progress bars: " % Total % Received..."
|
|
205
|
+
if (/^\s*%\s+Total/.test(t))
|
|
206
|
+
return false;
|
|
207
|
+
if (/^\s*\d+\s+\d+[kMG]?\s+\d+\s+\d+[kMG]?/.test(t) && t.length < 100)
|
|
208
|
+
return false;
|
|
209
|
+
// Drop wget progress: "2024-01-01 12:00:00 (1.23 MB/s) - saved"
|
|
210
|
+
if (/^\d{4}-\d{2}-\d{2}.*saved/.test(t))
|
|
211
|
+
return false;
|
|
212
|
+
// Drop download percentage lines
|
|
213
|
+
if (/^\s*\d+%\s/.test(t))
|
|
214
|
+
return false;
|
|
215
|
+
return true;
|
|
216
|
+
});
|
|
217
|
+
return collapseBlankLines(kept.join('\n')).trim() || out.trim();
|
|
218
|
+
}
|
|
164
219
|
const backgroundTasks = new Map();
|
|
165
220
|
let bgTaskCounter = 0;
|
|
166
221
|
/** Get a background task's result (called by the agent to check status). */
|
package/dist/ui/app.js
CHANGED
|
@@ -13,6 +13,7 @@ import { renderMarkdown } from './markdown.js';
|
|
|
13
13
|
import { resolveModel, PICKER_CATEGORIES, PICKER_MODELS_FLAT, } from './model-picker.js';
|
|
14
14
|
import { estimateCost } from '../pricing.js';
|
|
15
15
|
import { formatTokens, shortModelName } from '../stats/format.js';
|
|
16
|
+
import { mouse } from './mouse.js';
|
|
16
17
|
// ─── Full-width input box ──────────────────────────────────────────────────
|
|
17
18
|
function InputBox({ input, setInput, onSubmit, model, balance, sessionCost, queued, queuedCount, focused, busy, contextPct, vimMode, onVimModeChange }) {
|
|
18
19
|
const { stdout } = useStdout();
|
|
@@ -80,6 +81,37 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
80
81
|
// Messages queued while agent is busy — auto-submitted FIFO when turns complete.
|
|
81
82
|
const [queuedInputs, setQueuedInputs] = useState([]);
|
|
82
83
|
const turnDoneCallbackRef = useRef(null);
|
|
84
|
+
// ── Render throttling: batch rapid text_delta/thinking_delta into 50ms frames ──
|
|
85
|
+
// Without this, each delta (20-100/sec) triggers a full React re-render.
|
|
86
|
+
// With this, we accumulate in refs and flush at ~20fps — smooth and efficient.
|
|
87
|
+
const pendingTextRef = useRef('');
|
|
88
|
+
const pendingThinkingRef = useRef('');
|
|
89
|
+
const flushTimerRef = useRef(null);
|
|
90
|
+
const flushPendingText = useCallback(() => {
|
|
91
|
+
flushTimerRef.current = null;
|
|
92
|
+
const text = pendingTextRef.current;
|
|
93
|
+
const thinking = pendingThinkingRef.current;
|
|
94
|
+
if (text) {
|
|
95
|
+
pendingTextRef.current = '';
|
|
96
|
+
setWaiting(false);
|
|
97
|
+
setThinking(false);
|
|
98
|
+
setStreamText(prev => prev + text);
|
|
99
|
+
}
|
|
100
|
+
if (thinking) {
|
|
101
|
+
pendingThinkingRef.current = '';
|
|
102
|
+
setWaiting(false);
|
|
103
|
+
setThinking(true);
|
|
104
|
+
setThinkingText(prev => {
|
|
105
|
+
const updated = prev + thinking;
|
|
106
|
+
return updated.length > 500 ? updated.slice(-500) : updated;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}, []);
|
|
110
|
+
const scheduleFlush = useCallback(() => {
|
|
111
|
+
if (!flushTimerRef.current) {
|
|
112
|
+
flushTimerRef.current = setTimeout(flushPendingText, 50);
|
|
113
|
+
}
|
|
114
|
+
}, [flushPendingText]);
|
|
83
115
|
// Refs to read current state values inside memoized event handlers (avoids stale closures)
|
|
84
116
|
const streamTextRef = useRef('');
|
|
85
117
|
const turnTokensRef = useRef({ input: 0, output: 0, calls: 0 });
|
|
@@ -110,15 +142,19 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
110
142
|
const commitResponse = useCallback((text, tokens = turnTokensRef.current, cost = turnCostRef.current) => {
|
|
111
143
|
if (!text.trim())
|
|
112
144
|
return;
|
|
113
|
-
setCommittedResponses((rs) =>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
145
|
+
setCommittedResponses((rs) => {
|
|
146
|
+
const next = [...rs, {
|
|
147
|
+
key: String(Date.now() + Math.random()),
|
|
148
|
+
text,
|
|
149
|
+
tokens,
|
|
150
|
+
cost,
|
|
151
|
+
model: turnModelRef.current,
|
|
152
|
+
tier: turnTierRef.current,
|
|
153
|
+
savings: turnSavingsRef.current,
|
|
154
|
+
}];
|
|
155
|
+
// Cap at 300 items — older items are already in terminal scrollback
|
|
156
|
+
return next.length > 300 ? next.slice(-300) : next;
|
|
157
|
+
});
|
|
122
158
|
const allLines = text.split('\n');
|
|
123
159
|
if (allLines.length > 20) {
|
|
124
160
|
setResponsePreview(' ↑ scroll to see full reply\n' + allLines.slice(-20).join('\n'));
|
|
@@ -359,6 +395,22 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
359
395
|
turnSavingsRef.current = undefined;
|
|
360
396
|
onSubmit(trimmed);
|
|
361
397
|
}, [ready, currentModel, totalCost, onSubmit, onModelChange, onAbort, onExit, exit, lastPrompt, inputHistory, showStatus]);
|
|
398
|
+
// Mouse support — enable tracking and handle clicks on tool results
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
const cleanup = mouse.enable();
|
|
401
|
+
const handleClick = (_event) => {
|
|
402
|
+
// Click anywhere toggles the expandable tool (if one exists)
|
|
403
|
+
// This is intentionally simple — we don't track exact coordinates of components.
|
|
404
|
+
// The expandable tool is always the most recent tool result, so any click is a
|
|
405
|
+
// reasonable toggle target. Tab key remains the precise alternative.
|
|
406
|
+
setExpandableTool(prev => prev ? { ...prev, expanded: !prev.expanded } : null);
|
|
407
|
+
};
|
|
408
|
+
mouse.on('click', handleClick);
|
|
409
|
+
return () => {
|
|
410
|
+
mouse.removeListener('click', handleClick);
|
|
411
|
+
cleanup();
|
|
412
|
+
};
|
|
413
|
+
}, []);
|
|
362
414
|
// Expose event handler, balance updater, and permission bridge
|
|
363
415
|
useEffect(() => {
|
|
364
416
|
globalThis.__runcode_ui = {
|
|
@@ -390,18 +442,14 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
390
442
|
handleEvent: (event) => {
|
|
391
443
|
switch (event.kind) {
|
|
392
444
|
case 'text_delta':
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
445
|
+
// Throttled: accumulate in ref, flush every 50ms (~20fps)
|
|
446
|
+
pendingTextRef.current += event.text;
|
|
447
|
+
scheduleFlush();
|
|
396
448
|
break;
|
|
397
449
|
case 'thinking_delta':
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
// Keep last 500 chars of thinking for display
|
|
402
|
-
const updated = prev + event.text;
|
|
403
|
-
return updated.length > 500 ? updated.slice(-500) : updated;
|
|
404
|
-
});
|
|
450
|
+
// Throttled: accumulate in ref, flush every 50ms
|
|
451
|
+
pendingThinkingRef.current += event.text;
|
|
452
|
+
scheduleFlush();
|
|
405
453
|
break;
|
|
406
454
|
case 'capability_start':
|
|
407
455
|
setWaiting(false);
|
|
@@ -492,6 +540,17 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
492
540
|
break;
|
|
493
541
|
}
|
|
494
542
|
case 'turn_done': {
|
|
543
|
+
// Flush any pending throttled text immediately
|
|
544
|
+
if (flushTimerRef.current) {
|
|
545
|
+
clearTimeout(flushTimerRef.current);
|
|
546
|
+
flushTimerRef.current = null;
|
|
547
|
+
}
|
|
548
|
+
// Merge pending text into the ref so commitResponse sees the full text
|
|
549
|
+
if (pendingTextRef.current) {
|
|
550
|
+
streamTextRef.current += pendingTextRef.current;
|
|
551
|
+
pendingTextRef.current = '';
|
|
552
|
+
}
|
|
553
|
+
pendingThinkingRef.current = '';
|
|
495
554
|
// Flush expandable tool to Static before committing response
|
|
496
555
|
setExpandableTool(prev => {
|
|
497
556
|
if (prev)
|
|
@@ -649,7 +708,7 @@ export function launchInkUI(opts) {
|
|
|
649
708
|
return new Promise((resolve) => { resolveInput = resolve; });
|
|
650
709
|
},
|
|
651
710
|
onAbort: (cb) => { abortCallback = cb; },
|
|
652
|
-
cleanup: () => { instance.unmount(); },
|
|
711
|
+
cleanup: () => { mouse.disable(); instance.unmount(); },
|
|
653
712
|
requestPermission: (toolName, description) => {
|
|
654
713
|
const ui = globalThis.__runcode_ui;
|
|
655
714
|
return ui?.requestPermission(toolName, description) ?? Promise.resolve('no');
|
package/dist/ui/model-picker.js
CHANGED
|
@@ -54,7 +54,7 @@ export const MODEL_SHORTCUTS = {
|
|
|
54
54
|
// Others
|
|
55
55
|
minimax: 'minimax/minimax-m2.7',
|
|
56
56
|
glm: 'zai/glm-5.1',
|
|
57
|
-
'glm-turbo': 'zai/glm-5
|
|
57
|
+
'glm-turbo': 'zai/glm-5-turbo',
|
|
58
58
|
'glm5': 'zai/glm-5.1',
|
|
59
59
|
kimi: 'moonshot/kimi-k2.5',
|
|
60
60
|
};
|
|
@@ -79,7 +79,7 @@ export const PICKER_CATEGORIES = [
|
|
|
79
79
|
category: '🔥 Promo (flat $0.001/call)',
|
|
80
80
|
models: [
|
|
81
81
|
{ id: 'zai/glm-5.1', shortcut: 'glm', label: 'GLM-5.1', price: '$0.001/call', highlight: true },
|
|
82
|
-
{ id: 'zai/glm-5
|
|
82
|
+
{ id: 'zai/glm-5-turbo', shortcut: 'glm-turbo', label: 'GLM-5 Turbo', price: '$0.001/call', highlight: true },
|
|
83
83
|
],
|
|
84
84
|
},
|
|
85
85
|
{
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mouse event support for Ink terminal UI.
|
|
3
|
+
* Enables SGR extended mouse tracking (DECSET 1000+1006) and parses events from stdin.
|
|
4
|
+
* Lightweight — only handles clicks, not drag/hover/selection.
|
|
5
|
+
*/
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
export interface MouseEvent {
|
|
8
|
+
button: 'left' | 'middle' | 'right' | 'wheel-up' | 'wheel-down';
|
|
9
|
+
action: 'press' | 'release';
|
|
10
|
+
col: number;
|
|
11
|
+
row: number;
|
|
12
|
+
}
|
|
13
|
+
declare class MouseManager extends EventEmitter {
|
|
14
|
+
private enabled;
|
|
15
|
+
private stdinListener;
|
|
16
|
+
/**
|
|
17
|
+
* Enable mouse tracking. Call once on app startup.
|
|
18
|
+
* Returns cleanup function to call on unmount.
|
|
19
|
+
*/
|
|
20
|
+
enable(): () => void;
|
|
21
|
+
/**
|
|
22
|
+
* Disable mouse tracking and clean up.
|
|
23
|
+
*/
|
|
24
|
+
disable(): void;
|
|
25
|
+
isEnabled(): boolean;
|
|
26
|
+
}
|
|
27
|
+
/** Singleton mouse manager. */
|
|
28
|
+
export declare const mouse: MouseManager;
|
|
29
|
+
export {};
|
package/dist/ui/mouse.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mouse event support for Ink terminal UI.
|
|
3
|
+
* Enables SGR extended mouse tracking (DECSET 1000+1006) and parses events from stdin.
|
|
4
|
+
* Lightweight — only handles clicks, not drag/hover/selection.
|
|
5
|
+
*/
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
// ─── Terminal escape sequences ────────────────────────────────────────────
|
|
8
|
+
const ENABLE_MOUSE = '\x1b[?1000h' + // Normal mouse tracking (clicks + wheel)
|
|
9
|
+
'\x1b[?1006h'; // SGR extended format (readable coordinates)
|
|
10
|
+
const DISABLE_MOUSE = '\x1b[?1006l' +
|
|
11
|
+
'\x1b[?1000l';
|
|
12
|
+
// SGR mouse event format: ESC [ < button ; col ; row M (press) or m (release)
|
|
13
|
+
const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
|
|
14
|
+
// ─── Mouse Manager ───────────────────────────────────────────────────────
|
|
15
|
+
class MouseManager extends EventEmitter {
|
|
16
|
+
enabled = false;
|
|
17
|
+
stdinListener = null;
|
|
18
|
+
/**
|
|
19
|
+
* Enable mouse tracking. Call once on app startup.
|
|
20
|
+
* Returns cleanup function to call on unmount.
|
|
21
|
+
*/
|
|
22
|
+
enable() {
|
|
23
|
+
if (this.enabled)
|
|
24
|
+
return () => { };
|
|
25
|
+
this.enabled = true;
|
|
26
|
+
// Write enable sequences
|
|
27
|
+
process.stdout.write(ENABLE_MOUSE);
|
|
28
|
+
// Listen on stdin for mouse sequences
|
|
29
|
+
// We use 'data' event at a higher priority than Ink's handler.
|
|
30
|
+
// Mouse sequences that we parse are still passed to Ink (we can't consume them),
|
|
31
|
+
// but Ink will ignore unrecognized escape sequences.
|
|
32
|
+
this.stdinListener = (data) => {
|
|
33
|
+
const str = data.toString('utf-8');
|
|
34
|
+
let match;
|
|
35
|
+
SGR_MOUSE_RE.lastIndex = 0;
|
|
36
|
+
while ((match = SGR_MOUSE_RE.exec(str)) !== null) {
|
|
37
|
+
const btnCode = parseInt(match[1], 10);
|
|
38
|
+
const col = parseInt(match[2], 10) - 1; // 1-indexed → 0-indexed
|
|
39
|
+
const row = parseInt(match[3], 10) - 1;
|
|
40
|
+
const isPress = match[4] === 'M';
|
|
41
|
+
// Decode button
|
|
42
|
+
const baseBtn = btnCode & 0x03;
|
|
43
|
+
const isWheel = (btnCode & 0x40) !== 0;
|
|
44
|
+
let button;
|
|
45
|
+
if (isWheel) {
|
|
46
|
+
button = baseBtn === 0 ? 'wheel-up' : 'wheel-down';
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
button = baseBtn === 0 ? 'left' : baseBtn === 1 ? 'middle' : 'right';
|
|
50
|
+
}
|
|
51
|
+
const event = {
|
|
52
|
+
button,
|
|
53
|
+
action: isPress ? 'press' : 'release',
|
|
54
|
+
col,
|
|
55
|
+
row,
|
|
56
|
+
};
|
|
57
|
+
this.emit('mouse', event);
|
|
58
|
+
// Emit convenience events
|
|
59
|
+
if (button === 'left' && isPress) {
|
|
60
|
+
this.emit('click', event);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
process.stdin.on('data', this.stdinListener);
|
|
65
|
+
return () => this.disable();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Disable mouse tracking and clean up.
|
|
69
|
+
*/
|
|
70
|
+
disable() {
|
|
71
|
+
if (!this.enabled)
|
|
72
|
+
return;
|
|
73
|
+
this.enabled = false;
|
|
74
|
+
if (this.stdinListener) {
|
|
75
|
+
process.stdin.removeListener('data', this.stdinListener);
|
|
76
|
+
this.stdinListener = null;
|
|
77
|
+
}
|
|
78
|
+
// Best-effort: disable mouse tracking
|
|
79
|
+
try {
|
|
80
|
+
process.stdout.write(DISABLE_MOUSE);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Ignore write errors during cleanup (stdout may be closed)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
isEnabled() { return this.enabled; }
|
|
87
|
+
}
|
|
88
|
+
/** Singleton mouse manager. */
|
|
89
|
+
export const mouse = new MouseManager();
|
package/package.json
CHANGED