@blockrun/franklin 3.6.1 → 3.6.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.
@@ -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
+ }
@@ -22,9 +22,9 @@ Critical rules:
22
22
  - Preserve EXACT file paths, function names, line numbers, variable names
23
23
  - Preserve EXACT error messages and stack traces (verbatim)
24
24
  - Preserve user preferences and corrections (especially "don't do X" instructions)
25
- - Preserve decisions with their rationale (not just the decision)
25
+ - Preserve decisions WITH their rationale — "changed X to Y because Z was broken" (1-2 sentences per decision)
26
26
  - Include full code snippets and function signatures when they are load-bearing
27
- - DO NOT include reasoning that led to decisions only the decisions themselves
27
+ - DO NOT include verbose reasoning chains summarize the WHY in 1-2 sentences, not paragraphs
28
28
  - DO NOT include pleasantries, meta-commentary, or apologies
29
29
  - Use bullet points inside each section
30
30
  - Be specific: "edited src/foo.ts:42 to add error handling" not "made some changes"
@@ -46,7 +46,7 @@ Then produce the summary inside <summary> tags using these exact section headers
46
46
  [Any errors encountered, their root causes, and how they were resolved — this prevents re-investigating the same issues]
47
47
 
48
48
  ## Decisions
49
- [Key decisions made, each with its rationale]
49
+ [Each decision: what was chosen, why, and what constraint/goal drove it. Format: "Chose X over Y because Z." — losing the WHY causes rework later]
50
50
 
51
51
  ## Files Modified
52
52
  [Each file touched, with a one-line description of what changed and why]
@@ -405,7 +405,15 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
405
405
  // Create streaming executor for concurrent tool execution
406
406
  const streamExec = new StreamingExecutor({
407
407
  handlers: capabilityMap,
408
- scope: { workingDir: workDir, abortSignal: abort.signal, onAskUser: config.onAskUser },
408
+ scope: {
409
+ workingDir: workDir,
410
+ abortSignal: abort.signal,
411
+ onAskUser: config.onAskUser,
412
+ parentContext: {
413
+ goal: lastUserInput?.slice(0, 200),
414
+ recentFiles: [...readFileCache].slice(-10),
415
+ },
416
+ },
409
417
  permissions,
410
418
  guard: toolGuard,
411
419
  onStart: (id, name, preview) => onEvent({ kind: 'capability_start', id, name, preview }),
@@ -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
  };
@@ -67,6 +67,11 @@ export interface ExecutionScope {
67
67
  onProgress?: (text: string) => void;
68
68
  /** Routes AskUser questions through ink UI input to avoid raw-mode stdin conflict */
69
69
  onAskUser?: (question: string, options?: string[]) => Promise<string>;
70
+ /** Context from parent agent — helps sub-agents avoid duplicate work */
71
+ parentContext?: {
72
+ goal?: string;
73
+ recentFiles?: string[];
74
+ };
70
75
  }
71
76
  export interface StreamTextDelta {
72
77
  kind: 'text_delta';
@@ -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). */
@@ -24,7 +24,22 @@ async function execute(input, ctx) {
24
24
  }
25
25
  const toolDefs = subTools.map(c => c.spec);
26
26
  const systemInstructions = assembleInstructions(ctx.workingDir);
27
- const systemPrompt = systemInstructions.join('\n\n');
27
+ // Inject parent context so sub-agent avoids duplicate work
28
+ let parentContextSection = '';
29
+ if (ctx.parentContext) {
30
+ const parts = [];
31
+ if (ctx.parentContext.goal) {
32
+ parts.push(`Parent task: ${ctx.parentContext.goal}`);
33
+ }
34
+ if (ctx.parentContext.recentFiles && ctx.parentContext.recentFiles.length > 0) {
35
+ parts.push(`Files already read by parent: ${ctx.parentContext.recentFiles.join(', ')}`);
36
+ parts.push('Do not re-read these files unless you need to verify a change.');
37
+ }
38
+ if (parts.length > 0) {
39
+ parentContextSection = '\n\n# Parent Agent Context\n' + parts.join('\n');
40
+ }
41
+ }
42
+ const systemPrompt = systemInstructions.join('\n\n') + parentContextSection;
28
43
  const history = [
29
44
  { role: 'user', content: prompt },
30
45
  ];
@@ -91,6 +91,31 @@ function parseDuckDuckGoResults(html, maxResults) {
91
91
  snippet: stripTags(snippet?.[1] || '').trim(),
92
92
  });
