Skip to content

Commit 61f3468

Browse files
committed
feat: session persistence with /sessions and /resume commands (v1.5.0)
1 parent 427fd44 commit 61f3468

8 files changed

Lines changed: 445 additions & 10 deletions

File tree

dist/agent/loop.js

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { StreamingExecutor } from './streaming-executor.js';
1111
import { optimizeHistory, CAPPED_MAX_TOKENS, ESCALATED_MAX_TOKENS } from './optimize.js';
1212
import { recordUsage } from '../stats/tracker.js';
1313
import { estimateCost } from '../pricing.js';
14+
import { createSessionId, appendToSession, updateSessionMeta, pruneOldSessions, listSessions, loadSessionHistory, } from '../session/storage.js';
1415
// ─── Main Entry Point ──────────────────────────────────────────────────────
1516
/**
1617
* Run the agent loop.
@@ -208,12 +209,52 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
208209
const workDir = config.workingDir ?? process.cwd();
209210
const permissions = new PermissionManager(config.permissionMode ?? 'default');
210211
const history = [];
212+
// Session persistence
213+
const sessionId = createSessionId();
214+
let turnCount = 0;
215+
pruneOldSessions(); // Cleanup old sessions on start
211216
while (true) {
212217
const input = await getUserInput();
213218
if (input === null)
214219
break; // User wants to exit
215220
if (input === '')
216221
continue; // Empty input → re-prompt
222+
// Handle /sessions — list saved sessions
223+
if (input === '/sessions') {
224+
const sessions = listSessions();
225+
if (sessions.length === 0) {
226+
onEvent({ kind: 'text_delta', text: 'No saved sessions.\n' });
227+
}
228+
else {
229+
let text = `**${sessions.length} saved sessions:**\n\n`;
230+
for (const s of sessions.slice(0, 10)) {
231+
const date = new Date(s.updatedAt).toLocaleString();
232+
const dir = s.workDir ? ` — ${s.workDir.split('/').pop()}` : '';
233+
text += ` ${s.id} ${s.model} ${s.turnCount} turns ${date}${dir}\n`;
234+
}
235+
if (sessions.length > 10)
236+
text += ` ... and ${sessions.length - 10} more\n`;
237+
text += '\nUse /resume <session-id> to continue a session.\n';
238+
onEvent({ kind: 'text_delta', text });
239+
}
240+
onEvent({ kind: 'turn_done', reason: 'completed' });
241+
continue;
242+
}
243+
// Handle /resume <id> — restore session history
244+
if (input.startsWith('/resume ')) {
245+
const targetId = input.slice(8).trim();
246+
const restored = loadSessionHistory(targetId);
247+
if (restored.length === 0) {
248+
onEvent({ kind: 'text_delta', text: `Session "${targetId}" not found or empty.\n` });
249+
}
250+
else {
251+
history.length = 0;
252+
history.push(...restored);
253+
onEvent({ kind: 'text_delta', text: `Restored ${restored.length} messages from ${targetId}. Continue where you left off.\n` });
254+
}
255+
onEvent({ kind: 'turn_done', reason: 'completed' });
256+
continue;
257+
}
217258
// Handle /compact command — force compaction without sending to model
218259
if (input === '/compact') {
219260
const beforeTokens = estimateHistoryTokens(history);
@@ -231,15 +272,17 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
231272
continue;
232273
}
233274
history.push({ role: 'user', content: input });
275+
appendToSession(sessionId, { role: 'user', content: input });
276+
turnCount++;
234277
const abort = new AbortController();
235278
onAbortReady?.(() => abort.abort());
236-
let turnCount = 0;
279+
let loopCount = 0;
237280
let recoveryAttempts = 0;
238281
let maxTokensOverride;
239282
const lastActivity = Date.now();
240283
// Agent loop for this user message
241-
while (turnCount < maxTurns) {
242-
turnCount++;
284+
while (loopCount < maxTurns) {
285+
loopCount++;
243286
// ── Token optimization pipeline ──
244287
// 1. Strip thinking, budget tool results, time-based cleanup
245288
const optimized = optimizeHistory(history, {
@@ -392,6 +435,14 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
392435
history.push({ role: 'assistant', content: responseParts });
393436
// No more capabilities → done with this user message
394437
if (invocations.length === 0) {
438+
// Save session on completed turn
439+
appendToSession(sessionId, { role: 'assistant', content: responseParts });
440+
updateSessionMeta(sessionId, {
441+
model: config.model,
442+
workDir: config.workingDir || process.cwd(),
443+
turnCount,
444+
messageCount: history.length,
445+
});
395446
onEvent({ kind: 'turn_done', reason: 'completed' });
396447
break;
397448
}
@@ -409,7 +460,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
409460
}));
410461
history.push({ role: 'user', content: outcomeContent });
411462
}
412-
if (turnCount >= maxTurns) {
463+
if (loopCount >= maxTurns) {
413464
onEvent({ kind: 'turn_done', reason: 'max_turns' });
414465
}
415466
}

dist/session/storage.d.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Session persistence for runcode.
3+
* Saves conversation history as JSONL for resume capability.
4+
*/
5+
import type { Dialogue } from '../agent/types.js';
6+
export interface SessionMeta {
7+
id: string;
8+
model: string;
9+
workDir: string;
10+
createdAt: number;
11+
updatedAt: number;
12+
turnCount: number;
13+
messageCount: number;
14+
}
15+
/**
16+
* Create a new session ID based on timestamp.
17+
*/
18+
export declare function createSessionId(): string;
19+
/**
20+
* Save a message to the session transcript (append-only JSONL).
21+
*/
22+
export declare function appendToSession(sessionId: string, message: Dialogue): void;
23+
/**
24+
* Update session metadata.
25+
*/
26+
export declare function updateSessionMeta(sessionId: string, meta: Partial<SessionMeta>): void;
27+
/**
28+
* Load session metadata.
29+
*/
30+
export declare function loadSessionMeta(sessionId: string): SessionMeta | null;
31+
/**
32+
* Load full session history from JSONL.
33+
*/
34+
export declare function loadSessionHistory(sessionId: string): Dialogue[];
35+
/**
36+
* List all saved sessions, newest first.
37+
*/
38+
export declare function listSessions(): SessionMeta[];
39+
/**
40+
* Prune old sessions beyond MAX_SESSIONS.
41+
*/
42+
export declare function pruneOldSessions(): void;

