@blockrun/franklin 3.6.24 → 3.7.0
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/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 +80 -2
- 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/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
|
@@ -14,16 +14,58 @@ import { loadMcpConfig } from '../mcp/config.js';
|
|
|
14
14
|
import { connectMcpServers, disconnectMcpServers } from '../mcp/client.js';
|
|
15
15
|
export async function startCommand(options) {
|
|
16
16
|
const version = options.version ?? '1.0.0';
|
|
17
|
+
// Early-validate explicit resume ID so a typo fails fast — before wallet
|
|
18
|
+
// creation, banner, or MCP connection. Also resolve unambiguous prefixes so
|
|
19
|
+
// users don't need to paste the full 40-char session ID.
|
|
20
|
+
if (typeof options.resume === 'string' && options.resume !== 'picker') {
|
|
21
|
+
const { resolveSessionIdInput } = await import('../ui/session-picker.js');
|
|
22
|
+
const resolved = resolveSessionIdInput(options.resume);
|
|
23
|
+
if (!resolved.ok) {
|
|
24
|
+
if (resolved.error === 'ambiguous') {
|
|
25
|
+
console.error(chalk.red(`Ambiguous session prefix: ${options.resume}`));
|
|
26
|
+
console.error(chalk.dim('Matches:'));
|
|
27
|
+
for (const c of resolved.candidates) {
|
|
28
|
+
console.error(chalk.dim(` ${c.id} (${new Date(c.updatedAt).toLocaleString()})`));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
console.error(chalk.red(`No session found with id: ${options.resume}`));
|
|
33
|
+
console.error(chalk.dim('Run `franklin resume` to pick from a list.'));
|
|
34
|
+
}
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
options.resume = resolved.id;
|
|
38
|
+
}
|
|
39
|
+
// Resolve --continue early so the session's model can be inherited during
|
|
40
|
+
// model resolution below. If no matching session is found, we fall through
|
|
41
|
+
// to a fresh session (message is printed later, near the resume banner).
|
|
42
|
+
let continueResolvedId;
|
|
43
|
+
if (options.continue && !options.resume) {
|
|
44
|
+
const { findLatestSessionForDir } = await import('../ui/session-picker.js');
|
|
45
|
+
continueResolvedId = findLatestSessionForDir(process.cwd())?.id;
|
|
46
|
+
}
|
|
17
47
|
const chain = loadChain();
|
|
18
48
|
const apiUrl = API_URLS[chain];
|
|
19
49
|
const config = loadConfig();
|
|
20
|
-
// Resolve model
|
|
21
|
-
//
|
|
50
|
+
// Resolve model. Priority: explicit --model > resumed session's model > user
|
|
51
|
+
// config default > FREE default. Resuming restores the same model the user was
|
|
52
|
+
// on last time so the environment feels continuous. Explicit --model still wins
|
|
53
|
+
// so users can cheaply retry a paid session on a free model.
|
|
22
54
|
let model;
|
|
23
55
|
const configModel = config['default-model'];
|
|
56
|
+
let resumedSessionModel;
|
|
57
|
+
const modelSourceId = (typeof options.resume === 'string' && options.resume !== 'picker') ? options.resume
|
|
58
|
+
: continueResolvedId;
|
|
59
|
+
if (modelSourceId) {
|
|
60
|
+
const { loadSessionMeta } = await import('../session/storage.js');
|
|
61
|
+
resumedSessionModel = loadSessionMeta(modelSourceId)?.model;
|
|
62
|
+
}
|
|
24
63
|
if (options.model) {
|
|
25
64
|
model = resolveModel(options.model);
|
|
26
65
|
}
|
|
66
|
+
else if (resumedSessionModel && resumedSessionModel !== 'unknown') {
|
|
67
|
+
model = resumedSessionModel;
|
|
68
|
+
}
|
|
27
69
|
else if (configModel) {
|
|
28
70
|
model = configModel;
|
|
29
71
|
}
|
|
@@ -145,6 +187,41 @@ export async function startCommand(options) {
|
|
|
145
187
|
console.error(`[validate] ${issue.severity}: ${issue.toolName} — ${issue.issue}`);
|
|
146
188
|
}
|
|
147
189
|
}
|
|
190
|
+
// Resolve resume target, if requested.
|
|
191
|
+
let resumeSessionId;
|
|
192
|
+
if (options.resume || options.continue) {
|
|
193
|
+
const { pickSession } = await import('../ui/session-picker.js');
|
|
194
|
+
const { loadSessionMeta, loadSessionHistory } = await import('../session/storage.js');
|
|
195
|
+
if (typeof options.resume === 'string' && options.resume !== 'picker') {
|
|
196
|
+
// Explicit ID — already validated above
|
|
197
|
+
resumeSessionId = options.resume;
|
|
198
|
+
}
|
|
199
|
+
else if (options.continue) {
|
|
200
|
+
if (!continueResolvedId) {
|
|
201
|
+
console.error(chalk.yellow(` No prior session found in ${workDir} — starting a new one.`));
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
resumeSessionId = continueResolvedId;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
// --resume with no value → interactive picker
|
|
209
|
+
const picked = await pickSession({ workDir });
|
|
210
|
+
if (!picked) {
|
|
211
|
+
console.error(chalk.dim(' No session picked — starting a new one.'));
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
resumeSessionId = picked;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (resumeSessionId) {
|
|
218
|
+
const meta = loadSessionMeta(resumeSessionId);
|
|
219
|
+
const msgs = loadSessionHistory(resumeSessionId).length;
|
|
220
|
+
const when = meta ? new Date(meta.updatedAt).toLocaleString() : 'unknown';
|
|
221
|
+
console.log(chalk.green(` Resuming session ${resumeSessionId.slice(0, 24)}…`));
|
|
222
|
+
console.log(chalk.dim(` ${msgs} messages · last active ${when}\n`));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
148
225
|
// Agent config
|
|
149
226
|
const agentConfig = {
|
|
150
227
|
model,
|
|
@@ -158,6 +235,7 @@ export async function startCommand(options) {
|
|
|
158
235
|
// Interactive TTY = default mode (prompts for Bash/Write/Edit).
|
|
159
236
|
permissionMode: (options.trust || !process.stdin.isTTY) ? 'trust' : 'default',
|
|
160
237
|
debug: options.debug,
|
|
238
|
+
resumeSessionId,
|
|
161
239
|
};
|
|
162
240
|
// Bootstrap learnings from Claude Code config on first run (async, non-blocking)
|
|
163
241
|
Promise.all([
|
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.0",
|
|
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
|
}
|