From 431d41ccc6617feb0c9a186e5bf9e58a5dcc3ef3 Mon Sep 17 00:00:00 2001 From: Ry J Date: Tue, 21 Jul 2026 17:03:21 +1000 Subject: [PATCH 1/4] fix: split view focus follows highlight, remember focused pane --- src/renderer/components/SplitView.tsx | 23 +++++++++++++++---- src/renderer/store/index.ts | 33 +++++++++++++++++++++++---- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/SplitView.tsx b/src/renderer/components/SplitView.tsx index 5c9786b..1191f32 100644 --- a/src/renderer/components/SplitView.tsx +++ b/src/renderer/components/SplitView.tsx @@ -68,22 +68,34 @@ export function SplitView({ onTitleChange }: SplitViewProps): JSX.Element | null const setFocusedSessionId = useStore((s) => s.setFocusedSessionId) const setCmdHeld = useStore((s) => s.setCmdHeld) const setGroupingSelection = useStore((s) => s.setGroupingSelection) + const setSplitGroupLastFocus = useStore((s) => s.setSplitGroupLastFocus) const markSessionSeen = useStore((s) => s.markSessionSeen) const group = splitGroups.find((g) => g.id === activeSplitGroupId) const N = group?.orderedSessionIds.length ?? 0 - // Default focus to slot 0 when entering the view. + // Default focus to slot 0 when entering the view with no remembered pane + // (enterSplitGroup restores the group's last-focused member when it can). + // Also re-homes focus if the focused pane leaves the group mid-session. useEffect(() => { if (!group) return if (focusedSessionId && group.orderedSessionIds.includes(focusedSessionId)) return const first = group.orderedSessionIds[0] - if (first) { - setFocusedSessionId(first) - requestAnimationFrame(() => focusTerminal(first)) - } + if (first) setFocusedSessionId(first) }, [group, focusedSessionId, setFocusedSessionId]) + // Keyboard focus follows the highlight. Whenever the focused pane changes, + // give its terminal real focus and remember it for the group's next entry. + // Split terminals mount with autoFocus off, so a pane finishing its initial + // fit can't steal focus from the highlighted one (it used to land on the + // last pane to fit while the highlight sat on slot 0). + useEffect(() => { + if (!group || !focusedSessionId) return + if (!group.orderedSessionIds.includes(focusedSessionId)) return + setSplitGroupLastFocus(group.id, focusedSessionId) + requestAnimationFrame(() => focusTerminal(focusedSessionId)) + }, [group, focusedSessionId, setSplitGroupLastFocus]) + // Cmd-hold-still detection — opens the reshape modal after 1.5s of held Cmd // with no other key. Mirror of the graph-view flow but pre-populates the // selection with the current group's members. @@ -378,6 +390,7 @@ function SplitPanel({ session, slotIndex, isFocused, onFocus, onTitleChange }: S key={`split-${session.id}`} sessionId={session.id} visible={true} + autoFocus={false} onTitleChange={(title) => onTitleChange(session.id, title)} /> diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index cf4e53d..bb7094e 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -383,6 +383,10 @@ export interface AppState { dissolveSplitGroup: (groupId: string) => void /** Switch to split view on the given group. */ enterSplitGroup: (groupId: string) => void + /** Last-focused pane per group — restored by `enterSplitGroup` on re-entry. + * In-memory only; not persisted across app restarts. */ + splitGroupLastFocus: Record + setSplitGroupLastFocus: (groupId: string, sessionId: string) => void /** Replace the group's layout tree. `orderedSessionIds` is recomputed from leaves. */ setSplitGroupLayout: (groupId: string, layout: Layout) => void /** Append a new session into the active group by splitting the largest pane. */ @@ -656,12 +660,31 @@ export const useStore = create((set, get) => ({ return id }, dissolveSplitGroup: (groupId) => - set((state) => ({ - splitGroups: state.splitGroups.filter((g) => g.id !== groupId), - activeSplitGroupId: state.activeSplitGroupId === groupId ? null : state.activeSplitGroupId, - })), + set((state) => { + const { [groupId]: _dropped, ...lastFocus } = state.splitGroupLastFocus + return { + splitGroups: state.splitGroups.filter((g) => g.id !== groupId), + activeSplitGroupId: state.activeSplitGroupId === groupId ? null : state.activeSplitGroupId, + splitGroupLastFocus: lastFocus, + } + }), enterSplitGroup: (groupId) => - set({ activeSplitGroupId: groupId, viewMode: 'split', focusedSessionId: null }), + set((state) => { + // Restore the pane the user had focused last time they were in this + // group; fall back to null so SplitView defaults to slot 0. + const group = state.splitGroups.find((g) => g.id === groupId) + const remembered = state.splitGroupLastFocus[groupId] + const focusedSessionId = + remembered && group?.orderedSessionIds.includes(remembered) ? remembered : null + return { activeSplitGroupId: groupId, viewMode: 'split', focusedSessionId } + }), + splitGroupLastFocus: {}, + setSplitGroupLastFocus: (groupId, sessionId) => + set((state) => + state.splitGroupLastFocus[groupId] === sessionId + ? {} + : { splitGroupLastFocus: { ...state.splitGroupLastFocus, [groupId]: sessionId } } + ), setSplitGroupLayout: (groupId, layout) => set((state) => ({ splitGroups: state.splitGroups.map((g) => From 445375e078525c0776901e90f681dfbfc080836e Mon Sep 17 00:00:00 2001 From: Ry J Date: Fri, 24 Jul 2026 10:59:30 +1000 Subject: [PATCH 2/4] fix: stop onnxruntime allocator crashes during embedding inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three app crashes on 2026-07-24 shared one stack: SIGTRAP malloc abort under BFCArena::Extend while embedding note chunks on the main thread. The BFC arena doubles on every extension and never shrinks (1.6GB RSS observed for the 25MB bge-small model), and unbounded batches — a 26KB note embeds 21 chunks in one call — pushed its next multi-GB aligned allocation into a fatal abort inside the Electron main process. Disable the arena (plain malloc/free per tensor, memory returns to the OS) and embed in batches of 4 to bound peak activation memory. Verified in an isolated repro: 1.6GB → ~200MB stable, 10 full-note reindexes in 6.3s. Co-Authored-By: Claude Fable 5 --- src/main/memory/embeddings.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/main/memory/embeddings.ts b/src/main/memory/embeddings.ts index 3dd5031..855eca6 100644 --- a/src/main/memory/embeddings.ts +++ b/src/main/memory/embeddings.ts @@ -150,7 +150,13 @@ async function getEmbedder(): Promise { const embedder = await pipeline('feature-extraction', modelId, { // ONNX quantized weights → fast CPU inference, ~25MB on disk - dtype: 'q8' as any + dtype: 'q8' as any, + // Disable onnxruntime's BFC memory arena. The arena doubles on every + // extension and never shrinks (observed 1.6GB RSS for this 25MB model), + // and its huge aligned allocations fatally abort malloc inside the + // Electron main process — crashed the whole app 3× on 2026-07-24. + // Plain malloc/free per tensor is slightly slower but bounded. + session_options: { enableCpuMemArena: false } as any }) handle!.embedder = embedder return embedder @@ -165,12 +171,25 @@ async function getEmbedder(): Promise { return handle.embedderPromise } +// Peak inference memory scales with batch size — a 20+ chunk note embedded in +// one call needs multi-GB of activation buffers, which is what pushed the +// allocator over the edge. Small batches keep the peak bounded at negligible +// throughput cost (~0.6s per 21-chunk note). +const EMBED_BATCH_SIZE = 4 + async function embedTexts(texts: string[]): Promise { const embedder = await getEmbedder() - const out = await embedder(texts, { pooling: 'mean', normalize: true }) - // transformers.js returns a Tensor with .tolist(): number[][] - const list: number[][] = out.tolist() - return list.map((row) => Float32Array.from(row)) + const result: Float32Array[] = [] + for (let i = 0; i < texts.length; i += EMBED_BATCH_SIZE) { + const out = await embedder(texts.slice(i, i + EMBED_BATCH_SIZE), { + pooling: 'mean', + normalize: true + }) + // transformers.js returns a Tensor with .tolist(): number[][] + const list: number[][] = out.tolist() + for (const row of list) result.push(Float32Array.from(row)) + } + return result } // ─── Chunking ─────────────────────────────────────────────────────────────── From 03f967b5927c70375d7f1d7250098a885200da21 Mon Sep 17 00:00:00 2001 From: Ry J Date: Fri, 24 Jul 2026 10:59:30 +1000 Subject: [PATCH 3/4] feat: journal resumable sessions continuously for crash resilience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sessions.json was only written on graceful quit, so a hard crash lost every session with nothing to restore. Journal the same resumable snapshot during runtime — every 15s plus immediately when a session gets its Claude id — so a relaunch after a crash offers the normal restore prompt, losing at most ~15s of session-list changes. The journal skips content-identical writes and never overwrites a previous run's saved sessions with an empty list before this run has resumable sessions of its own, keeping the pending restore prompt safe right after a crash relaunch. Co-Authored-By: Claude Fable 5 --- docs/wiki/session-persistence.md | 12 ++++-- src/main/index.ts | 71 ++++++++++++++++++++++++-------- 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/docs/wiki/session-persistence.md b/docs/wiki/session-persistence.md index 389cbaf..132e0b5 100644 --- a/docs/wiki/session-persistence.md +++ b/docs/wiki/session-persistence.md @@ -1,7 +1,7 @@ --- slug: session-persistence title: Session Persistence & Restore -summary: Sessions are saved on quit and offered for restore on next launch; renderer crashes reconnect silently. +summary: Sessions are journaled continuously and offered for restore on next launch — surviving both clean quits and hard crashes; renderer crashes reconnect silently. related: [terminal-management, split-view, hotkeys-settings] --- @@ -9,11 +9,13 @@ related: [terminal-management, split-view, hotkeys-settings] ## What it is -Session Manager saves your resumable Claude sessions to disk when the app quits and offers to restore them on the next launch. Restored sessions resume the *same Claude conversation* (`claude --resume `) in their original project directory. Split-view groups are persisted alongside and reassembled from whichever members survive. +Session Manager saves your resumable Claude sessions to disk and offers to restore them on the next launch. Restored sessions resume the *same Claude conversation* (`claude --resume `) in their original project directory. Split-view groups are persisted alongside and reassembled from whichever members survive. + +The session list is **journaled continuously while the app runs** (every ~15 seconds, plus immediately when a session gets its Claude id), not just at quit — so restore works after a hard crash too, losing at most ~15 seconds of session-list changes. ## How to use it -1. Quit the app normally — resumable sessions are written to `sessions.json` (and groups to `split-groups.json`) under the app data directory (`~/Library/Application Support/session-manager/` on macOS). +1. Resumable sessions are written to `sessions.json` (and groups to `split-groups.json`) under the app data directory (`~/Library/Application Support/session-manager/` on macOS) — continuously during the run and finally at quit. 2. On next launch, a **restore modal** lists the saved sessions; confirm to respawn them with `claude --resume`. 3. Optional: Settings → *Auto-mode on app restart for restored sessions* starts restored sessions in auto (permission-skipping) mode. @@ -34,6 +36,7 @@ Fresh sessions you never typed into, plain shells, and exited sessions are not s These are different paths: - **Renderer crash** (GPU process dies): the window auto-reloads and silently reconnects to the still-running PTY processes. No modal, no conversation loss — the underlying processes never died. +- **Main-process crash** (whole app dies, e.g. a native-module fault): PTYs are gone, but the continuously-journaled `sessions.json` survives — the next launch shows the restore modal just like after a quit. - **App quit / OS restart**: PTYs are gone; the restore modal respawns fresh PTYs that resume the saved Claude conversations. ## Use cases @@ -44,7 +47,8 @@ These are different paths: ## Gotchas & tips -- **Pipeline and scheduled-task sessions are excluded** from the quit-save — their resumability is managed by their own panels (run history / task tree), not the restore modal. +- **Pipeline and scheduled-task sessions are excluded** from the save — their resumability is managed by their own panels (run history / task tree), not the restore modal. +- The journal never overwrites a previous run's saved sessions with an empty list before this run has any resumable sessions of its own — so the pending restore prompt after a crash relaunch is safe. - Restored sessions resume the *conversation*, not the terminal scrollback — the on-screen history starts fresh. - A split group whose members were partially unsaveable restores as a smaller group (dropped members are simply omitted); a group reduced to one session dissolves into a single. - Writes are atomic (temp file + rename), so a crash during save can't corrupt state files. diff --git a/src/main/index.ts b/src/main/index.ts index 8cd499e..74b25ba 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,8 +5,8 @@ import { pathToFileURL } from 'url' import { getArtifactById, sweepOrphanedImages } from './canvas-store' import { ALLOWED_IMAGE_EXTS } from './canvas-types' import { registerIpcHandlers } from './ipc' -import { getResumableSessions, killAllSessions } from './pty-manager' -import { saveSessions } from './session-store' +import { getResumableSessions, killAllSessions, onClaudeSessionIdChange } from './pty-manager' +import { saveSessions, type SavedSession } from './session-store' import { getPipelineClaudeSessionIds } from './pipeline-store' import { getScheduleClaudeSessionIds } from './schedule-store' import { startHookServer, stopHookServer } from './hook-server' @@ -51,28 +51,59 @@ const isDev = !app.isPackaged let mainWindow: BrowserWindow | null = null let didSave = false -function saveAndCleanup(): void { - if (didSave) return - didSave = true - - // Exclude agentic-pipeline and scheduled-task sessions — they shouldn't be offered - // in the generic restore prompt (which would re-add them as ordinary graph nodes). - // Pipeline resume is on-demand via the board (pipeline.json); scheduled runs resume - // on-demand via the Scheduled Tasks panel (schedules.json). +// Exclude agentic-pipeline and scheduled-task sessions — they shouldn't be offered +// in the generic restore prompt (which would re-add them as ordinary graph nodes). +// Pipeline resume is on-demand via the board (pipeline.json); scheduled runs resume +// on-demand via the Scheduled Tasks panel (schedules.json). +function collectResumableSessions(): SavedSession[] { const pipelineIds = getPipelineClaudeSessionIds() const scheduleIds = getScheduleClaudeSessionIds() - const resumable = getResumableSessions().filter( - (s) => !s.claudeSessionId || (!pipelineIds.has(s.claudeSessionId) && !scheduleIds.has(s.claudeSessionId)) - ) - console.log('[main] saving resumable sessions:', resumable.length, resumable.map(s => s.claudeSessionId)) - saveSessions( - resumable.map((s) => ({ + return getResumableSessions() + .filter( + (s) => !s.claudeSessionId || (!pipelineIds.has(s.claudeSessionId) && !scheduleIds.has(s.claudeSessionId)) + ) + .map((s) => ({ claudeSessionId: s.claudeSessionId, projectPath: s.projectPath, terminalTitle: s.terminalTitle, savedAt: Date.now() })) - ) +} + +// Crash-resilient session journal: sessions.json is written on graceful quit +// (saveAndCleanup), but a hard crash — e.g. a native-module fault — never +// reaches that path, so every session was lost. Journal the same snapshot +// during runtime so a relaunch after a crash still offers the restore prompt. +let journalTimer: NodeJS.Timeout | null = null +let journalWroteNonEmpty = false +let lastJournal = '' + +function journalSessions(): void { + if (didSave) return + const resumable = collectResumableSessions() + // Never clobber a previous run's saved sessions with an empty list before + // this run has produced any resumable sessions of its own — e.g. while the + // restore prompt is still open right after a crash relaunch. + if (resumable.length === 0 && !journalWroteNonEmpty) return + // savedAt changes every call; compare content without it to skip no-op writes. + const serialized = JSON.stringify(resumable.map(({ savedAt: _, ...rest }) => rest)) + if (serialized === lastJournal) return + lastJournal = serialized + if (resumable.length > 0) journalWroteNonEmpty = true + saveSessions(resumable) +} + +function saveAndCleanup(): void { + if (didSave) return + didSave = true + + if (journalTimer) { + clearInterval(journalTimer) + journalTimer = null + } + const resumable = collectResumableSessions() + console.log('[main] saving resumable sessions:', resumable.length, resumable.map(s => s.claudeSessionId)) + saveSessions(resumable) killAllSessions() cleanupAllSkillCommands() stopScheduler() @@ -298,6 +329,12 @@ app.whenReady().then(async () => { createWindow() startScheduler() + // Session journal: interval catches title/activity changes; the + // claudeSessionId listener captures new sessions as soon as they get an id, + // so a crash loses at most ~15s of state. + journalTimer = setInterval(journalSessions, 15_000) + onClaudeSessionIdChange(() => journalSessions()) + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow() From 1b1da7e86d4a2440c9f103f78925029c87d27fe3 Mon Sep 17 00:00:00 2001 From: Ry J Date: Fri, 24 Jul 2026 11:01:20 +1000 Subject: [PATCH 4/4] =?UTF-8?q?release:=20v0.2.3=20=E2=80=94=20crash=20res?= =?UTF-8?q?ilience=20&=20split-view=20focus=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 567850e..53956b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "session-manager", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "session-manager", - "version": "0.2.2", + "version": "0.2.3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 672b5b0..73a87b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "session-manager", - "version": "0.2.2", + "version": "0.2.3", "description": "Desktop app for managing multiple Claude Code sessions", "author": "Ry J", "license": "MIT",