@blockrun/franklin 3.6.16 → 3.6.17

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.
@@ -4,26 +4,49 @@
4
4
  import chalk from 'chalk';
5
5
  import { createPanelServer } from '../panel/server.js';
6
6
  export async function panelCommand(options) {
7
- const port = parseInt(options.port || '3100', 10);
8
- const server = createPanelServer(port);
9
- server.listen(port, () => {
10
- console.log('');
11
- console.log(chalk.bold(' Franklin Panel'));
12
- console.log(chalk.dim(` http://localhost:${port}`));
13
- console.log('');
14
- console.log(chalk.dim(' Press Ctrl+C to stop.'));
15
- console.log('');
16
- // Try to open browser
17
- const open = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
18
- import('node:child_process').then(({ exec }) => {
19
- exec(`${open} http://localhost:${port}`);
20
- }).catch(() => { });
21
- });
22
- // Graceful shutdown
23
- const shutdown = () => {
24
- server.close();
25
- process.exit(0);
7
+ const requestedPort = parseInt(options.port || '3100', 10);
8
+ // Handle port-in-use by trying up to 10 subsequent ports.
9
+ const tryListen = (port, attempt) => {
10
+ const server = createPanelServer(port);
11
+ server.on('error', (err) => {
12
+ if (err.code === 'EADDRINUSE' && attempt < 10) {
13
+ console.log(chalk.yellow(` Port ${port} busy — trying ${port + 1}...`));
14
+ tryListen(port + 1, attempt + 1);
15
+ return;
16
+ }
17
+ console.error(chalk.red(`\n Panel failed to start: ${err.message}`));
18
+ if (err.code === 'EADDRINUSE') {
19
+ console.error(chalk.dim(` All ports from ${requestedPort} to ${requestedPort + 9} are busy.`));
20
+ console.error(chalk.dim(` Try: franklin panel --port 4000`));
21
+ }
22
+ process.exit(1);
23
+ });
24
+ server.listen(port, () => {
25
+ console.log('');
26
+ console.log(chalk.bold(' Franklin Panel'));
27
+ console.log(chalk.dim(` http://localhost:${port}`));
28
+ console.log('');
29
+ console.log(chalk.dim(' Press Ctrl+C to stop.'));
30
+ console.log('');
31
+ // Try to open browser
32
+ const open = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
33
+ import('node:child_process').then(({ exec }) => {
34
+ exec(`${open} http://localhost:${port}`);
35
+ }).catch(() => { });
36
+ });
37
+ // Graceful shutdown
38
+ const shutdown = () => {
39
+ server.close();
40
+ process.exit(0);
41
+ };
42
+ process.on('SIGINT', shutdown);
43
+ process.on('SIGTERM', shutdown);
26
44
  };
27
- process.on('SIGINT', shutdown);
28
- process.on('SIGTERM', shutdown);
45
+ // Catch unexpected crashes with a useful message rather than a stack trace
46
+ process.on('uncaughtException', (err) => {
47
+ console.error(chalk.red(`\n Panel crashed: ${err.message}`));
48
+ console.error(chalk.dim(' Open an issue: https://github.com/BlockRunAI/Franklin/issues'));
49
+ process.exit(1);
50
+ });
51
+ tryListen(requestedPort, 0);
29
52
  }
@@ -36,130 +36,151 @@ function broadcast(data) {
36
36
  export function createPanelServer(port) {
37
37
  const html = getHTML();
38
38
  const server = http.createServer(async (req, res) => {
39
- const url = new URL(req.url || '/', `http://localhost:${port}`);
40
- const p = url.pathname;
41
- // ─── HTML ──
42
- if (p === '/') {
43
- res.writeHead(200, {
44
- 'Content-Type': 'text/html; charset=utf-8',
45
- 'Cache-Control': 'no-store, no-cache, must-revalidate',
46
- 'Pragma': 'no-cache',
47
- });
48
- res.end(html);
49
- return;
50
- }
51
- // ─── Static assets ──
52
- if (p.startsWith('/assets/') && p.endsWith('.jpg')) {
53
- const filename = path.basename(p);
54
- const assetsDir = path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), '..', 'assets');
55
- const imgPath = path.join(assetsDir, filename);
56
- try {
57
- const img = fs.readFileSync(imgPath);
58
- res.writeHead(200, {
59
- 'Content-Type': 'image/jpeg',
60
- 'Cache-Control': 'public, max-age=86400',
61
- });
62
- res.end(img);
63
- }
64
- catch {
65
- res.writeHead(404);
66
- res.end('Not found');
67
- }
68
- return;
69
- }
70
- // ─── SSE ──
71
- if (p === '/api/events') {
72
- res.writeHead(200, {
73
- 'Content-Type': 'text/event-stream',
74
- 'Cache-Control': 'no-cache',
75
- 'Connection': 'keep-alive',
76
- 'Access-Control-Allow-Origin': '*',
77
- });
78
- res.write('data: {"type":"connected"}\n\n');
79
- sseClients.add(res);
80
- req.on('close', () => sseClients.delete(res));
81
- return;
82
- }
83
- // ─── API ──
84
39
  try {
85
- if (p === '/api/stats') {
86
- const summary = getStatsSummary();
87
- json(res, {
88
- totalRequests: summary.stats.totalRequests,
89
- totalCostUsd: summary.stats.totalCostUsd,
90
- opusCost: summary.opusCost,
91
- saved: summary.saved,
92
- savedPct: summary.savedPct,
93
- avgCostPerRequest: summary.avgCostPerRequest,
94
- period: summary.period,
95
- byModel: summary.stats.byModel,
40
+ const url = new URL(req.url || '/', `http://localhost:${port}`);
41
+ const p = url.pathname;
42
+ // ─── HTML ──
43
+ if (p === '/') {
44
+ res.writeHead(200, {
45
+ 'Content-Type': 'text/html; charset=utf-8',
46
+ 'Cache-Control': 'no-store, no-cache, must-revalidate',
47
+ 'Pragma': 'no-cache',
96
48
  });
49
+ res.end(html);
97
50
  return;
98
51
  }
99
- if (p === '/api/insights') {
100
- const days = parseInt(url.searchParams.get('days') || '30', 10);
101
- const report = generateInsights(days);
102
- json(res, report);
103
- return;
104
- }
105
- if (p === '/api/sessions') {
106
- const sessions = listSessions();
107
- json(res, sessions);
108
- return;
109
- }
110
- if (p.startsWith('/api/sessions/search')) {
111
- const q = url.searchParams.get('q') || '';
112
- const limit = parseInt(url.searchParams.get('limit') || '20', 10);
113
- const results = searchSessions(q, { limit });
114
- json(res, results);
52
+ // ─── Static assets ──
53
+ if (p.startsWith('/assets/') && p.endsWith('.jpg')) {
54
+ const filename = path.basename(p);
55
+ const assetsDir = path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), '..', 'assets');
56
+ const imgPath = path.join(assetsDir, filename);
57
+ try {
58
+ const img = fs.readFileSync(imgPath);
59
+ res.writeHead(200, {
60
+ 'Content-Type': 'image/jpeg',
61
+ 'Cache-Control': 'public, max-age=86400',
62
+ });
63
+ res.end(img);
64
+ }
65
+ catch {
66
+ res.writeHead(404);
67
+ res.end('Not found');
68
+ }
115
69
  return;
116
70
  }
117
- if (p.startsWith('/api/sessions/')) {
118
- const id = decodeURIComponent(p.slice('/api/sessions/'.length));
119
- const history = loadSessionHistory(id);
120
- json(res, history);
71
+ // ─── SSE ──
72
+ if (p === '/api/events') {
73
+ res.writeHead(200, {
74
+ 'Content-Type': 'text/event-stream',
75
+ 'Cache-Control': 'no-cache',
76
+ 'Connection': 'keep-alive',
77
+ 'Access-Control-Allow-Origin': '*',
78
+ });
79
+ res.write('data: {"type":"connected"}\n\n');
80
+ sseClients.add(res);
81
+ req.on('close', () => sseClients.delete(res));
121
82
  return;
122
83
  }
123
- if (p === '/api/wallet') {
124
- try {
125
- const chain = loadChain();
126
- let address = '', balance = 0;
127
- if (chain === 'solana') {
128
- const { setupAgentSolanaWallet } = await import('@blockrun/llm');
129
- const client = await setupAgentSolanaWallet({ silent: true });
130
- address = await client.getWalletAddress();
131
- balance = await client.getBalance();
84
+ // ─── API ──
85
+ try {
86
+ if (p === '/api/stats') {
87
+ const summary = getStatsSummary();
88
+ json(res, {
89
+ totalRequests: summary.stats.totalRequests,
90
+ totalCostUsd: summary.stats.totalCostUsd,
91
+ opusCost: summary.opusCost,
92
+ saved: summary.saved,
93
+ savedPct: summary.savedPct,
94
+ avgCostPerRequest: summary.avgCostPerRequest,
95
+ period: summary.period,
96
+ byModel: summary.stats.byModel,
97
+ });
98
+ return;
99
+ }
100
+ if (p === '/api/insights') {
101
+ const days = parseInt(url.searchParams.get('days') || '30', 10);
102
+ const report = generateInsights(days);
103
+ json(res, report);
104
+ return;
105
+ }
106
+ if (p === '/api/sessions') {
107
+ const sessions = listSessions();
108
+ json(res, sessions);
109
+ return;
110
+ }
111
+ if (p.startsWith('/api/sessions/search')) {
112
+ const q = url.searchParams.get('q') || '';
113
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
114
+ const results = searchSessions(q, { limit });
115
+ json(res, results);
116
+ return;
117
+ }
118
+ if (p.startsWith('/api/sessions/')) {
119
+ const id = decodeURIComponent(p.slice('/api/sessions/'.length));
120
+ const history = loadSessionHistory(id);
121
+ json(res, history);
122
+ return;
123
+ }
124
+ if (p === '/api/wallet') {
125
+ try {
126
+ const chain = loadChain();
127
+ let address = '', balance = 0;
128
+ if (chain === 'solana') {
129
+ const { setupAgentSolanaWallet } = await import('@blockrun/llm');
130
+ const client = await setupAgentSolanaWallet({ silent: true });
131
+ address = await client.getWalletAddress();
132
+ balance = await client.getBalance();
133
+ }
134
+ else {
135
+ const { setupAgentWallet } = await import('@blockrun/llm');
136
+ const client = setupAgentWallet({ silent: true });
137
+ address = client.getWalletAddress();
138
+ balance = await client.getBalance();
139
+ }
140
+ json(res, { address, balance, chain });
132
141
  }
133
- else {
134
- const { setupAgentWallet } = await import('@blockrun/llm');
135
- const client = setupAgentWallet({ silent: true });
136
- address = client.getWalletAddress();
137
- balance = await client.getBalance();
142
+ catch {
143
+ json(res, { address: 'not set', balance: 0, chain: loadChain() });
138
144
  }
139
- json(res, { address, balance, chain });
145
+ return;
140
146
  }
141
- catch {
142
- json(res, { address: 'not set', balance: 0, chain: loadChain() });
147
+ if (p === '/api/social') {
148
+ const stats = getSocialStats();
149
+ json(res, stats);
150
+ return;
143
151
  }
144
- return;
145
- }
146
- if (p === '/api/social') {
147
- const stats = getSocialStats();
148
- json(res, stats);
149
- return;
152
+ if (p === '/api/learnings') {
153
+ const learnings = loadLearnings();
154
+ json(res, learnings);
155
+ return;
156
+ }
157
+ // 404
158
+ res.writeHead(404);
159
+ res.end('Not found');
150
160
  }
151
- if (p === '/api/learnings') {
152
- const learnings = loadLearnings();
153
- json(res, learnings);
154
- return;
161
+ catch (err) {
162
+ json(res, { error: err.message }, 500);
155
163
  }
156
- // 404
157
- res.writeHead(404);
158
- res.end('Not found');
159
164
  }
160
165
  catch (err) {
161
- json(res, { error: err.message }, 500);
166
+ // Outer safety net logs but never crashes the server
167
+ try {
168
+ if (!res.headersSent)
169
+ json(res, { error: err.message }, 500);
170
+ else
171
+ res.end();
172
+ }
173
+ catch { /* socket already gone */ }
174
+ console.error('[panel] request error:', err.message);
175
+ }
176
+ });
177
+ // Swallow socket errors (client disconnects, etc.) so they don't crash the process
178
+ server.on('clientError', (err, socket) => {
179
+ try {
180
+ socket.destroy();
162
181
  }
182
+ catch { /* already closed */ }
183
+ console.error('[panel] client error:', err.message);
163
184
  });
164
185
  // Watch stats file for changes → push to SSE clients
165
186
  const statsFile = fs.existsSync(path.join(BLOCKRUN_DIR, 'franklin-stats.json'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.6.16",
3
+ "version": "3.6.17",
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": {