@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.
@@ -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
- return `Execute: ${cmd.length > 100 ? cmd.slice(0, 100) + '...' : cmd}`;
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 || '';
@@ -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': 128_000,
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
  };
@@ -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'];
@@ -51,7 +51,26 @@ function compressOutput(command, output) {
51
51
  else if (sub === 'install')
52
52
  out = compressInstall(out);
53
53
  }
54
- // 7. Always collapse excessive blank lines
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) => [...rs, {
114
- key: String(Date.now() + Math.random()),
115
- text,
116
- tokens,
117
- cost,
118
- model: turnModelRef.current,
119
- tier: turnTierRef.current,
120
- savings: turnSavingsRef.current,
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
- setWaiting(false);
394
- setThinking(false);
395
- setStreamText(prev => prev + event.text);
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
- setWaiting(false);
399
- setThinking(true);
400
- setThinkingText(prev => {
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');
@@ -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.1-turbo',
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.1-turbo', shortcut: 'glm-turbo', label: 'GLM-5.1 Turbo', price: '$0.001/call', highlight: true },
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 {};
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.6.1",
3
+ "version": "3.6.2",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {