feat(electrobun): session continuity — Claude JSONL listing, resume/continue sidecar support, session picker UI

This commit is contained in:
Hibryda 2026-03-27 02:43:54 +01:00
parent 0e217b9dae
commit 485abb4774
9 changed files with 626 additions and 4 deletions

View file

@ -41,7 +41,10 @@ interface QueryMessage {
cwd?: string;
maxTurns?: number;
maxBudgetUsd?: number;
/** @deprecated Use resumeMode='resume' + resumeSessionId instead. */
resumeSessionId?: string;
/** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific). */
resumeMode?: 'new' | 'continue' | 'resume';
permissionMode?: string;
settingSources?: string[];
systemPrompt?: string;
@ -74,7 +77,7 @@ async function handleMessage(msg: Record<string, unknown>) {
}
async function handleQuery(msg: QueryMessage) {
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName, extraEnv } = msg;
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, resumeMode, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName, extraEnv } = msg;
if (sessions.has(sessionId)) {
send({ type: 'error', sessionId, message: 'Session already running' });
@ -112,6 +115,20 @@ async function handleQuery(msg: QueryMessage) {
return;
}
// Build resume/continue options based on resumeMode
let resumeOpt: string | undefined;
let continueOpt: boolean | undefined;
if (resumeMode === 'continue') {
continueOpt = true;
log(`Session ${sessionId}: continuing most recent session`);
} else if (resumeMode === 'resume' && resumeSessionId) {
resumeOpt = resumeSessionId;
log(`Session ${sessionId}: resuming SDK session ${resumeSessionId}`);
} else if (resumeSessionId && !resumeMode) {
// Legacy: direct resumeSessionId without resumeMode
resumeOpt = resumeSessionId;
}
const q = query({
prompt,
options: {
@ -121,7 +138,8 @@ async function handleQuery(msg: QueryMessage) {
env: cleanEnv,
maxTurns: maxTurns ?? undefined,
maxBudgetUsd: maxBudgetUsd ?? undefined,
resume: resumeSessionId ?? undefined,
resume: resumeOpt,
continue: continueOpt,
allowedTools: [
'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch', 'TodoWrite', 'NotebookEdit',

View file

@ -0,0 +1,129 @@
/**
* Claude session listing reads Claude SDK session files from disk.
*
* Sessions stored as JSONL at ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl
* where <encoded-cwd> = absolute path with non-alphanumeric chars replaced by '-'.
*/
import { join } from "path";
import { homedir } from "os";
import { readdirSync, readFileSync, statSync } from "fs";
// ── Types ────────────────────────────────────────────────────────────────────
export interface ClaudeSessionInfo {
sessionId: string;
summary: string;
lastModified: number;
fileSize: number;
firstPrompt: string;
model: string;
}
// ── Implementation ───────────────────────────────────────────────────────────
function encodeCwd(cwd: string): string {
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
}
/**
* List Claude sessions for a project CWD.
* Reads the first 5 lines of each .jsonl file to extract metadata.
* Returns sessions sorted by lastModified descending.
*/
export function listClaudeSessions(cwd: string): ClaudeSessionInfo[] {
const encoded = encodeCwd(cwd);
const sessionsDir = join(homedir(), ".claude", "projects", encoded);
let entries: string[];
try {
entries = readdirSync(sessionsDir);
} catch (err: unknown) {
// ENOENT or permission error — no sessions yet
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
console.warn("[claude-sessions] readdir error:", err);
return [];
}
const jsonlFiles = entries.filter((f) => f.endsWith(".jsonl"));
const results: ClaudeSessionInfo[] = [];
for (const file of jsonlFiles) {
try {
const filePath = join(sessionsDir, file);
const stat = statSync(filePath);
const sessionId = file.replace(/\.jsonl$/, "");
// Read first 5 lines for metadata extraction
const content = readFileSync(filePath, "utf-8");
const lines = content.split("\n").slice(0, 5);
let firstPrompt = "";
let model = "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
// Extract model from init/system messages
if (!model && parsed.model) {
model = String(parsed.model);
}
// Extract first user prompt
if (!firstPrompt && parsed.role === "user") {
const content = parsed.content;
if (typeof content === "string") {
firstPrompt = content;
} else if (Array.isArray(content)) {
// Content blocks format
const textBlock = content.find(
(b: Record<string, unknown>) => b.type === "text",
);
if (textBlock?.text) firstPrompt = String(textBlock.text);
}
}
// Also check for message type wrappers
if (!firstPrompt && parsed.type === "human" && parsed.message?.content) {
const mc = parsed.message.content;
if (typeof mc === "string") {
firstPrompt = mc;
} else if (Array.isArray(mc)) {
const textBlock = mc.find(
(b: Record<string, unknown>) => b.type === "text",
);
if (textBlock?.text) firstPrompt = String(textBlock.text);
}
}
} catch {
// Skip malformed JSONL lines
continue;
}
}
// Truncate first prompt for display
if (firstPrompt.length > 120) {
firstPrompt = firstPrompt.slice(0, 117) + "...";
}
results.push({
sessionId,
summary: firstPrompt || "(no prompt found)",
lastModified: stat.mtimeMs,
fileSize: stat.size,
firstPrompt: firstPrompt || "",
model: model || "unknown",
});
} catch {
// Skip corrupt/unreadable files
continue;
}
}
// Sort by lastModified descending (newest first)
results.sort((a, b) => b.lastModified - a.lastModified);
return results;
}

View file

@ -5,6 +5,7 @@
import type { SidecarManager } from "../sidecar-manager.ts";
import type { SessionDb } from "../session-db.ts";
import type { SearchDb } from "../search-db.ts";
import { listClaudeSessions } from "../claude-sessions.ts";
export function createAgentHandlers(
sidecarManager: SidecarManager,
@ -13,13 +14,13 @@ export function createAgentHandlers(
searchDb?: SearchDb,
) {
return {
"agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName }: Record<string, unknown>) => {
"agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName, resumeMode, resumeSessionId }: Record<string, unknown>) => {
try {
const result = sidecarManager.startSession(
sessionId as string,
provider as string,
prompt as string,
{ cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName } as Record<string, unknown>,
{ cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName, resumeMode, resumeSessionId } as Record<string, unknown>,
);
if (result.ok) {
@ -169,5 +170,14 @@ export function createAgentHandlers(
return { messages: [] };
}
},
"session.listClaude": ({ cwd }: { cwd: string }) => {
try {
return { sessions: listClaudeSessions(cwd) };
} catch (err) {
console.error("[session.listClaude]", err);
return { sessions: [] };
}
},
};
}

View file

@ -39,6 +39,10 @@ export interface StartSessionOptions {
extraEnv?: Record<string, string>;
additionalDirectories?: string[];
worktreeName?: string;
/** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific). */
resumeMode?: "new" | "continue" | "resume";
/** Required when resumeMode='resume' — the Claude SDK session ID to resume. */
resumeSessionId?: string;
}
type MessageCallback = (sessionId: string, messages: AgentMessage[]) => void;
@ -283,6 +287,12 @@ export class SidecarManager {
if (options.worktreeName) {
queryMsg.worktreeName = options.worktreeName;
}
if (options.resumeMode && options.resumeMode !== "new") {
queryMsg.resumeMode = options.resumeMode;
}
if (options.resumeSessionId) {
queryMsg.resumeSessionId = options.resumeSessionId;
}
dbg(`Sending query: ${JSON.stringify(queryMsg).slice(0, 200)}...`);
this.writeToProcess(sessionId, queryMsg);

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import ChatInput from "./ChatInput.svelte";
import SessionPicker from "./SessionPicker.svelte";
import type { AgentMessage, AgentStatus } from "./agent-store.svelte.ts";
import { t } from "./i18n.svelte.ts";
@ -14,6 +15,8 @@
profile?: string;
contextPct?: number;
burnRate?: number;
projectId?: string;
cwd?: string;
onSend?: (text: string) => void;
onStop?: () => void;
}
@ -25,6 +28,8 @@
tokens,
model = "claude-opus-4-5",
provider = "claude",
projectId = "",
cwd = "",
contextPct = 0,
onSend,
onStop,
@ -115,6 +120,14 @@
<span class="strip-tokens">{fmtTokens(tokens)} tok</span>
<span class="strip-sep" aria-hidden="true"></span>
<span class="strip-cost">{fmtCost(costUsd)}</span>
{#if projectId && cwd && provider === "claude"}
<SessionPicker
{projectId}
{provider}
{cwd}
onNewSession={onSend}
/>
{/if}
{#if status === "running" && onStop}
<button
class="strip-stop-btn"

View file

@ -276,6 +276,8 @@
costUsd={getAgentCost()}
tokens={getAgentTokens()}
model={getAgentModel()}
projectId={id}
{cwd}
{provider}
{profile}
{contextPct}

View file

@ -0,0 +1,333 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ClaudeSessionInfo } from './agent-store.svelte.ts';
import { listProjectSessions, resumeSession, continueLastSession, startAgent } from './agent-store.svelte.ts';
import { t } from './i18n.svelte.ts';
interface Props {
projectId: string;
provider: string;
cwd: string;
currentSessionId?: string;
onNewSession?: (prompt: string) => void;
}
let {
projectId,
provider,
cwd,
currentSessionId,
onNewSession,
}: Props = $props();
let open = $state(false);
let sessions = $state<ClaudeSessionInfo[]>([]);
let loading = $state(false);
let containerEl: HTMLDivElement;
onMount(() => {
fetchSessions();
// Close on outside click
function handleClick(e: MouseEvent) {
if (containerEl && !containerEl.contains(e.target as Node)) {
open = false;
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
});
async function fetchSessions() {
if (!cwd) return;
loading = true;
try {
sessions = await listProjectSessions(projectId, cwd);
} catch {
sessions = [];
}
loading = false;
}
function toggle() {
open = !open;
if (open) fetchSessions();
}
function fmtDate(ms: number): string {
const d = new Date(ms);
const now = Date.now();
const diffH = (now - ms) / 3_600_000;
if (diffH < 1) return `${Math.max(1, Math.round(diffH * 60))}m ago`;
if (diffH < 24) return `${Math.round(diffH)}h ago`;
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function fmtSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 3) + '...' : s;
}
async function handleContinue() {
open = false;
await continueLastSession(projectId, provider, '', cwd);
}
async function handleResume(sdkSessionId: string) {
open = false;
await resumeSession(projectId, provider, sdkSessionId, '', cwd);
}
function handleNew() {
open = false;
onNewSession?.('');
}
</script>
<div class="session-picker" bind:this={containerEl}>
<button
class="picker-trigger"
onclick={toggle}
title="Session history"
aria-label="Session history"
aria-expanded={open}
>
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 3.5a.5.5 0 0 0-1 0V8a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 7.71V3.5z"/>
<path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
</svg>
</button>
<div class="picker-dropdown" style:display={open ? 'flex' : 'none'}>
<div class="dropdown-header">
<span class="dropdown-title">Sessions</span>
<button class="new-btn" onclick={handleNew}>
+ New
</button>
</div>
{#if sessions.length > 0}
<button class="continue-btn" onclick={handleContinue}>
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="continue-icon">
<path d="M4 2l8 6-8 6V2z"/>
</svg>
Continue last session
</button>
{/if}
<div class="dropdown-divider"></div>
<div class="session-list">
{#if loading}
<div class="session-empty">Loading...</div>
{:else if sessions.length === 0}
<div class="session-empty">No previous sessions</div>
{:else}
{#each sessions as s (s.sessionId)}
<button
class="session-row"
class:active={currentSessionId === s.sessionId}
onclick={() => handleResume(s.sessionId)}
>
<div class="session-prompt">{truncate(s.firstPrompt || s.summary, 60)}</div>
<div class="session-meta">
<span class="meta-date">{fmtDate(s.lastModified)}</span>
<span class="meta-sep" aria-hidden="true"></span>
<span class="meta-size">{fmtSize(s.fileSize)}</span>
<span class="meta-sep" aria-hidden="true"></span>
<span class="meta-model">{s.model}</span>
</div>
</button>
{/each}
{/if}
</div>
</div>
</div>
<style>
.session-picker {
position: relative;
display: flex;
align-items: center;
}
.picker-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
padding: 0;
background: transparent;
border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem;
color: var(--ctp-overlay1);
cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.picker-trigger:hover {
color: var(--ctp-text);
border-color: var(--ctp-blue);
}
.picker-trigger svg {
width: 10px;
height: 10px;
}
.picker-dropdown {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
z-index: 100;
flex-direction: column;
width: 18rem;
max-height: 20rem;
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
box-shadow: 0 4px 12px color-mix(in srgb, var(--ctp-crust) 60%, transparent);
overflow: hidden;
}
.dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.dropdown-title {
font-size: 0.6875rem;
font-weight: 600;
color: var(--ctp-subtext1);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.new-btn {
font-size: 0.6875rem;
font-weight: 500;
color: var(--ctp-blue);
background: transparent;
border: none;
cursor: pointer;
padding: 0.125rem 0.375rem;
border-radius: 0.2rem;
transition: background 0.12s;
}
.new-btn:hover {
background: color-mix(in srgb, var(--ctp-blue) 12%, transparent);
}
.continue-btn {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.375rem 0.5rem;
background: transparent;
border: none;
color: var(--ctp-green);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
text-align: left;
transition: background 0.12s;
}
.continue-btn:hover {
background: color-mix(in srgb, var(--ctp-green) 10%, transparent);
}
.continue-icon {
width: 10px;
height: 10px;
flex-shrink: 0;
}
.dropdown-divider {
height: 1px;
background: var(--ctp-surface0);
flex-shrink: 0;
}
.session-list {
overflow-y: auto;
flex: 1;
min-height: 0;
}
.session-list::-webkit-scrollbar {
width: 0.25rem;
}
.session-list::-webkit-scrollbar-track {
background: transparent;
}
.session-list::-webkit-scrollbar-thumb {
background: var(--ctp-surface1);
border-radius: 0.25rem;
}
.session-empty {
padding: 1rem 0.5rem;
text-align: center;
font-size: 0.75rem;
color: var(--ctp-overlay0);
}
.session-row {
display: flex;
flex-direction: column;
gap: 0.125rem;
width: 100%;
padding: 0.375rem 0.5rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.12s;
}
.session-row:hover {
background: var(--ctp-surface0);
}
.session-row.active {
background: color-mix(in srgb, var(--ctp-blue) 12%, transparent);
}
.session-prompt {
font-size: 0.75rem;
color: var(--ctp-text);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-meta {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.625rem;
color: var(--ctp-overlay1);
}
.meta-sep {
width: 1px;
height: 0.5rem;
background: var(--ctp-surface1);
}
.meta-model {
color: var(--ctp-overlay0);
}
</style>

View file

@ -47,6 +47,21 @@ interface StartOptions {
extraEnv?: Record<string, string>;
additionalDirectories?: string[];
worktreeName?: string;
/** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific). */
resumeMode?: 'new' | 'continue' | 'resume';
/** Required when resumeMode='resume' — the Claude SDK session ID to resume. */
resumeSessionId?: string;
}
// ── Claude session listing types ──────────────────────────────────────────────
export interface ClaudeSessionInfo {
sessionId: string;
summary: string;
lastModified: number;
fileSize: number;
firstPrompt: string;
model: string;
}
// ── Toast callback (set by App.svelte) ────────────────────────────────────────
@ -297,6 +312,8 @@ function ensureListeners() {
scheduleCleanup(session.sessionId, session.projectId);
// Fix #14 (Codex audit): Enforce max sessions per project on completion
enforceMaxSessions(session.projectId);
// Invalidate Claude session cache so picker refreshes
invalidateSessionCache(session.projectId);
}
});
@ -567,6 +584,8 @@ async function _startAgentInner(
permissionMode: permissionMode,
claudeConfigDir: options.claudeConfigDir,
extraEnv: validateExtraEnv(options.extraEnv),
resumeMode: options.resumeMode,
resumeSessionId: options.resumeSessionId,
});
if (!result.ok) {
@ -828,6 +847,75 @@ function enforceMaxSessions(projectId: string): void {
}
}
// ── Session continuity API ────────────────────────────────────────────────────
// Claude session list cache per project (keyed by projectId)
const claudeSessionCache = new Map<string, { sessions: ClaudeSessionInfo[]; fetchedAt: number }>();
const CACHE_TTL_MS = 30_000; // 30 seconds
/**
* List Claude SDK sessions from disk for a project CWD.
* Cached for 30 seconds; invalidated on agent completion.
*/
export async function listProjectSessions(projectId: string, cwd: string): Promise<ClaudeSessionInfo[]> {
const cached = claudeSessionCache.get(projectId);
if (cached && (Date.now() - cached.fetchedAt) < CACHE_TTL_MS) {
return cached.sessions;
}
try {
const result = await appRpc.request['session.listClaude']({ cwd });
const sessions = (result?.sessions ?? []) as ClaudeSessionInfo[];
claudeSessionCache.set(projectId, { sessions, fetchedAt: Date.now() });
return sessions;
} catch (err) {
console.error('[listProjectSessions] error:', err);
return [];
}
}
/** Invalidate the Claude session cache for a project. */
export function invalidateSessionCache(projectId: string): void {
claudeSessionCache.delete(projectId);
}
/**
* Continue the most recent Claude session for a project.
* Uses SDK `continue: true` picks up where the last session left off.
*/
export async function continueLastSession(
projectId: string,
provider: string,
prompt: string,
cwd: string,
options: Omit<StartOptions, 'resumeMode' | 'resumeSessionId' | 'cwd'> = {},
): Promise<{ ok: boolean; error?: string }> {
return startAgent(projectId, provider, prompt, {
...options,
cwd,
resumeMode: 'continue',
});
}
/**
* Resume a specific Claude session by its SDK session ID.
*/
export async function resumeSession(
projectId: string,
provider: string,
sdkSessionId: string,
prompt: string,
cwd: string,
options: Omit<StartOptions, 'resumeMode' | 'resumeSessionId' | 'cwd'> = {},
): Promise<{ ok: boolean; error?: string }> {
return startAgent(projectId, provider, prompt, {
...options,
cwd,
resumeMode: 'resume',
resumeSessionId: sdkSessionId,
});
}
// NOTE: Do NOT call ensureListeners() at module load — appRpc may not be
// initialized yet (setAppRpc runs in main.ts after module imports resolve).
// Listeners are registered lazily on first startAgent/getSession/sendPrompt call.

View file

@ -389,6 +389,10 @@ export type PtyRPCRequests = {
extraEnv?: Record<string, string>;
additionalDirectories?: string[];
worktreeName?: string;
/** Session continuity: 'new' (default), 'continue' (most recent), 'resume' (specific session). */
resumeMode?: "new" | "continue" | "resume";
/** Required when resumeMode='resume' — the Claude SDK session ID to resume. */
resumeSessionId?: string;
};
response: { ok: boolean; error?: string };
};
@ -477,6 +481,21 @@ export type PtyRPCRequests = {
};
};
/** List Claude SDK sessions from disk for a project CWD. */
"session.listClaude": {
params: { cwd: string };
response: {
sessions: Array<{
sessionId: string;
summary: string;
lastModified: number;
fileSize: number;
firstPrompt: string;
model: string;
}>;
};
};
// ── btmsg RPC ──────────────────────────────────────────────────────────
/** Register an agent in btmsg. */