dist/session/storage.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Session persistence for runcode.
3+
* Saves conversation history as JSONL for resume capability.
4+
*/
5+
import fs from 'node:fs';
6+
import path from 'node:path';
7+
import { BLOCKRUN_DIR } from '../config.js';
8+
const SESSIONS_DIR = path.join(BLOCKRUN_DIR, 'sessions');
9+
const MAX_SESSIONS = 20; // Keep last 20 sessions
10+
function ensureDir() {
11+
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
12+
}
13+
function sessionPath(id) {
14+
return path.join(SESSIONS_DIR, `${id}.jsonl`);
15+
}
16+
function metaPath(id) {
17+
return path.join(SESSIONS_DIR, `${id}.meta.json`);
18+
}
19+
/**
20+
* Create a new session ID based on timestamp.
21+
*/
22+
export function createSessionId() {
23+
const now = new Date();
24+
const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
25+
return `session-${ts}`;
26+
}
27+
/**
28+
* Save a message to the session transcript (append-only JSONL).
29+
*/
30+
export function appendToSession(sessionId, message) {
31+
ensureDir();
32+
const line = JSON.stringify(message) + '\n';
33+
fs.appendFileSync(sessionPath(sessionId), line);
34+
}
35+
/**
36+
* Update session metadata.
37+
*/
38+
export function updateSessionMeta(sessionId, meta) {
39+
ensureDir();
40+
const existing = loadSessionMeta(sessionId);
41+
const updated = {
42+
id: sessionId,
43+
model: meta.model || existing?.model || 'unknown',
44+
workDir: meta.workDir || existing?.workDir || '',
45+
createdAt: existing?.createdAt || Date.now(),
46+
updatedAt: Date.now(),
47+
turnCount: meta.turnCount ?? existing?.turnCount ?? 0,
48+
messageCount: meta.messageCount ?? existing?.messageCount ?? 0,
49+
};
50+
fs.writeFileSync(metaPath(sessionId), JSON.stringify(updated, null, 2));
51+
}
52+
/**
53+
* Load session metadata.
54+
*/
55+
export function loadSessionMeta(sessionId) {
56+
try {
57+
return JSON.parse(fs.readFileSync(metaPath(sessionId), 'utf-8'));
58+
}
59+
catch {
60+
return null;
61+
}
62+
}
63+
/**
64+
* Load full session history from JSONL.
65+
*/
66+
export function loadSessionHistory(sessionId) {
67+
try {
68+
const content = fs.readFileSync(sessionPath(sessionId), 'utf-8');
69+
const lines = content.trim().split('\n').filter(Boolean);
70+
return lines.map(line => JSON.parse(line));
71+
}
72+
catch {
73+
return [];
74+
}
75+
}
76+
/**
77+
* List all saved sessions, newest first.
78+
*/
79+
export function listSessions() {
80+
ensureDir();
81+
try {
82+
const files = fs.readdirSync(SESSIONS_DIR)
83+
.filter(f => f.endsWith('.meta.json'));
84+
const metas = [];
85+
for (const file of files) {
86+
try {
87+
const meta = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, file), 'utf-8'));
88+
metas.push(meta);
89+
}
90+
catch { /* skip corrupted */ }
91+
}
92+
return metas.sort((a, b) => b.updatedAt - a.updatedAt);
93+
}
94+
catch {
95+
return [];
96+
}
97+
}
98+
/**
99+
* Prune old sessions beyond MAX_SESSIONS.
100+
*/
101+
export function pruneOldSessions() {
102+
const sessions = listSessions();
103+
if (sessions.length <= MAX_SESSIONS)
104+
return;
105+
const toDelete = sessions.slice(MAX_SESSIONS);
106+
for (const s of toDelete) {
107+
try {
108+
fs.unlinkSync(sessionPath(s.id));
109+
}
110+
catch { /* ok */ }
111+
try {
112+
fs.unlinkSync(metaPath(s.id));
113+
}
114+
catch { /* ok */ }
115+
}
116+
}

