From 485abb4774f5f48ef0ebfd2f4f1e9081532884df Mon Sep 17 00:00:00 2001 From: Hibryda Date: Fri, 27 Mar 2026 02:43:54 +0100 Subject: [PATCH] =?UTF-8?q?feat(electrobun):=20session=20continuity=20?= =?UTF-8?q?=E2=80=94=20Claude=20JSONL=20listing,=20resume/continue=20sidec?= =?UTF-8?q?ar=20support,=20session=20picker=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sidecar/claude-runner.ts | 22 +- ui-electrobun/src/bun/claude-sessions.ts | 129 +++++++ .../src/bun/handlers/agent-handlers.ts | 14 +- ui-electrobun/src/bun/sidecar-manager.ts | 10 + ui-electrobun/src/mainview/AgentPane.svelte | 13 + ui-electrobun/src/mainview/ProjectCard.svelte | 2 + .../src/mainview/SessionPicker.svelte | 333 ++++++++++++++++++ .../src/mainview/agent-store.svelte.ts | 88 +++++ ui-electrobun/src/shared/pty-rpc-schema.ts | 19 + 9 files changed, 626 insertions(+), 4 deletions(-) create mode 100644 ui-electrobun/src/bun/claude-sessions.ts create mode 100644 ui-electrobun/src/mainview/SessionPicker.svelte diff --git a/sidecar/claude-runner.ts b/sidecar/claude-runner.ts index 6fa6a30..01250b4 100644 --- a/sidecar/claude-runner.ts +++ b/sidecar/claude-runner.ts @@ -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) { } 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', diff --git a/ui-electrobun/src/bun/claude-sessions.ts b/ui-electrobun/src/bun/claude-sessions.ts new file mode 100644 index 0000000..95ed501 --- /dev/null +++ b/ui-electrobun/src/bun/claude-sessions.ts @@ -0,0 +1,129 @@ +/** + * Claude session listing — reads Claude SDK session files from disk. + * + * Sessions stored as JSONL at ~/.claude/projects//.jsonl + * where = 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) => 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) => 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; +} diff --git a/ui-electrobun/src/bun/handlers/agent-handlers.ts b/ui-electrobun/src/bun/handlers/agent-handlers.ts index cff7d9f..969a63a 100644 --- a/ui-electrobun/src/bun/handlers/agent-handlers.ts +++ b/ui-electrobun/src/bun/handlers/agent-handlers.ts @@ -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) => { + "agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName, resumeMode, resumeSessionId }: Record) => { 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, + { cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName, resumeMode, resumeSessionId } as Record, ); 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: [] }; + } + }, }; } diff --git a/ui-electrobun/src/bun/sidecar-manager.ts b/ui-electrobun/src/bun/sidecar-manager.ts index ee8c6c9..053be66 100644 --- a/ui-electrobun/src/bun/sidecar-manager.ts +++ b/ui-electrobun/src/bun/sidecar-manager.ts @@ -39,6 +39,10 @@ export interface StartSessionOptions { extraEnv?: Record; 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); diff --git a/ui-electrobun/src/mainview/AgentPane.svelte b/ui-electrobun/src/mainview/AgentPane.svelte index 5a6bf47..a22c1eb 100644 --- a/ui-electrobun/src/mainview/AgentPane.svelte +++ b/ui-electrobun/src/mainview/AgentPane.svelte @@ -1,6 +1,7 @@ + +
+ + +
+ + + {#if sessions.length > 0} + + {/if} + + + +
+ {#if loading} +
Loading...
+ {:else if sessions.length === 0} +
No previous sessions
+ {:else} + {#each sessions as s (s.sessionId)} + + {/each} + {/if} +
+
+
+ + diff --git a/ui-electrobun/src/mainview/agent-store.svelte.ts b/ui-electrobun/src/mainview/agent-store.svelte.ts index 9697913..ae62a88 100644 --- a/ui-electrobun/src/mainview/agent-store.svelte.ts +++ b/ui-electrobun/src/mainview/agent-store.svelte.ts @@ -47,6 +47,21 @@ interface StartOptions { extraEnv?: Record; 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(); +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 { + 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 = {}, +): 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 = {}, +): 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. diff --git a/ui-electrobun/src/shared/pty-rpc-schema.ts b/ui-electrobun/src/shared/pty-rpc-schema.ts index 297b2f4..9dde67c 100644 --- a/ui-electrobun/src/shared/pty-rpc-schema.ts +++ b/ui-electrobun/src/shared/pty-rpc-schema.ts @@ -389,6 +389,10 @@ export type PtyRPCRequests = { extraEnv?: Record; 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. */