diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 8537c2c..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "mcp__agor-launcher__agor-status", - "mcp__agor-launcher__agor-kill-stale", - "mcp__agor-launcher__agor-stop", - "mcp__agor-launcher__agor-start" - ] - } -} diff --git a/sidecar/claude-runner.ts b/sidecar/claude-runner.ts index f4da594..6fa6a30 100644 --- a/sidecar/claude-runner.ts +++ b/sidecar/claude-runner.ts @@ -8,8 +8,7 @@ import { execSync } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; -import { query, listSessions, type Query } from '@anthropic-ai/claude-agent-sdk'; -import { readFileSync, writeFileSync, readdirSync, statSync } from 'fs'; +import { query, type Query } from '@anthropic-ai/claude-agent-sdk'; const rl = createInterface({ input: stdin }); @@ -42,10 +41,7 @@ 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; @@ -78,7 +74,7 @@ async function handleMessage(msg: Record) { } async function handleQuery(msg: QueryMessage) { - const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, resumeMode, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName, extraEnv } = msg; + const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName, extraEnv } = msg; if (sessions.has(sessionId)) { send({ type: 'error', sessionId, message: 'Session already running' }); @@ -116,25 +112,6 @@ 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`); - // Sanitize the most recent session file before SDK reads it - sanitizeSessionFiles(cwd || process.cwd()); - } else if (resumeMode === 'resume' && resumeSessionId) { - resumeOpt = resumeSessionId; - log(`Session ${sessionId}: resuming SDK session ${resumeSessionId}`); - // Sanitize the specific session file - sanitizeSessionFile(cwd || process.cwd(), resumeSessionId); - } else if (resumeSessionId && !resumeMode) { - // Legacy: direct resumeSessionId without resumeMode - resumeOpt = resumeSessionId; - sanitizeSessionFile(cwd || process.cwd(), resumeSessionId); - } - const q = query({ prompt, options: { @@ -144,11 +121,17 @@ async function handleQuery(msg: QueryMessage) { env: cleanEnv, maxTurns: maxTurns ?? undefined, maxBudgetUsd: maxBudgetUsd ?? undefined, - resume: resumeOpt, - continue: continueOpt, + resume: resumeSessionId ?? undefined, + allowedTools: [ + 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', + 'WebSearch', 'WebFetch', 'TodoWrite', 'NotebookEdit', + ], permissionMode: (permissionMode ?? 'bypassPermissions') as 'bypassPermissions' | 'default', allowDangerouslySkipPermissions: (permissionMode ?? 'bypassPermissions') === 'bypassPermissions', - ...(systemPrompt ? { systemPrompt } : {}), + settingSources: settingSources ?? ['user', 'project'], + systemPrompt: systemPrompt + ? systemPrompt + : { type: 'preset' as const, preset: 'claude_code' as const }, model: model ?? undefined, additionalDirectories: additionalDirectories ?? undefined, extraArgs: worktreeName ? { worktree: worktreeName } : undefined, @@ -237,108 +220,5 @@ if (claudePath) { log('WARNING: Claude CLI not found — agent sessions will fail'); } -// ── Session sanitizer ────────────────────────────────────────────────────── -// Fixes "cache_control cannot be set for empty text blocks" API error on resume. -// The SDK stores empty text blocks with cache_control during streaming; -// the API rejects them on replay. We strip them before the SDK reads the file. - -function encodeCwd(cwdPath: string): string { - return cwdPath.replace(/[^a-zA-Z0-9]/g, '-'); -} - -function sanitizeSessionFile(cwdPath: string, sdkSessionId: string): void { - const encoded = encodeCwd(cwdPath); - const filePath = join(homedir(), '.claude', 'projects', encoded, `${sdkSessionId}.jsonl`); - try { - sanitizeJsonlFile(filePath); - } catch (err) { - log(`sanitize: could not clean ${filePath}: ${err}`); - } -} - -function sanitizeSessionFiles(cwdPath: string): void { - // For 'continue' mode, sanitize the most recent session - try { - const sessions = listSessions({ dir: cwdPath, limit: 1 }); - if (sessions && sessions.length > 0) { - sanitizeSessionFile(cwdPath, (sessions[0] as Record).sessionId as string); - } - } catch (err) { - log(`sanitize: listSessions failed: ${err}`); - // Fallback: try to find and sanitize the most recent .jsonl file - const encoded = encodeCwd(cwdPath); - const dir = join(homedir(), '.claude', 'projects', encoded); - try { - const files = readdirSync(dir).filter(f => f.endsWith('.jsonl')); - if (files.length > 0) { - // Sort by mtime descending - files.sort((a, b) => { - try { - return statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs; - } catch { return 0; } - }); - sanitizeJsonlFile(join(dir, files[0])); - } - } catch { /* dir doesn't exist */ } - } -} - -function sanitizeJsonlFile(filePath: string): void { - if (!existsSync(filePath)) return; - - const content = readFileSync(filePath, 'utf-8'); - const lines = content.split('\n'); - let modified = false; - - const cleaned = lines.map(line => { - if (!line.trim()) return line; - try { - const obj = JSON.parse(line); - if (cleanContentBlocks(obj)) { - modified = true; - return JSON.stringify(obj); - } - } catch { /* skip unparseable lines */ } - return line; - }); - - if (modified) { - writeFileSync(filePath, cleaned.join('\n')); - log(`sanitize: cleaned empty text blocks from ${filePath}`); - } -} - -function cleanContentBlocks(obj: Record): boolean { - let changed = false; - - // Check message.content array - const msg = obj.message as Record | undefined; - if (msg?.content && Array.isArray(msg.content)) { - const before = msg.content.length; - msg.content = (msg.content as Array>).filter(block => { - // Remove empty text blocks (with or without cache_control) - if (block.type === 'text' && (!block.text || !(block.text as string).trim())) { - return false; - } - return true; - }); - if (msg.content.length !== before) changed = true; - } - - // Also check top-level content array (some formats) - if (obj.content && Array.isArray(obj.content)) { - const before = (obj.content as unknown[]).length; - obj.content = (obj.content as Array>).filter(block => { - if (block.type === 'text' && (!block.text || !(block.text as string).trim())) { - return false; - } - return true; - }); - if ((obj.content as unknown[]).length !== before) changed = true; - } - - return changed; -} - log('Sidecar started'); send({ type: 'ready' }); diff --git a/ui-electrobun/src/bun/claude-sessions.ts b/ui-electrobun/src/bun/claude-sessions.ts deleted file mode 100644 index 39a9825..0000000 --- a/ui-electrobun/src/bun/claude-sessions.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * 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 50 lines for metadata extraction (Claude sessions have - // many system/queue/hook events before the first user message) - const content = readFileSync(filePath, "utf-8"); - const lines = content.split("\n").slice(0, 50); - - 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); - } - } - - // Claude session format: type="user" with message.content - if (!firstPrompt && parsed.type === "user" && 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); - } - } - - // Also check "human" wrapper (older format) - 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); - } - } - - // Extract model from assistant messages - if (!model && parsed.type === "assistant" && parsed.message?.model) { - model = String(parsed.message.model); - } - } 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; -} - -// ── Message types for display ─────────────────────────────────────────────── - -export interface SessionMessage { - id: string; - role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'system'; - content: string; - timestamp: number; - model?: string; - toolName?: string; -} - -/** - * Load conversation messages from a Claude JSONL session file. - * Extracts user prompts and assistant responses for display. - */ -export function loadClaudeSessionMessages(cwd: string, sdkSessionId: string): SessionMessage[] { - const encoded = encodeCwd(cwd); - const filePath = join(homedir(), ".claude", "projects", encoded, `${sdkSessionId}.jsonl`); - - let content: string; - try { - content = readFileSync(filePath, "utf-8"); - } catch { - return []; - } - - const messages: SessionMessage[] = []; - const lines = content.split("\n"); - - for (const line of lines) { - if (!line.trim()) continue; - try { - const obj = JSON.parse(line); - - // User messages - if (obj.type === "user" && obj.message?.content) { - const mc = obj.message.content; - let text = ""; - if (typeof mc === "string") { - text = mc; - } else if (Array.isArray(mc)) { - const tb = mc.find((b: Record) => b.type === "text"); - if (tb?.text) text = String(tb.text); - } - if (text) { - messages.push({ - id: obj.uuid || `user-${messages.length}`, - role: "user", - content: text, - timestamp: obj.timestamp ? new Date(obj.timestamp).getTime() : Date.now(), - }); - } - } - - // Assistant messages - if (obj.type === "assistant" && obj.message?.content) { - const mc = obj.message.content; - let text = ""; - if (Array.isArray(mc)) { - for (const block of mc) { - if (block.type === "text" && block.text) { - text += block.text; - } else if (block.type === "tool_use") { - messages.push({ - id: block.id || `tool-${messages.length}`, - role: "tool_call", - content: `${block.name}(${JSON.stringify(block.input || {}).slice(0, 200)})`, - timestamp: obj.timestamp ? new Date(obj.timestamp).getTime() : Date.now(), - toolName: block.name, - }); - } - } - } - if (text) { - messages.push({ - id: obj.uuid || obj.message?.id || `asst-${messages.length}`, - role: "assistant", - content: text, - timestamp: obj.timestamp ? new Date(obj.timestamp).getTime() : Date.now(), - model: obj.message?.model, - }); - } - } - - // Tool results - if (obj.type === "tool_result" || (obj.role === "tool" && obj.content)) { - const rc = obj.content; - let text = ""; - if (typeof rc === "string") { - text = rc; - } else if (Array.isArray(rc)) { - const tb = rc.find((b: Record) => b.type === "text"); - if (tb?.text) text = String(tb.text); - } - if (text && text.length > 0) { - messages.push({ - id: obj.uuid || `result-${messages.length}`, - role: "tool_result", - content: text.length > 500 ? text.slice(0, 497) + "..." : text, - timestamp: obj.timestamp ? new Date(obj.timestamp).getTime() : Date.now(), - }); - } - } - } catch { - continue; - } - } - - return messages; -} diff --git a/ui-electrobun/src/bun/handlers/agent-handlers.ts b/ui-electrobun/src/bun/handlers/agent-handlers.ts index 51d4d42..cff7d9f 100644 --- a/ui-electrobun/src/bun/handlers/agent-handlers.ts +++ b/ui-electrobun/src/bun/handlers/agent-handlers.ts @@ -5,7 +5,6 @@ import type { SidecarManager } from "../sidecar-manager.ts"; import type { SessionDb } from "../session-db.ts"; import type { SearchDb } from "../search-db.ts"; -import { listClaudeSessions, loadClaudeSessionMessages } from "../claude-sessions.ts"; export function createAgentHandlers( sidecarManager: SidecarManager, @@ -14,13 +13,13 @@ export function createAgentHandlers( searchDb?: SearchDb, ) { return { - "agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName, resumeMode, resumeSessionId }: Record) => { + "agent.start": ({ sessionId, provider, prompt, cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName }: Record) => { try { const result = sidecarManager.startSession( sessionId as string, provider as string, prompt as string, - { cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName, resumeMode, resumeSessionId } as Record, + { cwd, model, systemPrompt, maxTurns, permissionMode, claudeConfigDir, extraEnv, additionalDirectories, worktreeName } as Record, ); if (result.ok) { @@ -170,23 +169,5 @@ export function createAgentHandlers( return { messages: [] }; } }, - - "session.listClaude": ({ cwd }: { cwd: string }) => { - try { - return { sessions: listClaudeSessions(cwd) }; - } catch (err) { - console.error("[session.listClaude]", err); - return { sessions: [] }; - } - }, - - "session.loadMessages": ({ cwd, sdkSessionId }: { cwd: string; sdkSessionId: string }) => { - try { - return { messages: loadClaudeSessionMessages(cwd, sdkSessionId) }; - } catch (err) { - console.error("[session.loadMessages]", err); - return { messages: [] }; - } - }, }; } diff --git a/ui-electrobun/src/bun/sidecar-manager.ts b/ui-electrobun/src/bun/sidecar-manager.ts index 053be66..ee8c6c9 100644 --- a/ui-electrobun/src/bun/sidecar-manager.ts +++ b/ui-electrobun/src/bun/sidecar-manager.ts @@ -39,10 +39,6 @@ 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; @@ -287,12 +283,6 @@ 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 8992352..5a6bf47 100644 --- a/ui-electrobun/src/mainview/AgentPane.svelte +++ b/ui-electrobun/src/mainview/AgentPane.svelte @@ -1,9 +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/Terminal.svelte b/ui-electrobun/src/mainview/Terminal.svelte index d7cf397..b2fa5cc 100644 --- a/ui-electrobun/src/mainview/Terminal.svelte +++ b/ui-electrobun/src/mainview/Terminal.svelte @@ -2,23 +2,23 @@ import { onMount, onDestroy } from 'svelte'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; + // CanvasAddon and ImageAddon require xterm ^5.0.0 — disabled on xterm 6.x + // xterm 6's default renderer uses DOM (no Canvas/WebGL needed) + // TODO: re-enable when @xterm/addon-canvas releases a 6.x-compatible version + // import { CanvasAddon } from '@xterm/addon-canvas'; + // import { ImageAddon } from '@xterm/addon-image'; import { appRpc } from './rpc.ts'; import { fontStore } from './font-store.svelte.ts'; import { themeStore } from './theme-store.svelte.ts'; import { getXtermTheme } from './themes.ts'; - import { appState } from './app-state.svelte.ts'; interface Props { sessionId: string; /** Working directory to open the shell in. */ cwd?: string; - /** When true, terminal is read-only (agent preview). No PTY, no stdin. */ - readonly?: boolean; - /** Project ID — required for agent preview to read bash output. */ - projectId?: string; } - let { sessionId, cwd, readonly = false, projectId }: Props = $props(); + let { sessionId, cwd }: Props = $props(); let termEl: HTMLDivElement; let term: Terminal; @@ -26,10 +26,8 @@ let unsubFont: (() => void) | null = null; let ro: ResizeObserver | null = null; let destroyed = false; + // Fix #5: Store listener cleanup functions to prevent leaks let listenerCleanups: Array<() => void> = []; - /** Index into bashOutputLines — tracks how many lines have been written to xterm. */ - let writtenIndex = 0; - let bashPollTimer: ReturnType | null = null; /** Decode a base64 string from the daemon into a Uint8Array. */ function decodeBase64(b64: string): Uint8Array { @@ -39,17 +37,6 @@ return bytes; } - /** Write new bash output lines to the readonly terminal. */ - function flushBashLines(): void { - if (!projectId || !term) return; - const t = appState.project.getState(projectId).terminals; - const lines = t.bashOutputLines; - while (writtenIndex < lines.length) { - term.writeln(lines[writtenIndex]); - writtenIndex++; - } - } - onMount(() => { const currentTheme = themeStore.currentTheme; const termFamily = fontStore.termFontFamily || 'JetBrains Mono, Fira Code, monospace'; @@ -59,21 +46,20 @@ theme: getXtermTheme(currentTheme), fontFamily: termFamily, fontSize: termSize, - cursorBlink: !readonly, + cursorBlink: true, allowProposedApi: true, scrollback: 5000, - disableStdin: readonly, }); - if (readonly) { - term.attachCustomKeyEventHandler(() => false); - } - fitAddon = new FitAddon(); term.loadAddon(fitAddon); + // NOTE: CanvasAddon and ImageAddon MUST be loaded AFTER term.open() + // because they access _linkifier2 which is created during open() const openAndLoadAddons = () => { term.open(termEl); + // xterm 6.x uses improved default DOM renderer — no Canvas/WebGL addon needed + // Re-enable when addons release 6.x-compatible versions fitAddon.fit(); }; @@ -92,102 +78,99 @@ } // ── Read cursor/scrollback settings ───────────────────────────────── - if (!readonly) { - void (async () => { - try { - const { settings } = await appRpc.request['settings.getAll']({}); - if (settings['cursor_style']) { - const style = settings['cursor_style']; - if (style === 'block' || style === 'underline' || style === 'bar') { - term.options.cursorStyle = style; - } + + void (async () => { + try { + const { settings } = await appRpc.request['settings.getAll']({}); + if (settings['cursor_style']) { + const style = settings['cursor_style']; + if (style === 'block' || style === 'underline' || style === 'bar') { + term.options.cursorStyle = style; } - if (settings['cursor_blink'] === 'false') { - term.options.cursorBlink = false; - } - if (settings['scrollback']) { - const sb = parseInt(settings['scrollback'], 10); - if (!isNaN(sb) && sb >= 0) term.options.scrollback = sb; - } - } catch { /* non-critical — use defaults */ } - })(); - } + } + if (settings['cursor_blink'] === 'false') { + term.options.cursorBlink = false; + } + if (settings['scrollback']) { + const sb = parseInt(settings['scrollback'], 10); + if (!isNaN(sb) && sb >= 0) term.options.scrollback = sb; + } + } catch { /* non-critical — use defaults */ } + })(); // ── Subscribe to terminal font changes ───────────────────────────────── + unsubFont = fontStore.onTermFontChange((family: string, size: number) => { term.options.fontFamily = family || 'JetBrains Mono, Fira Code, monospace'; term.options.fontSize = size; fitAddon.fit(); - if (!readonly) { - appRpc.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {}); + appRpc.request['pty.resize']({ sessionId, cols: term.cols, rows: term.rows }).catch(() => {}); + }); + + // ── Connect to PTY daemon (fire-and-forget from onMount) ─────────────── + + void (async () => { + // Read default_shell and default_cwd from settings if not provided + let effectiveCwd = cwd; + try { + const { settings } = await appRpc.request['settings.getAll']({}); + if (!effectiveCwd && settings['default_cwd']) { + effectiveCwd = settings['default_cwd']; + } + // default_shell is handled by agor-ptyd, not needed in create params + } catch { /* use provided or defaults */ } + + const { cols, rows } = term; + const result = await appRpc.request['pty.create']({ sessionId, cols, rows, cwd: effectiveCwd }); + if (!result?.ok) { + term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`); + term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m'); } + })(); + + // ── Receive output from daemon ───────────────────────────────────────── + + const outputHandler = ({ sessionId: sid, data }: { sessionId: string; data: string }) => { + if (destroyed || sid !== sessionId) return; + term.write(decodeBase64(data)); + }; + appRpc.addMessageListener('pty.output', outputHandler); + + const closedHandler = ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => { + if (destroyed || sid !== sessionId) return; + term.writeln(`\r\n\x1b[90m[Process exited${exitCode !== null ? ` with code ${exitCode}` : ''}]\x1b[0m`); + }; + appRpc.addMessageListener('pty.closed', closedHandler); + + // Fix #5: Store cleanup functions for message listeners + listenerCleanups.push( + () => appRpc.removeMessageListener?.('pty.output', outputHandler), + () => appRpc.removeMessageListener?.('pty.closed', closedHandler), + ); + + // ── Send user input to daemon ────────────────────────────────────────── + + // Feature 5: Max terminal paste chunk (64KB) — truncate with warning + const MAX_PASTE_CHUNK = 64 * 1024; + term.onData((data: string) => { + let payload = data; + if (payload.length > MAX_PASTE_CHUNK) { + console.warn(`[terminal] Paste truncated from ${payload.length} to ${MAX_PASTE_CHUNK} bytes`); + payload = payload.slice(0, MAX_PASTE_CHUNK); + term.writeln('\r\n\x1b[33m[agor] Paste truncated to 64KB\x1b[0m'); + } + appRpc.request['pty.write']({ sessionId, data: payload }).catch((err: unknown) => { + console.error('[pty.write] error:', err); + }); }); - if (readonly) { - // ── Agent preview mode: read bash output from project state ────────── - flushBashLines(); - bashPollTimer = setInterval(flushBashLines, 500); - } else { - // ── Connect to PTY daemon (fire-and-forget from onMount) ───────────── - void (async () => { - let effectiveCwd = cwd; - try { - const { settings } = await appRpc.request['settings.getAll']({}); - if (!effectiveCwd && settings['default_cwd']) { - effectiveCwd = settings['default_cwd']; - } - } catch { /* use provided or defaults */ } + // ── Sync resize events to daemon ─────────────────────────────────────── - const { cols, rows } = term; - const result = await appRpc.request['pty.create']({ sessionId, cols, rows, cwd: effectiveCwd }); - if (!result?.ok) { - term.writeln(`\x1b[31m[agor] Failed to connect to PTY daemon: ${result?.error ?? 'unknown error'}\x1b[0m`); - term.writeln('\x1b[33m[agor] Is agor-ptyd running? Start it with: agor-ptyd\x1b[0m'); - } - })(); - - // ── Receive output from daemon ───────────────────────────────────────── - const outputHandler = ({ sessionId: sid, data }: { sessionId: string; data: string }) => { - if (destroyed || sid !== sessionId) return; - term.write(decodeBase64(data)); - }; - appRpc.addMessageListener('pty.output', outputHandler); - - const closedHandler = ({ sessionId: sid, exitCode }: { sessionId: string; exitCode: number | null }) => { - if (destroyed || sid !== sessionId) return; - term.writeln(`\r\n\x1b[90m[Process exited${exitCode !== null ? ` with code ${exitCode}` : ''}]\x1b[0m`); - }; - appRpc.addMessageListener('pty.closed', closedHandler); - - listenerCleanups.push( - () => appRpc.removeMessageListener?.('pty.output', outputHandler), - () => appRpc.removeMessageListener?.('pty.closed', closedHandler), - ); - - // ── Send user input to daemon ────────────────────────────────────────── - const MAX_PASTE_CHUNK = 64 * 1024; - term.onData((data: string) => { - let payload = data; - if (payload.length > MAX_PASTE_CHUNK) { - console.warn(`[terminal] Paste truncated from ${payload.length} to ${MAX_PASTE_CHUNK} bytes`); - payload = payload.slice(0, MAX_PASTE_CHUNK); - term.writeln('\r\n\x1b[33m[agor] Paste truncated to 64KB\x1b[0m'); - } - appRpc.request['pty.write']({ sessionId, data: payload }).catch((err: unknown) => { - console.error('[pty.write] error:', err); - }); - }); - - // ── Sync resize events to daemon ─────────────────────────────────────── - term.onResize(({ cols: c, rows: r }) => { - appRpc.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {}); - }); - } - - // ── ResizeObserver: re-fit on container resize + visibility ──────────── - ro = new ResizeObserver(() => { - requestAnimationFrame(() => { fitAddon.fit(); }); + term.onResize(({ cols: c, rows: r }) => { + appRpc.request['pty.resize']({ sessionId, cols: c, rows: r }).catch(() => {}); }); + + ro = new ResizeObserver(() => { fitAddon.fit(); }); ro.observe(termEl); }); @@ -195,14 +178,12 @@ destroyed = true; unsubFont?.(); ro?.disconnect(); - if (bashPollTimer) clearInterval(bashPollTimer); + // Fix #5: Clean up all message listeners to prevent leaks for (const cleanup of listenerCleanups) { try { cleanup(); } catch { /* ignore */ } } listenerCleanups = []; - if (!readonly) { - appRpc.request['pty.close']({ sessionId }).catch(() => {}); - } + appRpc.request['pty.close']({ sessionId }).catch(() => {}); term?.dispose(); }); diff --git a/ui-electrobun/src/mainview/TerminalTabs.svelte b/ui-electrobun/src/mainview/TerminalTabs.svelte index 132191d..7eec51f 100644 --- a/ui-electrobun/src/mainview/TerminalTabs.svelte +++ b/ui-electrobun/src/mainview/TerminalTabs.svelte @@ -40,23 +40,10 @@ blurTerminal(); appState.project.terminals.toggleExpanded(projectId); } - - function togglePreview() { - blurTerminal(); - appState.project.terminals.toggleAgentPreview(projectId); - } - - function hasPreview(): boolean { - return getTerminals().tabs.some(t => t.kind === 'agentPreview'); - } - -
+ +
- -
- No terminals — click + to add one -
{#each getTerminals().tabs as tab (tab.id)} {#if getTerminals().mounted.has(tab.id)}
- +
{/if} {/each} @@ -142,6 +108,7 @@ .term-wrapper { display: flex; flex-direction: column; + flex: 1; min-height: 0; } @@ -202,7 +169,6 @@ } .term-tab:hover { color: var(--ctp-text); } .term-tab.active { color: var(--ctp-text); border-bottom-color: var(--accent); } - .term-tab.preview { font-style: italic; } .tab-label { pointer-events: none; } .tab-close { @@ -224,7 +190,7 @@ } .tab-close:hover { background: var(--ctp-surface1); color: var(--ctp-red); } - .tab-add, .tab-eye { + .tab-add { align-self: center; width: 1.25rem; height: 1.25rem; @@ -240,9 +206,7 @@ justify-content: center; margin-left: 0.125rem; } - .tab-add:hover, .tab-eye:hover { background: var(--ctp-surface0); color: var(--ctp-text); } - .tab-eye svg { width: 0.75rem; height: 0.75rem; } - .tab-eye.active { color: var(--ctp-blue); border-color: var(--ctp-blue); } + .tab-add:hover { background: var(--ctp-surface0); color: var(--ctp-text); } /* Terminal panes — fill remaining space below tab bar */ .term-panes { @@ -256,14 +220,4 @@ inset: 0; flex-direction: column; } - - .term-empty { - position: absolute; - inset: 0; - align-items: center; - justify-content: center; - color: var(--ctp-overlay0); - font-size: 0.8125rem; - font-style: italic; - } diff --git a/ui-electrobun/src/mainview/agent-store.svelte.ts b/ui-electrobun/src/mainview/agent-store.svelte.ts index 8c963d7..9697913 100644 --- a/ui-electrobun/src/mainview/agent-store.svelte.ts +++ b/ui-electrobun/src/mainview/agent-store.svelte.ts @@ -47,21 +47,6 @@ 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) ──────────────────────────────────────── @@ -127,78 +112,6 @@ function validateExtraEnv(env: Record | undefined): Record sessionId for lookup const projectSessionMap = new Map(); -// Pending resume: when the user selects a session to resume, we store the -// resume options here. The next startAgent() call reads and clears them. -// This avoids sending an empty prompt — the user types their message first. -interface PendingResume { - mode: 'continue' | 'resume'; - sdkSessionId?: string; -} -const pendingResumes = new Map(); - -/** Set a pending resume for a project — next startAgent will use these options. */ -export function setPendingResume(projectId: string, mode: 'continue' | 'resume', sdkSessionId?: string): void { - pendingResumes.set(projectId, { mode, sdkSessionId }); - bump(); -} - -/** Check if a project has a pending resume. */ -export function getPendingResume(projectId: string): PendingResume | undefined { - void _v; - return pendingResumes.get(projectId); -} - -/** Clear pending resume (called after startAgent consumes it). */ -export function clearPendingResume(projectId: string): void { - pendingResumes.delete(projectId); - bump(); -} - -/** - * Load full conversation history from a Claude JSONL session file into the store. - * Called when user selects a session from the picker — shows the conversation - * BEFORE the user sends their next prompt. - */ -export async function loadSessionHistory(projectId: string, sdkSessionId: string, cwd: string): Promise { - try { - const { messages } = await appRpc.request['session.loadMessages']({ cwd, sdkSessionId }) as { - messages: Array<{ id: string; role: string; content: string; timestamp: number; model?: string; toolName?: string }>; - }; - if (!messages || messages.length === 0) return; - - // Create a display-only session to show the history - const displaySessionId = `${projectId}-history-${Date.now()}`; - const converted: AgentMessage[] = messages.map((m, i) => ({ - id: m.id || `hist-${i}`, - seqId: i, - role: m.role === 'user' ? 'user' as const - : m.role === 'assistant' ? 'assistant' as const - : m.role === 'tool_call' ? 'tool_call' as const - : m.role === 'tool_result' ? 'tool_result' as const - : 'system' as const, - content: m.content, - timestamp: m.timestamp, - toolName: m.toolName, - })); - - sessions[displaySessionId] = { - sessionId: displaySessionId, - projectId, - provider: 'claude', - status: 'done', - messages: converted, - costUsd: 0, - inputTokens: 0, - outputTokens: 0, - model: messages.find(m => m.model)?.model || 'unknown', - }; - projectSessionMap.set(projectId, displaySessionId); - bump(); - } catch (err) { - console.error('[agent-store] loadSessionHistory error:', err); - } -} - // Map sessionId -> reactive session state let sessions = $state>({}); @@ -384,8 +297,6 @@ 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); } }); @@ -591,22 +502,8 @@ async function _startAgentInner( prompt: string, options: StartOptions, ): Promise<{ ok: boolean; error?: string }> { - // Check for pending resume (user selected a session in the picker) - const pending = pendingResumes.get(projectId); - if (pending) { - pendingResumes.delete(projectId); - if (!options.resumeMode) { - options.resumeMode = pending.mode; - options.resumeSessionId = pending.sdkSessionId; - } - } - - const isResume = options.resumeMode === 'continue' || options.resumeMode === 'resume'; - - // If resuming, keep existing session messages; if new, clear old session - if (!isResume) { - clearSession(projectId); - } + // If there's an existing done/error session for this project, clear it first + clearSession(projectId); const sessionId = `${projectId}-${Date.now()}`; @@ -636,38 +533,25 @@ async function _startAgentInner( } } catch { /* use provided or defaults */ } - // Create reactive session state — carry forward messages if resuming - const existingSessionId = projectSessionMap.get(projectId); - const existingMessages = (isResume && existingSessionId && sessions[existingSessionId]) - ? [...sessions[existingSessionId].messages] - : []; - - // Add the new user prompt to messages - const newUserMsg = { - id: `${sessionId}-user-0`, - seqId: nextSeqId(sessionId), - role: 'user' as const, - content: prompt, - timestamp: Date.now(), - }; - + // Create reactive session state sessions[sessionId] = { sessionId, projectId, provider, status: 'running', - messages: [...existingMessages, newUserMsg], + messages: [{ + id: `${sessionId}-user-0`, + seqId: nextSeqId(sessionId), + role: 'user', + content: prompt, + timestamp: Date.now(), + }], costUsd: 0, inputTokens: 0, outputTokens: 0, model: defaultModel ?? 'claude-opus-4-5', }; - // Clean up the old session entry if resuming (we moved messages to the new one) - if (isResume && existingSessionId && existingSessionId !== sessionId) { - delete sessions[existingSessionId]; - } - projectSessionMap.set(projectId, sessionId); bump(); // Force re-render — new session created resetStallTimer(sessionId, projectId); @@ -683,8 +567,6 @@ async function _startAgentInner( permissionMode: permissionMode, claudeConfigDir: options.claudeConfigDir, extraEnv: validateExtraEnv(options.extraEnv), - resumeMode: options.resumeMode, - resumeSessionId: options.resumeSessionId, }); if (!result.ok) { @@ -946,75 +828,6 @@ 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/mainview/app-state.svelte.ts b/ui-electrobun/src/mainview/app-state.svelte.ts index 69be688..595b3a4 100644 --- a/ui-electrobun/src/mainview/app-state.svelte.ts +++ b/ui-electrobun/src/mainview/app-state.svelte.ts @@ -52,7 +52,6 @@ import { getProjectState, getActiveTab, isTabActivated, setActiveTab, addTerminalTab, closeTerminalTab, activateTerminalTab, toggleTerminalExpanded, - toggleAgentPreview, appendBashOutput, setFileState, setFileMulti, nextFileRequestToken, getFileRequestToken, setCommsState, setCommsMulti, setTaskState, setTaskMulti, nextTaskPollToken, getTaskPollToken, @@ -137,8 +136,6 @@ export const appState = { closeTab: closeTerminalTab, activateTab: activateTerminalTab, toggleExpanded: toggleTerminalExpanded, - toggleAgentPreview: toggleAgentPreview, - appendBashOutput: appendBashOutput, }, files: { diff --git a/ui-electrobun/src/mainview/project-state.svelte.ts b/ui-electrobun/src/mainview/project-state.svelte.ts index 355c7ac..755c664 100644 --- a/ui-electrobun/src/mainview/project-state.svelte.ts +++ b/ui-electrobun/src/mainview/project-state.svelte.ts @@ -26,19 +26,15 @@ let _version = $state(0); // ── Factory ─────────────────────────────────────────────────────────────── -const MAX_BASH_OUTPUT_LINES = 500; - function createProjectState(projectId: string): ProjectState { const firstTabId = `${projectId}-t1`; return { terminals: { - tabs: [{ kind: 'pty', id: firstTabId, title: 'shell 1' }], + tabs: [{ id: firstTabId, title: 'shell 1' }], activeTabId: firstTabId, expanded: true, nextId: 2, mounted: new Set([firstTabId]), - bashOutputLines: [], - bashLinesVersion: 0, }, files: { childrenCache: new Map(), @@ -131,7 +127,7 @@ export function setActiveTab(projectId: string, tab: ProjectTab): void { export function addTerminalTab(projectId: string): void { const t = ensureProject(projectId).terminals; const id = `${projectId}-t${t.nextId}`; - t.tabs = [...t.tabs, { kind: 'pty', id, title: `shell ${t.nextId}` }]; + t.tabs = [...t.tabs, { id, title: `shell ${t.nextId}` }]; t.nextId++; t.activeTabId = id; t.mounted.add(id); @@ -164,38 +160,6 @@ export function toggleTerminalExpanded(projectId: string): void { bump(); } -export function toggleAgentPreview(projectId: string): void { - const t = ensureProject(projectId).terminals; - const existing = t.tabs.find(tab => tab.kind === 'agentPreview'); - if (existing) { - // Remove the preview tab - t.tabs = t.tabs.filter(tab => tab.kind !== 'agentPreview'); - t.mounted.delete(existing.id); - if (t.activeTabId === existing.id) { - const next = t.tabs[0]; - t.activeTabId = next?.id ?? ''; - } - } else { - // Add a preview tab - const id = `${projectId}-preview`; - t.tabs = [...t.tabs, { kind: 'agentPreview', id, title: 'Agent Preview' }]; - t.activeTabId = id; - t.mounted.add(id); - if (!t.expanded) t.expanded = true; - } - bump(); -} - -export function appendBashOutput(projectId: string, line: string): void { - const t = ensureProject(projectId).terminals; - t.bashOutputLines.push(line); - if (t.bashOutputLines.length > MAX_BASH_OUTPUT_LINES) { - t.bashOutputLines.splice(0, t.bashOutputLines.length - MAX_BASH_OUTPUT_LINES); - } - t.bashLinesVersion++; - bump(); -} - // ── File actions ────────────────────────────────────────────────────────── export function setFileState( diff --git a/ui-electrobun/src/mainview/project-state.types.ts b/ui-electrobun/src/mainview/project-state.types.ts index 4dd8cc9..d45fa74 100644 --- a/ui-electrobun/src/mainview/project-state.types.ts +++ b/ui-electrobun/src/mainview/project-state.types.ts @@ -10,9 +10,10 @@ export type { ProjectTab }; // ── Terminal ────────────────────────────────────────────────────────────── -export type TermTab = - | { kind: 'pty'; id: string; title: string } - | { kind: 'agentPreview'; id: string; title: string }; +export interface TermTab { + id: string; + title: string; +} export interface TerminalState { tabs: TermTab[]; @@ -20,10 +21,6 @@ export interface TerminalState { expanded: boolean; nextId: number; mounted: Set; - /** Ring buffer of bash tool_call output lines for agent preview. */ - bashOutputLines: string[]; - /** Bumped on every append — drives polling in readonly terminal. */ - bashLinesVersion: number; } // ── Files ───────────────────────────────────────────────────────────────── diff --git a/ui-electrobun/src/mainview/workspace-store.svelte.ts b/ui-electrobun/src/mainview/workspace-store.svelte.ts index 4800660..242231d 100644 --- a/ui-electrobun/src/mainview/workspace-store.svelte.ts +++ b/ui-electrobun/src/mainview/workspace-store.svelte.ts @@ -76,11 +76,6 @@ export function getActiveGroup(): Group { export function getFilteredProjects(): Project[] { return projects.filter(p => (p.groupId ?? 'dev') === activeGroupId); } - -/** Get projects for a specific group (used by GroupStatusDots). */ -export function getProjectsForGroup(groupId: string): Project[] { - return projects.filter(p => (p.groupId ?? 'dev') === groupId); -} export function getTotalCostDerived(): number { return projects.reduce((s, p) => s + p.costUsd, 0); } diff --git a/ui-electrobun/src/shared/pty-rpc-schema.ts b/ui-electrobun/src/shared/pty-rpc-schema.ts index c438ac5..297b2f4 100644 --- a/ui-electrobun/src/shared/pty-rpc-schema.ts +++ b/ui-electrobun/src/shared/pty-rpc-schema.ts @@ -389,10 +389,6 @@ 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 }; }; @@ -481,36 +477,6 @@ 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; - }>; - }; - }; - - /** Load full conversation messages from a Claude JSONL session file. */ - "session.loadMessages": { - params: { cwd: string; sdkSessionId: string }; - response: { - messages: Array<{ - id: string; - role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'system'; - content: string; - timestamp: number; - model?: string; - toolName?: string; - }>; - }; - }; - // ── btmsg RPC ────────────────────────────────────────────────────────── /** Register an agent in btmsg. */