dist/ui/app.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,12 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
167167
setShowHelp(true);
168168
setShowWallet(false);
169169
return;
170+
case '/sessions':
171+
setStreamText('');
172+
setWaiting(true);
173+
setReady(false);
174+
onSubmit('/sessions');
175+
return;
170176
case '/clear':
171177
setStreamText('');
172178
setTools(new Map());
@@ -199,6 +205,14 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
199205
onSubmit('/compact');
200206
return;
201207
default:
208+
// Commands with arguments that pass through to the loop
209+
if (trimmed.startsWith('/resume ')) {
210+
setStreamText('');
211+
setWaiting(true);
212+
setReady(false);
213+
onSubmit(trimmed);
214+
return;
215+
}
202216
setStatusMsg(`Unknown command: ${cmd}. Try /help`);
203217
setTimeout(() => setStatusMsg(''), 3000);
204218
return;
@@ -296,7 +310,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
296310
}), _jsx(Text, { children: " " })] }));
297311
}
298312
// ── Normal Mode ──
299-
return (_jsxs(Box, { flexDirection: "column", children: [statusMsg && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "green", children: statusMsg }) })), showHelp && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Commands" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/model" }), " [name] Switch model (picker if no name)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/wallet" }), " Show wallet address & balance"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/cost" }), " Session cost & savings"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/retry" }), " Retry the last prompt"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/compact" }), " Compress conversation history"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/clear" }), " Clear conversation display"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/help" }), " This help"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/exit" }), " Quit"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: " Shortcuts: sonnet, opus, gpt, gemini, deepseek, flash, free, r1, o4, nano, mini, haiku" })] })), showWallet && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Wallet" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" Chain: ", _jsx(Text, { color: "magenta", children: chain })] }), _jsxs(Text, { children: [" Address: ", _jsx(Text, { color: "cyan", children: walletAddress })] }), _jsxs(Text, { children: [" Balance: ", _jsx(Text, { color: "green", children: balance })] })] })), Array.from(tools.values()).map((tool, i) => (_jsx(Box, { marginLeft: 1, children: tool.done ? (tool.error
313+
return (_jsxs(Box, { flexDirection: "column", children: [statusMsg && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "green", children: statusMsg }) })), showHelp && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Commands" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/model" }), " [name] Switch model (picker if no name)"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/wallet" }), " Show wallet address & balance"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/cost" }), " Session cost & savings"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/retry" }), " Retry the last prompt"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/compact" }), " Compress conversation history"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/sessions" }), " List saved sessions"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/resume" }), " id Resume a saved session"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/clear" }), " Clear conversation display"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/help" }), " This help"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", children: "/exit" }), " Quit"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: " Shortcuts: sonnet, opus, gpt, gemini, deepseek, flash, free, r1, o4, nano, mini, haiku" })] })), showWallet && (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, children: "Wallet" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" Chain: ", _jsx(Text, { color: "magenta", children: chain })] }), _jsxs(Text, { children: [" Address: ", _jsx(Text, { color: "cyan", children: walletAddress })] }), _jsxs(Text, { children: [" Balance: ", _jsx(Text, { color: "green", children: balance })] })] })), Array.from(tools.values()).map((tool, i) => (_jsx(Box, { marginLeft: 1, children: tool.done ? (tool.error
300314
? _jsxs(Text, { color: "red", children: [" \u2717 ", tool.name, " ", _jsxs(Text, { dimColor: true, children: [tool.elapsed, "ms"] })] })
301315
: _jsxs(Text, { color: "green", children: [" \u2713 ", tool.name, " ", _jsxs(Text, { dimColor: true, children: [tool.elapsed, "ms \u2014 ", tool.preview.slice(0, 200), tool.preview.length > 200 ? '...' : ''] })] })) : (_jsxs(Text, { color: "cyan", children: [" ", _jsx(Spinner, { type: "dots" }), " ", tool.name, "... ", _jsxs(Text, { dimColor: true, children: [Math.round((Date.now() - tool.startTime) / 1000), "s"] })] })) }, i))), thinking && (_jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsxs(Text, { color: "magenta", children: [" ", _jsx(Spinner, { type: "dots" }), " thinking..."] }), thinkingText && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", thinkingText.split('\n').pop()?.slice(0, 80)] }))] })), waiting && !thinking && tools.size === 0 && (_jsx(Box, { marginLeft: 1, children: _jsxs(Text, { color: "yellow", children: [" ", _jsx(Spinner, { type: "dots" }), " ", _jsx(Text, { dimColor: true, children: currentModel })] }) })), streamText && (_jsx(Box, { marginTop: 0, marginBottom: 0, children: _jsx(Text, { children: streamText }) })), ready && (turnTokens.input > 0 || turnTokens.output > 0) && streamText && (_jsx(Box, { marginLeft: 1, marginTop: 0, children: _jsxs(Text, { dimColor: true, children: [turnTokens.input.toLocaleString(), " in / ", turnTokens.output.toLocaleString(), " out", totalCost > 0 ? ` · $${totalCost.toFixed(4)} session` : ''] }) })), ready && (_jsx(InputBox, { input: input, setInput: setInput, onSubmit: handleSubmit, model: currentModel, balance: balance, focused: mode === 'input' }))] }));
302316
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@blockrun/runcode",
3-
"version": "1.4.0",
3+
"version": "1.5.0",
44
"description": "RunCode — AI coding agent powered by 41+ models. Pay per use with USDC.",
55
"type": "module",
66
"bin": {

0 commit comments

Comments
 (0)