93
93
  }
94
+ // Last resort: if both parsers failed, extract ANY external links from the page
95
+ // Partial results are better than "No results found" when the page loaded OK
96
+ if (results.length === 0) {
97
+ const allLinks = /<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi;
98
+ let match;
99
+ while ((match = allLinks.exec(html)) !== null && results.length < maxResults) {
100
+ let url = match[1] || '';
101
+ const text = stripTags(match[2]).trim();
102
+ // Must be a real external URL with meaningful text
103
+ if (!text || text.length < 4)
104
+ continue;
105
+ if (url.startsWith('/') || url.includes('duckduckgo.com'))
106
+ continue;
107
+ // Extract from DDG redirect wrapper
108
+ const uddg = url.match(/uddg=([^&]+)/);
109
+ if (uddg)
110
+ url = decodeURIComponent(uddg[1]);
111
+ if (!url.startsWith('http'))
112
+ continue;
113
+ if (seenUrls.has(url))
114
+ continue;
115
+ seenUrls.add(url);
116
+ results.push({ title: text, url, snippet: '' });
117
+ }
118
+ }
94
119
  return results;
95
120
  }
96
121
  function stripTags(html) {
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,25 @@ 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 — clicks toggle tool results, drag selects text
399
+ useEffect(() => {
400
+ const cleanup = mouse.enable();
401
+ const handleClick = (_event) => {
402
+ // Click: toggle expandable tool
403
+ setExpandableTool(prev => prev ? { ...prev, expanded: !prev.expanded } : null);
404
+ };
405
+ const handleCopied = (info) => {
406
+ // Show status when text is copied via drag-select
407
+ showStatus(`Copied ${info.length} chars to clipboard`, 'success', 2000);
408
+ };
409
+ mouse.on('click', handleClick);
410
+ mouse.on('copied', handleCopied);
411
+ return () => {
412
+ mouse.removeListener('click', handleClick);
413
+ mouse.removeListener('copied', handleCopied);
414
+ cleanup();
415
+ };
416
+ }, []);
362
417
  // Expose event handler, balance updater, and permission bridge
363
418
  useEffect(() => {
364
419
  globalThis.__runcode_ui = {
@@ -390,18 +445,14 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
390
445
  handleEvent: (event) => {
391
446
  switch (event.kind) {
392
447
  case 'text_delta':
393
- setWaiting(false);
394
- setThinking(false);
395
- setStreamText(prev => prev + event.text);
448
+ // Throttled: accumulate in ref, flush every 50ms (~20fps)
449
+ pendingTextRef.current += event.text;
450
+ scheduleFlush();
396
451
  break;
397
452
  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
- });
453
+ // Throttled: accumulate in ref, flush every 50ms
454
+ pendingThinkingRef.current += event.text;
455
+ scheduleFlush();
405
456
  break;
406
457
  case 'capability_start':
407
458
  setWaiting(false);
@@ -492,6 +543,17 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
492
543
  break;
493
544
  }
494
545
  case 'turn_done': {
546
+ // Flush any pending throttled text immediately
547
+ if (flushTimerRef.current) {
548
+ clearTimeout(flushTimerRef.current);
549
+ flushTimerRef.current = null;
550
+ }
551
+ // Merge pending text into the ref so commitResponse sees the full text
552
+ if (pendingTextRef.current) {
553
+ streamTextRef.current += pendingTextRef.current;
554
+ pendingTextRef.current = '';
555
+ }
556
+ pendingThinkingRef.current = '';
495
557
  // Flush expandable tool to Static before committing response
496
558
  setExpandableTool(prev => {
497
559
  if (prev)
@@ -649,7 +711,7 @@ export function launchInkUI(opts) {
649
711
  return new Promise((resolve) => { resolveInput = resolve; });
650
712
  },
651
713
  onAbort: (cb) => { abortCallback = cb; },
652
- cleanup: () => { instance.unmount(); },
714
+ cleanup: () => { mouse.disable(); instance.unmount(); },
653
715
  requestPermission: (toolName, description) => {
654
716
  const ui = globalThis.__runcode_ui;
655
717
  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,43 @@
1
+ /**
2
+ * Mouse event support for Ink terminal UI.
3
+ * - SGR extended mouse tracking (DECSET 1000+1002+1006)
4
+ * - Click detection (left click → 'click' event)
5
+ * - Drag detection with text selection (press → motion → release)
6
+ * - Stdout interception for screen text buffer
7
+ * - Clipboard copy on drag-select
8
+ */
9
+ import { EventEmitter } from 'node:events';
10
+ export interface MouseEvent {
11
+ button: 'left' | 'middle' | 'right' | 'wheel-up' | 'wheel-down';
12
+ action: 'press' | 'release' | 'drag';
13
+ col: number;
14
+ row: number;
15
+ }
16
+ export interface Selection {
17
+ startRow: number;
18
+ startCol: number;
19
+ endRow: number;
20
+ endCol: number;
21
+ text: string;
22
+ }
23
+ declare class MouseManager extends EventEmitter {
24
+ private enabled;
25
+ private stdinListener;
26
+ private screen;
27
+ private dragState;
28
+ private pressPos;
29
+ private dragPos;
30
+ /**
31
+ * Enable mouse tracking + screen buffer. Returns cleanup function.
32
+ */
33
+ enable(): () => void;
34
+ private handleLeftButton;
35
+ /**
36
+ * Disable mouse tracking and clean up.
37
+ */
38
+ disable(): void;
39
+ isEnabled(): boolean;
40
+ }
41
+ /** Singleton mouse manager. */
42
+ export declare const mouse: MouseManager;
43
+ export {};
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Mouse event support for Ink terminal UI.
3
+ * - SGR extended mouse tracking (DECSET 1000+1002+1006)
4
+ * - Click detection (left click → 'click' event)
5
+ * - Drag detection with text selection (press → motion → release)
6
+ * - Stdout interception for screen text buffer
7
+ * - Clipboard copy on drag-select
8
+ */
9
+ import { EventEmitter } from 'node:events';
10
+ import { execSync } from 'node:child_process';
11
+ // ─── Terminal escape sequences ────────────────────────────────────────────
12
+ const ENABLE_MOUSE = '\x1b[?1000h' + // Normal mouse tracking (clicks + wheel)
13
+ '\x1b[?1002h' + // Button-motion tracking (drag events)
14
+ '\x1b[?1006h'; // SGR extended format (readable coordinates)
15
+ const DISABLE_MOUSE = '\x1b[?1006l' +
16
+ '\x1b[?1002l' +
17
+ '\x1b[?1000l';
18
+ // SGR mouse event format: ESC [ < button ; col ; row M (press) or m (release)
19
+ const SGR_MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
20
+ // Strip ANSI escape sequences to get plain text
21
+ const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b[()][012AB]|\x1b\[[\?=]?\d*[hlJKHfABCDEFGSTm]/g;
22
+ function stripAnsi(text) {
23
+ return text.replace(ANSI_RE, '');
24
+ }
25
+ // ─── Screen Buffer ───────────────────────────────────────────────────────
26
+ // Lightweight stdout interceptor that captures rendered text lines.
27
+ // Doesn't parse ANSI cursor movement — just stores line content as written.
28
+ class ScreenBuffer {
29
+ lines = [];
30
+ maxLines = 500; // ring buffer
31
+ originalWrite = null;
32
+ capturing = false;
33
+ start() {
34
+ if (this.capturing)
35
+ return;
36
+ this.capturing = true;
37
+ this.lines = [];
38
+ // Intercept stdout.write to capture rendered text
39
+ this.originalWrite = process.stdout.write.bind(process.stdout);
40
+ const self = this;
41
+ process.stdout.write = function (chunk, ...args) {
42
+ // Capture the text
43
+ const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
44
+ self.addText(text);
45
+ // Pass through to original
46
+ return self.originalWrite(chunk, ...args);
47
+ };
48
+ }
49
+ stop() {
50
+ if (!this.capturing)
51
+ return;
52
+ this.capturing = false;
53
+ if (this.originalWrite) {
54
+ process.stdout.write = this.originalWrite;
55
+ this.originalWrite = null;
56
+ }
57
+ }
58
+ addText(text) {
59
+ // Split into lines and store plain text (ANSI stripped)
60
+ const plain = stripAnsi(text);
61
+ const newLines = plain.split('\n');
62
+ for (const line of newLines) {
63
+ if (line.length > 0) {
64
+ this.lines.push(line);
65
+ }
66
+ }
67
+ // Cap ring buffer
68
+ if (this.lines.length > this.maxLines) {
69
+ this.lines = this.lines.slice(-this.maxLines);
70
+ }
71
+ }
72
+ /**
73
+ * Get text between two screen coordinates.
74
+ * Uses the stored text buffer — approximate but good enough for selection.
75
+ */
76
+ getTextInRange(startRow, startCol, endRow, endCol) {
77
+ // Normalize direction
78
+ let r1 = startRow, c1 = startCol, r2 = endRow, c2 = endCol;
79
+ if (r1 > r2 || (r1 === r2 && c1 > c2)) {
80
+ [r1, c1, r2, c2] = [r2, c2, r1, c1];
81
+ }
82
+ // Map screen rows to buffer lines
83
+ // Screen rows are relative to current viewport. Our buffer stores
84
+ // recent lines. We use terminal rows to estimate offset.
85
+ const termRows = process.stdout.rows || 24;
86
+ const bufLen = this.lines.length;
87
+ // Last N lines correspond to the visible screen
88
+ const startIdx = Math.max(0, bufLen - termRows + r1);
89
+ const endIdx = Math.max(0, bufLen - termRows + r2);
90
+ if (startIdx >= bufLen)
91
+ return '';
92
+ const selected = [];
93
+ for (let i = startIdx; i <= Math.min(endIdx, bufLen - 1); i++) {
94
+ const line = this.lines[i] || '';
95
+ if (i === startIdx && i === endIdx) {
96
+ // Single line selection
97
+ selected.push(line.slice(c1, c2 + 1));
98
+ }
99
+ else if (i === startIdx) {
100
+ selected.push(line.slice(c1));
101
+ }
102
+ else if (i === endIdx) {
103
+ selected.push(line.slice(0, c2 + 1));
104
+ }
105
+ else {
106
+ selected.push(line);
107
+ }
108
+ }
109
+ return selected.join('\n').trim();
110
+ }
111
+ }
112
+ // ─── Clipboard ───────────────────────────────────────────────────────────
113
+ function copyToClipboard(text) {
114
+ if (!text)
115
+ return false;
116
+ try {
117
+ if (process.platform === 'darwin') {
118
+ execSync('pbcopy', { input: text, timeout: 2000 });
119
+ }
120
+ else if (process.platform === 'linux') {
121
+ // Try xclip first, then xsel
122
+ try {
123
+ execSync('xclip -selection clipboard', { input: text, timeout: 2000 });
124
+ }
125
+ catch {
126
+ execSync('xsel --clipboard --input', { input: text, timeout: 2000 });
127
+ }
128
+ }
129
+ else if (process.platform === 'win32') {
130
+ execSync('clip', { input: text, timeout: 2000 });
131
+ }
132
+ else {
133
+ return false;
134
+ }
135
+ return true;
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ }
141
+ class MouseManager extends EventEmitter {
142
+ enabled = false;
143
+ stdinListener = null;
144
+ screen = new ScreenBuffer();
145
+ // Drag state machine
146
+ dragState = 'idle';
147
+ pressPos = { row: 0, col: 0 };
148
+ dragPos = { row: 0, col: 0 };
149
+ /**
150
+ * Enable mouse tracking + screen buffer. Returns cleanup function.
151
+ */
152
+ enable() {
153
+ if (this.enabled)
154
+ return () => { };
155
+ this.enabled = true;
156
+ // Start screen buffer capture
157
+ this.screen.start();
158
+ // Write enable sequences
159
+ process.stdout.write(ENABLE_MOUSE);
160
+ this.stdinListener = (data) => {
161
+ const str = data.toString('utf-8');
162
+ let match;
163
+ SGR_MOUSE_RE.lastIndex = 0;
164
+ while ((match = SGR_MOUSE_RE.exec(str)) !== null) {
165
+ const btnCode = parseInt(match[1], 10);
166
+ const col = parseInt(match[2], 10) - 1; // 1-indexed → 0-indexed
167
+ const row = parseInt(match[3], 10) - 1;
168
+ const isPress = match[4] === 'M';
169
+ // Decode button
170
+ const baseBtn = btnCode & 0x03;
171
+ const isWheel = (btnCode & 0x40) !== 0;
172
+ const isMotion = (btnCode & 0x20) !== 0; // Bit 5 = motion
173
+ let button;
174
+ if (isWheel) {
175
+ button = baseBtn === 0 ? 'wheel-up' : 'wheel-down';
176
+ }
177
+ else {
178
+ button = baseBtn === 0 ? 'left' : baseBtn === 1 ? 'middle' : 'right';
179
+ }
180
+ const action = isMotion ? 'drag' : (isPress ? 'press' : 'release');
181
+ const event = { button, action, col, row };
182
+ this.emit('mouse', event);
183
+ // ── Drag state machine (left button only) ──
184
+ if (button === 'left') {
185
+ this.handleLeftButton(action, row, col);
186
+ }
187
+ }
188
+ };
189
+ process.stdin.on('data', this.stdinListener);
190
+ return () => this.disable();
191
+ }
192
+ handleLeftButton(action, row, col) {
193
+ switch (this.dragState) {
194
+ case 'idle':
195
+ if (action === 'press') {
196
+ this.dragState = 'pressing';
197
+ this.pressPos = { row, col };
198
+ this.dragPos = { row, col };
199
+ }
200
+ break;
201
+ case 'pressing':
202
+ if (action === 'drag') {
203
+ // Movement detected — it's a drag, not a click
204
+ const dist = Math.abs(row - this.pressPos.row) + Math.abs(col - this.pressPos.col);
205
+ if (dist >= 2) { // Threshold to distinguish drag from click
206
+ this.dragState = 'dragging';
207
+ this.dragPos = { row, col };
208
+ this.emit('drag-start', { ...this.pressPos });
209
+ }
210
+ }
211
+ else if (action === 'release') {
212
+ // Press → release without drag = click
213
+ this.dragState = 'idle';
214
+ this.emit('click', { button: 'left', action: 'press', col, row });
215
+ }
216
+ break;
217
+ case 'dragging':
218
+ if (action === 'drag') {
219
+ this.dragPos = { row, col };
220
+ this.emit('drag-move', { row, col });
221
+ }
222
+ else if (action === 'release') {
223
+ // Drag complete — extract text and copy to clipboard
224
+ const text = this.screen.getTextInRange(this.pressPos.row, this.pressPos.col, row, col);
225
+ this.dragState = 'idle';
226
+ if (text.length > 0) {
227
+ const copied = copyToClipboard(text);
228
+ const selection = {
229
+ startRow: this.pressPos.row,
230
+ startCol: this.pressPos.col,
231
+ endRow: row,
232
+ endCol: col,
233
+ text,
234
+ };
235
+ this.emit('selection', selection);
236
+ if (copied) {
237
+ this.emit('copied', { text, length: text.length });
238
+ }
239
+ }
240
+ }
241
+ break;
242
+ }
243
+ }
244
+ /**
245
+ * Disable mouse tracking and clean up.
246
+ */
247
+ disable() {
248
+ if (!this.enabled)
249
+ return;
250
+ this.enabled = false;
251
+ this.dragState = 'idle';
252
+ if (this.stdinListener) {
253
+ process.stdin.removeListener('data', this.stdinListener);
254
+ this.stdinListener = null;
255
+ }
256
+ this.screen.stop();
257
+ try {
258
+ process.stdout.write(DISABLE_MOUSE);
259
+ }
260
+ catch {
261
+ // Ignore write errors during cleanup
262
+ }
263
+ }
264
+ isEnabled() { return this.enabled; }
265
+ }
266
+ /** Singleton mouse manager. */
267
+ 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.3",
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": {