@blockrun/franklin 3.6.24 → 3.7.1
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/context.js +1 -0
- package/dist/agent/loop.js +16 -3
- package/dist/agent/types.d.ts +2 -0
- package/dist/commands/panel.js +3 -1
- package/dist/commands/start.d.ts +4 -0
- package/dist/commands/start.js +158 -19
- package/dist/index.js +40 -21
- package/dist/panel/html.js +230 -0
- package/dist/panel/server.js +136 -0
- package/dist/session/search.js +1 -1
- package/dist/ui/session-picker.d.ts +30 -0
- package/dist/ui/session-picker.js +129 -0
- package/package.json +3 -1
package/dist/agent/context.js
CHANGED
|
@@ -38,6 +38,7 @@ You are an interactive agent — not a chatbot. Use the tools available to you t
|
|
|
38
38
|
- **Search strategy**: Glob/Grep for directed searches (known file/symbol). Use Agent for open-ended exploration that may require multiple rounds.
|
|
39
39
|
- **Batch bash**: chain sequential shell commands with && in a single call. Only split when you need intermediate output.
|
|
40
40
|
- **AskUser discipline**: Only use AskUser when you need explicit confirmation for a destructive action (deleting files, dropping databases). NEVER use AskUser to ask what the user wants — just answer their message directly. If the request is vague, make a reasonable assumption and proceed.
|
|
41
|
+
- **Greetings**: When the user sends only a greeting or filler ("hi", "hello", "hey", "ok", "thanks", "yo"), reply with ONE short plain-text sentence (e.g. "Hi — what do you want to work on?"). Do NOT call AskUser. Do NOT assume a marketing/trading/coding task. Do NOT invoke any tools.
|
|
41
42
|
- Never write to /etc, /usr, ~/.ssh, ~/.aws. Don't commit secrets.`;
|
|
42
43
|
}
|
|
43
44
|
function getCodeStyleSection() {
|
package/dist/agent/loop.js
CHANGED
|
@@ -20,7 +20,7 @@ import { routeRequest, parseRoutingProfile } from '../router/index.js';
|
|
|
20
20
|
import { recordOutcome } from '../router/local-elo.js';
|
|
21
21
|
import { shouldPlan, getPlanningPrompt, getExecutorModel, isExecutorStuck, toolCallSignature } from './planner.js';
|
|
22
22
|
import { shouldVerify, runVerification } from './verification.js';
|
|
23
|
-
import { createSessionId, appendToSession, updateSessionMeta, pruneOldSessions, } from '../session/storage.js';
|
|
23
|
+
import { createSessionId, appendToSession, updateSessionMeta, pruneOldSessions, loadSessionHistory, loadSessionMeta, } from '../session/storage.js';
|
|
24
24
|
/**
|
|
25
25
|
* Atomically replace all elements in a history array.
|
|
26
26
|
* Safer than `history.length = 0; history.push(...)` because if push throws
|
|
@@ -226,9 +226,22 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
226
226
|
// will keep failing until the user adds funds. Map stores failure timestamp for future TTL.
|
|
227
227
|
const paymentFailedModels = new Map(); // model → timestamp
|
|
228
228
|
// Plan-then-execute: session-level disable flag lives on config (set by /noplan command)
|
|
229
|
-
// Session persistence
|
|
230
|
-
const sessionId = createSessionId();
|
|
229
|
+
// Session persistence — reuse existing session ID when resuming, else create new
|
|
230
|
+
const sessionId = config.resumeSessionId || createSessionId();
|
|
231
231
|
let turnCount = 0;
|
|
232
|
+
// Resume: hydrate history from the saved JSONL transcript.
|
|
233
|
+
// Sanitize to drop any orphaned tool_use / tool_result pairs from a crash.
|
|
234
|
+
if (config.resumeSessionId) {
|
|
235
|
+
const prior = loadSessionHistory(config.resumeSessionId);
|
|
236
|
+
if (prior.length > 0) {
|
|
237
|
+
const sanitized = sanitizeHistory(prior);
|
|
238
|
+
replaceHistory(history, sanitized);
|
|
239
|
+
const meta = loadSessionMeta(config.resumeSessionId);
|
|
240
|
+
if (meta) {
|
|
241
|
+
turnCount = meta.turnCount ?? 0;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
232
245
|
let tokenBudgetWarned = false; // Emit token budget warning at most once per session
|
|
233
246
|
let lastSessionActivity = Date.now();
|
|
234
247
|
let lastRoutedModel = ''; // last model chosen by router (for local elo)
|
package/dist/agent/types.d.ts
CHANGED
|
@@ -146,4 +146,6 @@ export interface AgentConfig {
|
|
|
146
146
|
onModelChange?: (model: string, reason?: 'user' | 'system') => void;
|
|
147
147
|
/** The user's intended model — updated by /model command, used for turn recovery */
|
|
148
148
|
baseModel?: string;
|
|
149
|
+
/** Resume an existing session by ID — loads prior history and keeps appending to the same JSONL */
|
|
150
|
+
resumeSessionId?: string;
|
|
149
151
|
}
|
package/dist/commands/panel.js
CHANGED
|
@@ -22,7 +22,9 @@ export async function panelCommand(options) {
|
|
|
22
22
|
}
|
|
23
23
|
process.exit(1);
|
|
24
24
|
});
|
|
25
|
-
|
|
25
|
+
// Bind to loopback only — the panel exposes wallet secrets on /api/wallet/secret
|
|
26
|
+
// and a write-capable /api/wallet/import. Never expose these on a LAN.
|
|
27
|
+
server.listen(port, '127.0.0.1', () => {
|
|
26
28
|
console.log('');
|
|
27
29
|
console.log(chalk.bold(' Franklin Panel'));
|
|
28
30
|
console.log(chalk.dim(` http://localhost:${port}`) +
|
package/dist/commands/start.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ interface StartOptions {
|
|
|
3
3
|
debug?: boolean;
|
|
4
4
|
trust?: boolean;
|
|
5
5
|
version?: string;
|
|
6
|
+
/** Resume: explicit session ID, or true for "most recent in cwd", or 'picker' to prompt */
|
|
7
|
+
resume?: string | boolean | 'picker';
|
|
8
|
+
/** Continue: resume most recent session matching the current working directory */
|
|
9
|
+
continue?: boolean;
|
|
6
10
|
}
|
|
7
11
|
export declare function startCommand(options: StartOptions): Promise<void>;
|
|
8
12
|
export {};
|
package/dist/commands/start.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { getOrCreateWallet, getOrCreateSolanaWallet } from '@blockrun/llm';
|
|
3
3
|
import { loadChain, API_URLS } from '../config.js';
|
|
4
|
-
import { flushStats } from '../stats/tracker.js';
|
|
4
|
+
import { flushStats, loadStats } from '../stats/tracker.js';
|
|
5
|
+
import { OPUS_PRICING } from '../pricing.js';
|
|
5
6
|
import { loadConfig } from './config.js';
|
|
6
7
|
import { printBanner } from '../banner.js';
|
|
7
8
|
import { assembleInstructions } from '../agent/context.js';
|
|
@@ -14,16 +15,58 @@ import { loadMcpConfig } from '../mcp/config.js';
|
|
|
14
15
|
import { connectMcpServers, disconnectMcpServers } from '../mcp/client.js';
|
|
15
16
|
export async function startCommand(options) {
|
|
16
17
|
const version = options.version ?? '1.0.0';
|
|
18
|
+
// Early-validate explicit resume ID so a typo fails fast — before wallet
|
|
19
|
+
// creation, banner, or MCP connection. Also resolve unambiguous prefixes so
|
|
20
|
+
// users don't need to paste the full 40-char session ID.
|
|
21
|
+
if (typeof options.resume === 'string' && options.resume !== 'picker') {
|
|
22
|
+
const { resolveSessionIdInput } = await import('../ui/session-picker.js');
|
|
23
|
+
const resolved = resolveSessionIdInput(options.resume);
|
|
24
|
+
if (!resolved.ok) {
|
|
25
|
+
if (resolved.error === 'ambiguous') {
|
|
26
|
+
console.error(chalk.red(`Ambiguous session prefix: ${options.resume}`));
|
|
27
|
+
console.error(chalk.dim('Matches:'));
|
|
28
|
+
for (const c of resolved.candidates) {
|
|
29
|
+
console.error(chalk.dim(` ${c.id} (${new Date(c.updatedAt).toLocaleString()})`));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
console.error(chalk.red(`No session found with id: ${options.resume}`));
|
|
34
|
+
console.error(chalk.dim('Run `franklin resume` to pick from a list.'));
|
|
35
|
+
}
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
options.resume = resolved.id;
|
|
39
|
+
}
|
|
40
|
+
// Resolve --continue early so the session's model can be inherited during
|
|
41
|
+
// model resolution below. If no matching session is found, we fall through
|
|
42
|
+
// to a fresh session (message is printed later, near the resume banner).
|
|
43
|
+
let continueResolvedId;
|
|
44
|
+
if (options.continue && !options.resume) {
|
|
45
|
+
const { findLatestSessionForDir } = await import('../ui/session-picker.js');
|
|
46
|
+
continueResolvedId = findLatestSessionForDir(process.cwd())?.id;
|
|
47
|
+
}
|
|
17
48
|
const chain = loadChain();
|
|
18
49
|
const apiUrl = API_URLS[chain];
|
|
19
50
|
const config = loadConfig();
|
|
20
|
-
// Resolve model
|
|
21
|
-
//
|
|
51
|
+
// Resolve model. Priority: explicit --model > resumed session's model > user
|
|
52
|
+
// config default > FREE default. Resuming restores the same model the user was
|
|
53
|
+
// on last time so the environment feels continuous. Explicit --model still wins
|
|
54
|
+
// so users can cheaply retry a paid session on a free model.
|
|
22
55
|
let model;
|
|
23
56
|
const configModel = config['default-model'];
|
|
57
|
+
let resumedSessionModel;
|
|
58
|
+
const modelSourceId = (typeof options.resume === 'string' && options.resume !== 'picker') ? options.resume
|
|
59
|
+
: continueResolvedId;
|
|
60
|
+
if (modelSourceId) {
|
|
61
|
+
const { loadSessionMeta } = await import('../session/storage.js');
|
|
62
|
+
resumedSessionModel = loadSessionMeta(modelSourceId)?.model;
|
|
63
|
+
}
|
|
24
64
|
if (options.model) {
|
|
25
65
|
model = resolveModel(options.model);
|
|
26
66
|
}
|
|
67
|
+
else if (resumedSessionModel && resumedSessionModel !== 'unknown') {
|
|
68
|
+
model = resumedSessionModel;
|
|
69
|
+
}
|
|
27
70
|
else if (configModel) {
|
|
28
71
|
model = configModel;
|
|
29
72
|
}
|
|
@@ -72,11 +115,17 @@ export async function startCommand(options) {
|
|
|
72
115
|
}
|
|
73
116
|
printBanner(version);
|
|
74
117
|
const workDir = process.cwd();
|
|
118
|
+
// Auto-start panel in background unless explicitly disabled.
|
|
119
|
+
// Binds loopback-only (wallet secrets on /api/wallet/secret — never expose on LAN).
|
|
120
|
+
let panelUrl;
|
|
121
|
+
if (process.env.FRANKLIN_PANEL_AUTOSTART !== '0') {
|
|
122
|
+
panelUrl = await startPanelBackground(3100);
|
|
123
|
+
}
|
|
75
124
|
// Session info — aligned, minimal. Model + balance live in the input bar below.
|
|
76
125
|
// Full wallet address is shown so the user can copy-paste it to fund the wallet.
|
|
77
126
|
console.log(chalk.dim(' Wallet: ') + (walletAddress || chalk.yellow('not set')));
|
|
78
127
|
console.log(chalk.dim(' Dir: ') + workDir);
|
|
79
|
-
console.log(chalk.dim(' Dashboard: ') + chalk.cyan('franklin panel') + chalk.dim(' → http://localhost:3100'));
|
|
128
|
+
console.log(chalk.dim(' Dashboard: ') + (panelUrl ? chalk.cyan(panelUrl) : chalk.cyan('franklin panel') + chalk.dim(' → http://localhost:3100')));
|
|
80
129
|
console.log(chalk.dim(' Help: ') + chalk.cyan('/help'));
|
|
81
130
|
console.log('');
|
|
82
131
|
// Balance fetcher — used at startup and after each turn
|
|
@@ -145,6 +194,41 @@ export async function startCommand(options) {
|
|
|
145
194
|
console.error(`[validate] ${issue.severity}: ${issue.toolName} — ${issue.issue}`);
|
|
146
195
|
}
|
|
147
196
|
}
|
|
197
|
+
// Resolve resume target, if requested.
|
|
198
|
+
let resumeSessionId;
|
|
199
|
+
if (options.resume || options.continue) {
|
|
200
|
+
const { pickSession } = await import('../ui/session-picker.js');
|
|
201
|
+
const { loadSessionMeta, loadSessionHistory } = await import('../session/storage.js');
|
|
202
|
+
if (typeof options.resume === 'string' && options.resume !== 'picker') {
|
|
203
|
+
// Explicit ID — already validated above
|
|
204
|
+
resumeSessionId = options.resume;
|
|
205
|
+
}
|
|
206
|
+
else if (options.continue) {
|
|
207
|
+
if (!continueResolvedId) {
|
|
208
|
+
console.error(chalk.yellow(` No prior session found in ${workDir} — starting a new one.`));
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
resumeSessionId = continueResolvedId;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
// --resume with no value → interactive picker
|
|
216
|
+
const picked = await pickSession({ workDir });
|
|
217
|
+
if (!picked) {
|
|
218
|
+
console.error(chalk.dim(' No session picked — starting a new one.'));
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
resumeSessionId = picked;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (resumeSessionId) {
|
|
225
|
+
const meta = loadSessionMeta(resumeSessionId);
|
|
226
|
+
const msgs = loadSessionHistory(resumeSessionId).length;
|
|
227
|
+
const when = meta ? new Date(meta.updatedAt).toLocaleString() : 'unknown';
|
|
228
|
+
console.log(chalk.green(` Resuming session ${resumeSessionId.slice(0, 24)}…`));
|
|
229
|
+
console.log(chalk.dim(` ${msgs} messages · last active ${when}\n`));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
148
232
|
// Agent config
|
|
149
233
|
const agentConfig = {
|
|
150
234
|
model,
|
|
@@ -158,6 +242,7 @@ export async function startCommand(options) {
|
|
|
158
242
|
// Interactive TTY = default mode (prompts for Bash/Write/Edit).
|
|
159
243
|
permissionMode: (options.trust || !process.stdin.isTTY) ? 'trust' : 'default',
|
|
160
244
|
debug: options.debug,
|
|
245
|
+
resumeSessionId,
|
|
161
246
|
};
|
|
162
247
|
// Bootstrap learnings from Claude Code config on first run (async, non-blocking)
|
|
163
248
|
Promise.all([
|
|
@@ -179,6 +264,7 @@ export async function startCommand(options) {
|
|
|
179
264
|
}
|
|
180
265
|
// ─── Ink UI (interactive terminal) ─────────────────────────────────────────
|
|
181
266
|
async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, onBalanceReady, fetchBalance) {
|
|
267
|
+
const startSnapshot = snapshotStats();
|
|
182
268
|
const ui = launchInkUI({
|
|
183
269
|
model,
|
|
184
270
|
workDir,
|
|
@@ -246,15 +332,17 @@ async function runWithInkUI(agentConfig, model, workDir, version, walletInfo, on
|
|
|
246
332
|
catch { /* extraction is best-effort */ }
|
|
247
333
|
}
|
|
248
334
|
await disconnectMcpServers();
|
|
249
|
-
// Session summary —
|
|
335
|
+
// Session summary — delta vs. snapshot at session start
|
|
250
336
|
try {
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
337
|
+
const delta = statsDelta(startSnapshot);
|
|
338
|
+
if (delta.requests > 0) {
|
|
339
|
+
const cost = delta.cost.toFixed(4);
|
|
340
|
+
const savedStr = delta.saved > 0.001 ? ` · saved $${delta.saved.toFixed(2)} vs Opus` : '';
|
|
341
|
+
const tokens = `${(delta.inputTokens / 1000).toFixed(0)}k in / ${(delta.outputTokens / 1000).toFixed(0)}k out`;
|
|
342
|
+
console.log(chalk.dim(`\n Session: ${delta.requests} requests · $${cost} USDC${savedStr} · ${tokens}`));
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
console.log(chalk.dim('\n Session: 0 requests · no spend'));
|
|
258
346
|
}
|
|
259
347
|
}
|
|
260
348
|
catch { /* stats unavailable */ }
|
|
@@ -265,6 +353,7 @@ async function runWithBasicUI(agentConfig, model, workDir) {
|
|
|
265
353
|
const { TerminalUI } = await import('../ui/terminal.js');
|
|
266
354
|
const ui = new TerminalUI();
|
|
267
355
|
ui.printWelcome(model, workDir);
|
|
356
|
+
const startSnapshot = snapshotStats();
|
|
268
357
|
let lastTerminalPrompt = '';
|
|
269
358
|
try {
|
|
270
359
|
await interactiveSession(agentConfig, async () => {
|
|
@@ -312,19 +401,69 @@ async function runWithBasicUI(agentConfig, model, workDir) {
|
|
|
312
401
|
}
|
|
313
402
|
// Session summary for piped mode
|
|
314
403
|
try {
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
const
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
console.error(`Session: ${stats.totalRequests} requests · $${cost} USDC${savedStr} · ${tokens}`);
|
|
404
|
+
const delta = statsDelta(startSnapshot);
|
|
405
|
+
if (delta.requests > 0) {
|
|
406
|
+
const cost = delta.cost.toFixed(4);
|
|
407
|
+
const savedStr = delta.saved > 0.001 ? ` · saved $${delta.saved.toFixed(2)} vs Opus` : '';
|
|
408
|
+
const tokens = `${(delta.inputTokens / 1000).toFixed(0)}k in / ${(delta.outputTokens / 1000).toFixed(0)}k out`;
|
|
409
|
+
console.error(`Session: ${delta.requests} requests · $${cost} USDC${savedStr} · ${tokens}`);
|
|
322
410
|
}
|
|
323
411
|
}
|
|
324
412
|
catch { /* stats unavailable */ }
|
|
325
413
|
ui.printGoodbye();
|
|
326
414
|
flushStats();
|
|
327
415
|
}
|
|
416
|
+
// ─── Panel auto-start ──────────────────────────────────────────────────────
|
|
417
|
+
async function startPanelBackground(startPort) {
|
|
418
|
+
const MAX_ATTEMPTS = 20;
|
|
419
|
+
try {
|
|
420
|
+
const { createPanelServer } = await import('../panel/server.js');
|
|
421
|
+
return await new Promise((resolve) => {
|
|
422
|
+
const tryListen = (port, attempt) => {
|
|
423
|
+
const server = createPanelServer(port);
|
|
424
|
+
server.on('error', (err) => {
|
|
425
|
+
if (err.code === 'EADDRINUSE' && attempt < MAX_ATTEMPTS) {
|
|
426
|
+
tryListen(port + 1, attempt + 1);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
resolve(undefined);
|
|
430
|
+
});
|
|
431
|
+
server.listen(port, '127.0.0.1', () => {
|
|
432
|
+
server.unref?.();
|
|
433
|
+
resolve(`http://localhost:${port}`);
|
|
434
|
+
});
|
|
435
|
+
};
|
|
436
|
+
tryListen(startPort, 0);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function snapshotStats() {
|
|
444
|
+
try {
|
|
445
|
+
const s = loadStats();
|
|
446
|
+
return {
|
|
447
|
+
requests: s.totalRequests,
|
|
448
|
+
cost: s.totalCostUsd,
|
|
449
|
+
inputTokens: s.totalInputTokens,
|
|
450
|
+
outputTokens: s.totalOutputTokens,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
return { requests: 0, cost: 0, inputTokens: 0, outputTokens: 0 };
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
function statsDelta(before) {
|
|
458
|
+
const now = loadStats();
|
|
459
|
+
const requests = Math.max(0, now.totalRequests - before.requests);
|
|
460
|
+
const cost = Math.max(0, now.totalCostUsd - before.cost);
|
|
461
|
+
const inputTokens = Math.max(0, now.totalInputTokens - before.inputTokens);
|
|
462
|
+
const outputTokens = Math.max(0, now.totalOutputTokens - before.outputTokens);
|
|
463
|
+
const opusCost = (inputTokens / 1_000_000) * OPUS_PRICING.input +
|
|
464
|
+
(outputTokens / 1_000_000) * OPUS_PRICING.output;
|
|
465
|
+
return { requests, cost, inputTokens, outputTokens, saved: Math.max(0, opusCost - cost) };
|
|
466
|
+
}
|
|
328
467
|
async function handleSlashCommand(cmd, config, ui) {
|
|
329
468
|
const parts = cmd.trim().split(/\s+/);
|
|
330
469
|
const command = parts[0].toLowerCase();
|
package/dist/index.js
CHANGED
|
@@ -41,7 +41,16 @@ program
|
|
|
41
41
|
.option('-m, --model <model>', 'Model to use (e.g. openai/gpt-5.4, anthropic/claude-sonnet-4.6). Default from config or claude-sonnet-4.6')
|
|
42
42
|
.option('--debug', 'Enable debug logging')
|
|
43
43
|
.option('--trust', 'Trust mode — skip permission prompts for all tools')
|
|
44
|
+
.option('-r, --resume [sessionId]', 'Resume a session by ID (or show picker if omitted)')
|
|
45
|
+
.option('-c, --continue', 'Continue the most recent session in this directory')
|
|
44
46
|
.action((options) => startCommand({ ...options, version }));
|
|
47
|
+
program
|
|
48
|
+
.command('resume [sessionId]')
|
|
49
|
+
.description('Resume a saved Franklin session (alias for: franklin --resume)')
|
|
50
|
+
.option('-m, --model <model>', 'Override the model for this session')
|
|
51
|
+
.option('--debug', 'Enable debug logging')
|
|
52
|
+
.option('--trust', 'Trust mode — skip permission prompts for all tools')
|
|
53
|
+
.action((sessionId, options) => startCommand({ ...options, version, resume: sessionId ?? 'picker' }));
|
|
45
54
|
program
|
|
46
55
|
.command('proxy')
|
|
47
56
|
.description('Run payment proxy for Claude Code or other tools')
|
|
@@ -176,13 +185,41 @@ const args = process.argv.slice(2);
|
|
|
176
185
|
const firstArg = args[0];
|
|
177
186
|
const HELP_FLAGS = new Set(['-h', '--help']);
|
|
178
187
|
const VERSION_FLAGS = new Set(['-V', '--version']);
|
|
179
|
-
const START_ONLY_FLAGS = new Set(['--trust', '--debug', '-m', '--model']);
|
|
188
|
+
const START_ONLY_FLAGS = new Set(['--trust', '--debug', '-m', '--model', '-r', '--resume', '-c', '--continue']);
|
|
180
189
|
function hasAnyFlag(argv, flags) {
|
|
181
190
|
return argv.some(arg => flags.has(arg));
|
|
182
191
|
}
|
|
183
192
|
function hasStartOnlyFlag(argv) {
|
|
184
193
|
return argv.some(arg => START_ONLY_FLAGS.has(arg));
|
|
185
194
|
}
|
|
195
|
+
function parseStartFlags(argv, startIdx = 0) {
|
|
196
|
+
const opts = { version };
|
|
197
|
+
for (let i = startIdx; i < argv.length; i++) {
|
|
198
|
+
const arg = argv[i];
|
|
199
|
+
if (arg === '--trust')
|
|
200
|
+
opts.trust = true;
|
|
201
|
+
else if (arg === '--debug')
|
|
202
|
+
opts.debug = true;
|
|
203
|
+
else if ((arg === '-m' || arg === '--model') && argv[i + 1]) {
|
|
204
|
+
opts.model = argv[++i];
|
|
205
|
+
}
|
|
206
|
+
else if (arg === '-c' || arg === '--continue') {
|
|
207
|
+
opts.continue = true;
|
|
208
|
+
}
|
|
209
|
+
else if (arg === '-r' || arg === '--resume') {
|
|
210
|
+
// --resume may take an optional session id — look at next arg
|
|
211
|
+
const next = argv[i + 1];
|
|
212
|
+
if (next && !next.startsWith('-')) {
|
|
213
|
+
opts.resume = next;
|
|
214
|
+
i++;
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
opts.resume = 'picker';
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return opts;
|
|
222
|
+
}
|
|
186
223
|
// Handle chain shortcuts: `runcode solana` or `runcode base`
|
|
187
224
|
if (firstArg === 'solana' || firstArg === 'base') {
|
|
188
225
|
if (hasAnyFlag(args, HELP_FLAGS)) {
|
|
@@ -194,16 +231,7 @@ if (firstArg === 'solana' || firstArg === 'base') {
|
|
|
194
231
|
}
|
|
195
232
|
const { saveChain } = await import('./config.js');
|
|
196
233
|
saveChain(firstArg);
|
|
197
|
-
const startOpts =
|
|
198
|
-
for (let i = 1; i < args.length; i++) {
|
|
199
|
-
if (args[i] === '--trust')
|
|
200
|
-
startOpts.trust = true;
|
|
201
|
-
else if (args[i] === '--debug')
|
|
202
|
-
startOpts.debug = true;
|
|
203
|
-
else if ((args[i] === '-m' || args[i] === '--model') && args[i + 1]) {
|
|
204
|
-
startOpts.model = args[++i];
|
|
205
|
-
}
|
|
206
|
-
}
|
|
234
|
+
const startOpts = parseStartFlags(args, 1);
|
|
207
235
|
await startCommand(startOpts);
|
|
208
236
|
process.exit(0);
|
|
209
237
|
}
|
|
@@ -219,16 +247,7 @@ else if (!firstArg || firstArg.startsWith('-')) {
|
|
|
219
247
|
program.parse();
|
|
220
248
|
}
|
|
221
249
|
// No subcommand or only flags — treat as 'start' with flags
|
|
222
|
-
const startOpts =
|
|
223
|
-
for (let i = 0; i < args.length; i++) {
|
|
224
|
-
if (args[i] === '--trust')
|
|
225
|
-
startOpts.trust = true;
|
|
226
|
-
else if (args[i] === '--debug')
|
|
227
|
-
startOpts.debug = true;
|
|
228
|
-
else if ((args[i] === '-m' || args[i] === '--model') && args[i + 1]) {
|
|
229
|
-
startOpts.model = args[++i];
|
|
230
|
-
}
|
|
231
|
-
}
|
|
250
|
+
const startOpts = parseStartFlags(args, 0);
|
|
232
251
|
await startCommand(startOpts);
|
|
233
252
|
process.exit(0);
|
|
234
253
|
}
|
package/dist/panel/html.js
CHANGED
|
@@ -273,6 +273,63 @@ a:hover { text-decoration:underline; }
|
|
|
273
273
|
.tab.active { display:block; }
|
|
274
274
|
.empty { color:var(--text-dim); text-align:center; padding:56px 24px; font-size:13px; }
|
|
275
275
|
|
|
276
|
+
/* ── Wallet page ── */
|
|
277
|
+
.wallet-grid { display:grid; grid-template-columns:1.1fr 1fr; gap:14px; }
|
|
278
|
+
.wallet-grid .card { display:flex; flex-direction:column; gap:10px; }
|
|
279
|
+
.wallet-receive { grid-row:span 2; align-items:flex-start; }
|
|
280
|
+
.wallet-address-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; width:100%; }
|
|
281
|
+
.wallet-chain-pill {
|
|
282
|
+
font-size:10px; font-weight:700; letter-spacing:0.8px; text-transform:uppercase;
|
|
283
|
+
padding:3px 8px; border-radius:6px; background:oklch(0.68 0.16 260 / 18%); color:var(--brand);
|
|
284
|
+
}
|
|
285
|
+
.wallet-address {
|
|
286
|
+
font-family:var(--mono); font-size:12px; color:var(--text);
|
|
287
|
+
background:oklch(0 0 0 / 35%); padding:8px 10px; border-radius:8px;
|
|
288
|
+
border:1px solid var(--border); word-break:break-all; flex:1; min-width:0;
|
|
289
|
+
}
|
|
290
|
+
.wallet-balance-big { font-family:var(--mono); font-size:28px; font-weight:700; color:var(--gold); letter-spacing:-0.02em; }
|
|
291
|
+
.wallet-qr {
|
|
292
|
+
background:#fff; padding:14px; border-radius:12px; display:inline-block;
|
|
293
|
+
box-shadow:0 10px 40px oklch(0 0 0 / 35%); min-width:220px; min-height:220px;
|
|
294
|
+
}
|
|
295
|
+
.wallet-qr svg { display:block; width:200px; height:200px; }
|
|
296
|
+
.wallet-hint { font-size:12.5px; color:var(--text-muted); line-height:1.55; }
|
|
297
|
+
.wallet-hint code { font-family:var(--mono); font-size:11.5px; color:var(--text); background:oklch(0 0 0 / 30%); padding:1px 5px; border-radius:4px; }
|
|
298
|
+
.wallet-secret { position:relative; }
|
|
299
|
+
.wallet-secret .wallet-key-value {
|
|
300
|
+
font-family:var(--mono); font-size:11.5px; color:var(--text);
|
|
301
|
+
background:oklch(0 0 0 / 35%); padding:10px; border-radius:8px;
|
|
302
|
+
border:1px solid var(--border-strong); word-break:break-all; display:block;
|
|
303
|
+
user-select:all;
|
|
304
|
+
}
|
|
305
|
+
.wallet-secret-actions { display:flex; gap:8px; margin-top:8px; }
|
|
306
|
+
.wallet-import-input {
|
|
307
|
+
width:100%; min-height:70px; background:oklch(0 0 0 / 35%); color:var(--text);
|
|
308
|
+
border:1px solid var(--border); border-radius:8px; padding:10px;
|
|
309
|
+
font-family:var(--mono); font-size:12px; resize:vertical;
|
|
310
|
+
}
|
|
311
|
+
.wallet-import-input:focus { border-color:var(--brand); outline:none; box-shadow:0 0 0 3px oklch(0.68 0.16 260 / 14%); }
|
|
312
|
+
.wallet-actions { display:flex; align-items:center; gap:10px; margin-top:4px; }
|
|
313
|
+
.wallet-import-status { font-size:12px; color:var(--text-muted); }
|
|
314
|
+
.wallet-import-status.ok { color:var(--success); }
|
|
315
|
+
.wallet-import-status.err { color:var(--danger); }
|
|
316
|
+
.wallet-steps { margin:6px 0 0 18px; color:var(--text-muted); font-size:12.5px; line-height:1.7; }
|
|
317
|
+
.wallet-steps em { color:var(--text); font-style:normal; font-weight:600; }
|
|
318
|
+
|
|
319
|
+
.btn {
|
|
320
|
+
font-family:var(--sans); font-size:12px; font-weight:600;
|
|
321
|
+
padding:7px 12px; border-radius:7px; border:1px solid var(--border);
|
|
322
|
+
background:oklch(1 0 0 / 4%); color:var(--text); cursor:pointer;
|
|
323
|
+
transition:background 0.15s, border-color 0.15s, transform 0.05s;
|
|
324
|
+
}
|
|
325
|
+
.btn:hover { background:oklch(1 0 0 / 10%); }
|
|
326
|
+
.btn:active { transform:translateY(1px); }
|
|
327
|
+
.btn-ghost { background:transparent; }
|
|
328
|
+
.btn-warn { background:oklch(0.78 0.14 85 / 18%); color:var(--gold); border-color:oklch(0.78 0.14 85 / 35%); }
|
|
329
|
+
.btn-warn:hover { background:oklch(0.78 0.14 85 / 30%); }
|
|
330
|
+
.btn-danger { background:oklch(0.65 0.20 25 / 18%); color:var(--danger); border-color:oklch(0.65 0.20 25 / 35%); }
|
|
331
|
+
.btn-danger:hover { background:oklch(0.65 0.20 25 / 30%); }
|
|
332
|
+
|
|
276
333
|
@media (max-width:768px) {
|
|
277
334
|
body { flex-direction:column; }
|
|
278
335
|
.sidebar { width:100%; min-width:100%; flex-direction:row; padding:8px; overflow-x:auto; border-right:none; border-bottom:1px solid var(--border); }
|
|
@@ -280,6 +337,8 @@ a:hover { text-decoration:underline; }
|
|
|
280
337
|
.sidebar-nav { flex-direction:row; gap:4px; padding:0; }
|
|
281
338
|
.content { padding:16px; }
|
|
282
339
|
.grid-4 { grid-template-columns:repeat(2,1fr); }
|
|
340
|
+
.wallet-grid { grid-template-columns:1fr; }
|
|
341
|
+
.wallet-receive { grid-row:auto; }
|
|
283
342
|
.savings-hero { flex-direction:column; gap:12px; text-align:center; }
|
|
284
343
|
.savings-pct { display:none; }
|
|
285
344
|
.watermark { width:100%; }
|
|
@@ -308,6 +367,10 @@ a:hover { text-decoration:underline; }
|
|
|
308
367
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>
|
|
309
368
|
Overview
|
|
310
369
|
</button>
|
|
370
|
+
<button class="nav-item" data-tab="wallet">
|
|
371
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4z"/></svg>
|
|
372
|
+
Wallet
|
|
373
|
+
</button>
|
|
311
374
|
<button class="nav-item" data-tab="sessions">
|
|
312
375
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
|
313
376
|
Sessions
|
|
@@ -401,6 +464,69 @@ a:hover { text-decoration:underline; }
|
|
|
401
464
|
</div>
|
|
402
465
|
</div>
|
|
403
466
|
|
|
467
|
+
<!-- Wallet -->
|
|
468
|
+
<div class="tab" id="tab-wallet">
|
|
469
|
+
<div class="content-header">
|
|
470
|
+
<h2>Wallet</h2>
|
|
471
|
+
<p>Receive USDC, back up your key, or import an existing wallet</p>
|
|
472
|
+
</div>
|
|
473
|
+
|
|
474
|
+
<div class="wallet-grid">
|
|
475
|
+
<div class="card wallet-receive">
|
|
476
|
+
<h3>Receive USDC</h3>
|
|
477
|
+
<div class="wallet-address-row">
|
|
478
|
+
<span class="wallet-chain-pill" id="wallet-chain-pill">—</span>
|
|
479
|
+
<code class="wallet-address" id="wallet-address-full">—</code>
|
|
480
|
+
<button class="btn btn-ghost" id="wallet-copy-btn" title="Copy address">Copy</button>
|
|
481
|
+
</div>
|
|
482
|
+
<div class="wallet-balance-big" id="wallet-balance-big">—</div>
|
|
483
|
+
<div class="wallet-qr" id="wallet-qr"></div>
|
|
484
|
+
<p class="wallet-hint" id="wallet-qr-hint">Scan to send USDC to this wallet.</p>
|
|
485
|
+
</div>
|
|
486
|
+
|
|
487
|
+
<div class="card">
|
|
488
|
+
<h3>Back up your key</h3>
|
|
489
|
+
<p class="wallet-hint">
|
|
490
|
+
Your private key is the only way to access this wallet.
|
|
491
|
+
Save it somewhere safe — a password manager, encrypted note, or hardware token.
|
|
492
|
+
<strong>Never</strong> share it; anyone with the key can drain the wallet.
|
|
493
|
+
</p>
|
|
494
|
+
<div class="wallet-secret" id="wallet-secret">
|
|
495
|
+
<button class="btn btn-warn" id="wallet-reveal-btn">Reveal private key</button>
|
|
496
|
+
</div>
|
|
497
|
+
<div id="wallet-file-hint" class="wallet-hint" style="margin-top:10px"></div>
|
|
498
|
+
</div>
|
|
499
|
+
|
|
500
|
+
<div class="card">
|
|
501
|
+
<h3>Import an existing wallet</h3>
|
|
502
|
+
<p class="wallet-hint">
|
|
503
|
+
Paste a private key below to replace the current wallet.
|
|
504
|
+
<strong>This overwrites your existing wallet file.</strong>
|
|
505
|
+
Make sure the current key is backed up first, or you will lose access to any funds still on it.
|
|
506
|
+
</p>
|
|
507
|
+
<textarea id="wallet-import-input" class="wallet-import-input" placeholder="0x… (Base) or base58 key (Solana)"></textarea>
|
|
508
|
+
<div class="wallet-actions">
|
|
509
|
+
<button class="btn btn-danger" id="wallet-import-btn">Import & replace</button>
|
|
510
|
+
<span class="wallet-import-status" id="wallet-import-status"></span>
|
|
511
|
+
</div>
|
|
512
|
+
</div>
|
|
513
|
+
|
|
514
|
+
<div class="card">
|
|
515
|
+
<h3>Export to another tool</h3>
|
|
516
|
+
<p class="wallet-hint">
|
|
517
|
+
Franklin stores your key in <code id="wallet-file-path">~/.blockrun/</code>.
|
|
518
|
+
To use the same wallet in MetaMask / Phantom / a hardware wallet:
|
|
519
|
+
</p>
|
|
520
|
+
<ol class="wallet-steps">
|
|
521
|
+
<li>Click <em>Reveal private key</em> above and copy it.</li>
|
|
522
|
+
<li>In your destination wallet, choose <em>Import account</em> / <em>Import private key</em>.</li>
|
|
523
|
+
<li>Paste the key. The wallet will derive the same address.</li>
|
|
524
|
+
<li>Consider deleting the local file once imported if you no longer want Franklin to spend from it.</li>
|
|
525
|
+
</ol>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
|
|
404
530
|
<!-- Sessions -->
|
|
405
531
|
<div class="tab" id="tab-sessions">
|
|
406
532
|
<div class="content-header">
|
|
@@ -589,6 +715,109 @@ async function loadLearnings() {
|
|
|
589
715
|
}).join('');
|
|
590
716
|
}
|
|
591
717
|
|
|
718
|
+
async function loadWallet() {
|
|
719
|
+
const w = await api('wallet');
|
|
720
|
+
if (!w) return;
|
|
721
|
+
const addr = w.address || '';
|
|
722
|
+
document.getElementById('wallet-address-full').textContent = addr || 'not set';
|
|
723
|
+
document.getElementById('wallet-balance-big').textContent = usdBig(w.balance) + ' USDC';
|
|
724
|
+
document.getElementById('wallet-chain-pill').textContent = w.chain || '—';
|
|
725
|
+
|
|
726
|
+
// QR via server — never leak address to third parties
|
|
727
|
+
const qrBox = document.getElementById('wallet-qr');
|
|
728
|
+
const hint = document.getElementById('wallet-qr-hint');
|
|
729
|
+
if (addr && addr !== 'not set') {
|
|
730
|
+
const svg = await fetch('/api/wallet/qr?data=' + encodeURIComponent(addr)).then(r => r.ok ? r.text() : null);
|
|
731
|
+
qrBox.innerHTML = svg || '';
|
|
732
|
+
hint.textContent = w.chain === 'solana'
|
|
733
|
+
? 'Scan to send USDC (Solana) to this address.'
|
|
734
|
+
: 'Scan to send USDC on Base to this address.';
|
|
735
|
+
} else {
|
|
736
|
+
qrBox.innerHTML = '';
|
|
737
|
+
hint.textContent = 'No wallet set yet — run: franklin setup';
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Copy button
|
|
742
|
+
document.getElementById('wallet-copy-btn').addEventListener('click', async () => {
|
|
743
|
+
const addr = document.getElementById('wallet-address-full').textContent;
|
|
744
|
+
try {
|
|
745
|
+
await navigator.clipboard.writeText(addr);
|
|
746
|
+
const btn = document.getElementById('wallet-copy-btn');
|
|
747
|
+
const orig = btn.textContent;
|
|
748
|
+
btn.textContent = 'Copied ✓';
|
|
749
|
+
setTimeout(() => { btn.textContent = orig; }, 1400);
|
|
750
|
+
} catch { /* clipboard may be blocked — user can select manually */ }
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// Reveal private key
|
|
754
|
+
document.getElementById('wallet-reveal-btn').addEventListener('click', async () => {
|
|
755
|
+
if (!confirm('Show the private key on screen?\\n\\nAnyone who sees or records the key can drain this wallet. Make sure nobody is looking over your shoulder or recording your screen.')) return;
|
|
756
|
+
const box = document.getElementById('wallet-secret');
|
|
757
|
+
box.innerHTML = '<div class="wallet-hint">Loading…</div>';
|
|
758
|
+
try {
|
|
759
|
+
const r = await fetch('/api/wallet/secret');
|
|
760
|
+
if (!r.ok) {
|
|
761
|
+
const err = await r.json().catch(() => ({ error: 'unknown' }));
|
|
762
|
+
box.innerHTML = '<div class="wallet-hint err">Error: ' + esc(err.error || r.statusText) + '</div>';
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const d = await r.json();
|
|
766
|
+
box.innerHTML =
|
|
767
|
+
'<code class="wallet-key-value" id="wallet-key-value">' + esc(d.privateKey) + '</code>' +
|
|
768
|
+
'<div class="wallet-secret-actions">' +
|
|
769
|
+
'<button class="btn" id="wallet-key-copy">Copy key</button>' +
|
|
770
|
+
'<button class="btn btn-ghost" id="wallet-key-hide">Hide</button>' +
|
|
771
|
+
'</div>';
|
|
772
|
+
document.getElementById('wallet-file-hint').textContent = 'Stored at: ' + d.walletFile;
|
|
773
|
+
document.getElementById('wallet-file-path').textContent = d.walletFile;
|
|
774
|
+
document.getElementById('wallet-key-copy').addEventListener('click', async () => {
|
|
775
|
+
await navigator.clipboard.writeText(d.privateKey);
|
|
776
|
+
const btn = document.getElementById('wallet-key-copy');
|
|
777
|
+
btn.textContent = 'Copied ✓';
|
|
778
|
+
setTimeout(() => { btn.textContent = 'Copy key'; }, 1400);
|
|
779
|
+
});
|
|
780
|
+
document.getElementById('wallet-key-hide').addEventListener('click', () => {
|
|
781
|
+
box.innerHTML = '<button class="btn btn-warn" id="wallet-reveal-btn-2">Reveal private key</button>';
|
|
782
|
+
document.getElementById('wallet-reveal-btn-2').addEventListener('click',
|
|
783
|
+
() => document.getElementById('wallet-reveal-btn').click());
|
|
784
|
+
});
|
|
785
|
+
} catch (err) {
|
|
786
|
+
box.innerHTML = '<div class="wallet-hint err">Error: ' + esc(err.message) + '</div>';
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// Import
|
|
791
|
+
document.getElementById('wallet-import-btn').addEventListener('click', async () => {
|
|
792
|
+
const pk = document.getElementById('wallet-import-input').value.trim();
|
|
793
|
+
const status = document.getElementById('wallet-import-status');
|
|
794
|
+
status.className = 'wallet-import-status';
|
|
795
|
+
if (!pk) { status.textContent = 'Paste a private key first.'; return; }
|
|
796
|
+
if (!confirm('Replace the current wallet with this key?\\n\\nThis OVERWRITES your existing wallet file. Any funds on the current wallet will be inaccessible unless you already backed up its key.')) return;
|
|
797
|
+
status.textContent = 'Importing…';
|
|
798
|
+
try {
|
|
799
|
+
const r = await fetch('/api/wallet/import', {
|
|
800
|
+
method: 'POST',
|
|
801
|
+
headers: { 'Content-Type': 'application/json' },
|
|
802
|
+
body: JSON.stringify({ privateKey: pk }),
|
|
803
|
+
});
|
|
804
|
+
const d = await r.json();
|
|
805
|
+
if (!r.ok) {
|
|
806
|
+
status.textContent = 'Error: ' + (d.error || r.statusText);
|
|
807
|
+
status.className = 'wallet-import-status err';
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
status.textContent = 'Imported ✓ New address: ' + d.address;
|
|
811
|
+
status.className = 'wallet-import-status ok';
|
|
812
|
+
document.getElementById('wallet-import-input').value = '';
|
|
813
|
+
loadWallet();
|
|
814
|
+
loadOverview();
|
|
815
|
+
} catch (err) {
|
|
816
|
+
status.textContent = 'Error: ' + err.message;
|
|
817
|
+
status.className = 'wallet-import-status err';
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
|
|
592
821
|
const es = new EventSource('/api/events');
|
|
593
822
|
const dot = document.getElementById('dot');
|
|
594
823
|
const statusEl = document.getElementById('status');
|
|
@@ -602,6 +831,7 @@ loadOverview();
|
|
|
602
831
|
loadSessions();
|
|
603
832
|
loadSocial();
|
|
604
833
|
loadLearnings();
|
|
834
|
+
loadWallet();
|
|
605
835
|
setInterval(() => api('wallet').then(w => {
|
|
606
836
|
if (w) {
|
|
607
837
|
document.getElementById('balance').textContent = usdBig(w.balance) + ' USDC';
|
package/dist/panel/server.js
CHANGED
|
@@ -22,6 +22,32 @@ function json(res, data, status = 200) {
|
|
|
22
22
|
});
|
|
23
23
|
res.end(JSON.stringify(data));
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Require the request to come from loopback. Wallet secret + import endpoints
|
|
27
|
+
* must never be reachable from another host — defense-in-depth on top of the
|
|
28
|
+
* 127.0.0.1 listen binding in panel.ts.
|
|
29
|
+
*/
|
|
30
|
+
function isLoopback(req) {
|
|
31
|
+
const addr = req.socket.remoteAddress || '';
|
|
32
|
+
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1';
|
|
33
|
+
}
|
|
34
|
+
async function readBody(req, maxBytes = 16 * 1024) {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
let size = 0;
|
|
37
|
+
const chunks = [];
|
|
38
|
+
req.on('data', (chunk) => {
|
|
39
|
+
size += chunk.length;
|
|
40
|
+
if (size > maxBytes) {
|
|
41
|
+
reject(new Error('Request body too large'));
|
|
42
|
+
req.destroy();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
chunks.push(chunk);
|
|
46
|
+
});
|
|
47
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
48
|
+
req.on('error', reject);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
25
51
|
function broadcast(data) {
|
|
26
52
|
const msg = `data: ${JSON.stringify(data)}\n\n`;
|
|
27
53
|
for (const client of sseClients) {
|
|
@@ -144,6 +170,116 @@ export function createPanelServer(port) {
|
|
|
144
170
|
}
|
|
145
171
|
return;
|
|
146
172
|
}
|
|
173
|
+
// ─── Wallet QR (SVG) ────────────────────────────────────────────────
|
|
174
|
+
// Returns an SVG QR code for a given payload (?data=...). Generated
|
|
175
|
+
// server-side so the browser never ships the wallet address to a
|
|
176
|
+
// third-party QR service. Size-bounded.
|
|
177
|
+
if (p === '/api/wallet/qr') {
|
|
178
|
+
const data = url.searchParams.get('data') || '';
|
|
179
|
+
if (!data || data.length > 256) {
|
|
180
|
+
json(res, { error: 'missing or oversized data param' }, 400);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const QRCode = (await import('qrcode')).default;
|
|
185
|
+
const svg = await QRCode.toString(data, {
|
|
186
|
+
type: 'svg',
|
|
187
|
+
errorCorrectionLevel: 'M',
|
|
188
|
+
margin: 1,
|
|
189
|
+
color: { dark: '#000000', light: '#ffffff' },
|
|
190
|
+
});
|
|
191
|
+
res.writeHead(200, {
|
|
192
|
+
'Content-Type': 'image/svg+xml; charset=utf-8',
|
|
193
|
+
'Cache-Control': 'no-store',
|
|
194
|
+
});
|
|
195
|
+
res.end(svg);
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
json(res, { error: err.message }, 500);
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// ─── Wallet secret (loopback only) ──────────────────────────────────
|
|
203
|
+
// Returns the private key so the user can back it up / move it.
|
|
204
|
+
// Hardened: loopback-only (belt-and-suspenders on the 127.0.0.1 bind),
|
|
205
|
+
// no-store cache header, JSON only.
|
|
206
|
+
if (p === '/api/wallet/secret') {
|
|
207
|
+
if (!isLoopback(req)) {
|
|
208
|
+
json(res, { error: 'forbidden' }, 403);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const chain = loadChain();
|
|
213
|
+
const { loadWallet, loadSolanaWallet, WALLET_FILE_PATH, SOLANA_WALLET_FILE_PATH } = await import('@blockrun/llm');
|
|
214
|
+
const privateKey = chain === 'solana' ? loadSolanaWallet() : loadWallet();
|
|
215
|
+
if (!privateKey) {
|
|
216
|
+
json(res, { error: 'wallet not set' }, 404);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
res.writeHead(200, {
|
|
220
|
+
'Content-Type': 'application/json',
|
|
221
|
+
'Cache-Control': 'no-store',
|
|
222
|
+
});
|
|
223
|
+
res.end(JSON.stringify({
|
|
224
|
+
chain,
|
|
225
|
+
privateKey,
|
|
226
|
+
walletFile: chain === 'solana' ? SOLANA_WALLET_FILE_PATH : WALLET_FILE_PATH,
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
json(res, { error: err.message }, 500);
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// ─── Wallet import (loopback only) ──────────────────────────────────
|
|
235
|
+
// Overwrites the local wallet with a user-supplied private key.
|
|
236
|
+
// Destructive — overwrites the existing wallet file without backup,
|
|
237
|
+
// so the UI warns the user. Loopback-only.
|
|
238
|
+
if (p === '/api/wallet/import' && req.method === 'POST') {
|
|
239
|
+
if (!isLoopback(req)) {
|
|
240
|
+
json(res, { error: 'forbidden' }, 403);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const raw = await readBody(req);
|
|
245
|
+
const body = JSON.parse(raw);
|
|
246
|
+
const pk = (body.privateKey || '').trim();
|
|
247
|
+
if (!pk) {
|
|
248
|
+
json(res, { error: 'privateKey required' }, 400);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const chain = loadChain();
|
|
252
|
+
if (chain === 'solana') {
|
|
253
|
+
const { saveSolanaWallet, setupAgentSolanaWallet } = await import('@blockrun/llm');
|
|
254
|
+
// Basic shape check: base58 chars, reasonable length. Library validates too.
|
|
255
|
+
if (!/^[1-9A-HJ-NP-Za-km-z]{40,120}$/.test(pk)) {
|
|
256
|
+
json(res, { error: 'invalid Solana private key format' }, 400);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
saveSolanaWallet(pk);
|
|
260
|
+
const client = await setupAgentSolanaWallet({ silent: true });
|
|
261
|
+
const address = await client.getWalletAddress();
|
|
262
|
+
json(res, { ok: true, chain, address });
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const { saveWallet, setupAgentWallet } = await import('@blockrun/llm');
|
|
266
|
+
// Base: 0x + 64 hex chars
|
|
267
|
+
const normalized = pk.startsWith('0x') ? pk : `0x${pk}`;
|
|
268
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(normalized)) {
|
|
269
|
+
json(res, { error: 'invalid Base private key — expected 0x + 64 hex chars' }, 400);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
saveWallet(normalized);
|
|
273
|
+
const client = setupAgentWallet({ silent: true });
|
|
274
|
+
const address = client.getWalletAddress();
|
|
275
|
+
json(res, { ok: true, chain, address });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
json(res, { error: err.message }, 500);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
147
283
|
if (p === '/api/social') {
|
|
148
284
|
const stats = getSocialStats();
|
|
149
285
|
json(res, stats);
|
package/dist/session/search.js
CHANGED
|
@@ -224,6 +224,6 @@ export function formatSearchResults(matches, query) {
|
|
|
224
224
|
lines.push(` [${m.matchedRole}] ${m.snippet}`);
|
|
225
225
|
lines.push('');
|
|
226
226
|
}
|
|
227
|
-
lines.push(` Resume: franklin
|
|
227
|
+
lines.push(` Resume: franklin --resume <session-id> (or: franklin resume for a picker)\n`);
|
|
228
228
|
return lines.join('\n');
|
|
229
229
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive session picker for `franklin resume`.
|
|
3
|
+
* Lists recent sessions (newest first) and returns the selected ID.
|
|
4
|
+
*/
|
|
5
|
+
import { type SessionMeta } from '../session/storage.js';
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a user-provided session identifier to a full session ID.
|
|
8
|
+
* Supports exact match and unambiguous prefix match (minimum 8 chars).
|
|
9
|
+
* Returns { ok, id } on success, or { ok, error, candidates } on failure.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveSessionIdInput(input: string): {
|
|
12
|
+
ok: true;
|
|
13
|
+
id: string;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
error: 'not-found' | 'ambiguous';
|
|
17
|
+
candidates: SessionMeta[];
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Find the most recent session for a given working directory.
|
|
21
|
+
* Returns null if none exists.
|
|
22
|
+
*/
|
|
23
|
+
export declare function findLatestSessionForDir(workDir: string): SessionMeta | null;
|
|
24
|
+
/**
|
|
25
|
+
* Show an interactive session picker. Returns the selected session ID,
|
|
26
|
+
* or null if the user cancels / no sessions exist.
|
|
27
|
+
*/
|
|
28
|
+
export declare function pickSession(opts?: {
|
|
29
|
+
workDir?: string;
|
|
30
|
+
}): Promise<string | null>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive session picker for `franklin resume`.
|
|
3
|
+
* Lists recent sessions (newest first) and returns the selected ID.
|
|
4
|
+
*/
|
|
5
|
+
import readline from 'node:readline';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
import { listSessions } from '../session/storage.js';
|
|
10
|
+
// Canonicalize a path so symlinks compare equal (e.g., /tmp vs /private/tmp on macOS).
|
|
11
|
+
// Falls back to resolve() if the path no longer exists on disk.
|
|
12
|
+
function canonical(p) {
|
|
13
|
+
try {
|
|
14
|
+
return fs.realpathSync(path.resolve(p));
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return path.resolve(p);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function formatRelative(ts) {
|
|
21
|
+
const diff = Date.now() - ts;
|
|
22
|
+
const sec = Math.floor(diff / 1000);
|
|
23
|
+
if (sec < 60)
|
|
24
|
+
return `${sec}s ago`;
|
|
25
|
+
const min = Math.floor(sec / 60);
|
|
26
|
+
if (min < 60)
|
|
27
|
+
return `${min}m ago`;
|
|
28
|
+
const hr = Math.floor(min / 60);
|
|
29
|
+
if (hr < 24)
|
|
30
|
+
return `${hr}h ago`;
|
|
31
|
+
const day = Math.floor(hr / 24);
|
|
32
|
+
return `${day}d ago`;
|
|
33
|
+
}
|
|
34
|
+
function shortDir(dir) {
|
|
35
|
+
const home = process.env.HOME || '';
|
|
36
|
+
const clean = home && dir.startsWith(home) ? '~' + dir.slice(home.length) : dir;
|
|
37
|
+
return clean.length > 40 ? '…' + clean.slice(-39) : clean;
|
|
38
|
+
}
|
|
39
|
+
function modelShort(model) {
|
|
40
|
+
const slash = model.lastIndexOf('/');
|
|
41
|
+
return slash >= 0 ? model.slice(slash + 1) : model;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a user-provided session identifier to a full session ID.
|
|
45
|
+
* Supports exact match and unambiguous prefix match (minimum 8 chars).
|
|
46
|
+
* Returns { ok, id } on success, or { ok, error, candidates } on failure.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveSessionIdInput(input) {
|
|
49
|
+
const sessions = listSessions();
|
|
50
|
+
// Exact match first
|
|
51
|
+
const exact = sessions.find((s) => s.id === input);
|
|
52
|
+
if (exact)
|
|
53
|
+
return { ok: true, id: exact.id };
|
|
54
|
+
// Prefix match — require at least 8 chars to avoid accidental collisions
|
|
55
|
+
if (input.length >= 8) {
|
|
56
|
+
const matches = sessions.filter((s) => s.id.startsWith(input));
|
|
57
|
+
if (matches.length === 1)
|
|
58
|
+
return { ok: true, id: matches[0].id };
|
|
59
|
+
if (matches.length > 1)
|
|
60
|
+
return { ok: false, error: 'ambiguous', candidates: matches };
|
|
61
|
+
}
|
|
62
|
+
return { ok: false, error: 'not-found', candidates: [] };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Find the most recent session for a given working directory.
|
|
66
|
+
* Returns null if none exists.
|
|
67
|
+
*/
|
|
68
|
+
export function findLatestSessionForDir(workDir) {
|
|
69
|
+
const target = canonical(workDir);
|
|
70
|
+
for (const s of listSessions()) {
|
|
71
|
+
if (canonical(s.workDir) === target)
|
|
72
|
+
return s;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Show an interactive session picker. Returns the selected session ID,
|
|
78
|
+
* or null if the user cancels / no sessions exist.
|
|
79
|
+
*/
|
|
80
|
+
export async function pickSession(opts = {}) {
|
|
81
|
+
const sessions = listSessions();
|
|
82
|
+
if (sessions.length === 0) {
|
|
83
|
+
console.error(chalk.yellow('\n No saved sessions found.\n'));
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const limit = 20;
|
|
87
|
+
const shown = sessions.slice(0, limit);
|
|
88
|
+
console.error('');
|
|
89
|
+
console.error(chalk.bold(' Resume session:\n'));
|
|
90
|
+
shown.forEach((s, i) => {
|
|
91
|
+
const num = chalk.cyan(String(i + 1).padStart(2));
|
|
92
|
+
const when = formatRelative(s.updatedAt).padEnd(8);
|
|
93
|
+
const turns = `${s.turnCount}t`.padEnd(5);
|
|
94
|
+
const model = modelShort(s.model).padEnd(20);
|
|
95
|
+
const dir = chalk.dim(shortDir(s.workDir));
|
|
96
|
+
const hereMark = opts.workDir && canonical(s.workDir) === canonical(opts.workDir)
|
|
97
|
+
? chalk.green(' ●')
|
|
98
|
+
: '';
|
|
99
|
+
console.error(` ${num}. ${chalk.dim(when)} ${turns} ${model} ${dir}${hereMark}`);
|
|
100
|
+
});
|
|
101
|
+
console.error('');
|
|
102
|
+
console.error(chalk.dim(' Enter a number to resume, or press Enter to cancel.'));
|
|
103
|
+
if (opts.workDir)
|
|
104
|
+
console.error(chalk.dim(' ● = matches current directory'));
|
|
105
|
+
console.error('');
|
|
106
|
+
const rl = readline.createInterface({
|
|
107
|
+
input: process.stdin,
|
|
108
|
+
output: process.stderr,
|
|
109
|
+
terminal: process.stdin.isTTY ?? false,
|
|
110
|
+
});
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
rl.question(chalk.bold(' session> '), (answer) => {
|
|
113
|
+
rl.close();
|
|
114
|
+
const trimmed = answer.trim();
|
|
115
|
+
if (!trimmed) {
|
|
116
|
+
resolve(null);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const num = parseInt(trimmed, 10);
|
|
120
|
+
if (!isNaN(num) && num >= 1 && num <= shown.length) {
|
|
121
|
+
resolve(shown[num - 1].id);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Allow raw ID as well
|
|
125
|
+
const match = sessions.find(s => s.id === trimmed || s.id.startsWith(trimmed));
|
|
126
|
+
resolve(match ? match.id : null);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blockrun/franklin",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.1",
|
|
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": {
|
|
@@ -71,10 +71,12 @@
|
|
|
71
71
|
"ink-spinner": "^5.0.0",
|
|
72
72
|
"ink-text-input": "^6.0.0",
|
|
73
73
|
"playwright-core": "^1.49.1",
|
|
74
|
+
"qrcode": "^1.5.4",
|
|
74
75
|
"react": "^19.2.4"
|
|
75
76
|
},
|
|
76
77
|
"devDependencies": {
|
|
77
78
|
"@types/node": "^22.0.0",
|
|
79
|
+
"@types/qrcode": "^1.5.6",
|
|
78
80
|
"typescript": "^5.7.0"
|
|
79
81
|
}
|
|
80
82
|
}
|