diff --git a/.context/DECISIONS.md b/.context/DECISIONS.md
index 1a6db1f..318e4cb 100644
--- a/.context/DECISIONS.md
+++ b/.context/DECISIONS.md
@@ -3,6 +3,7 @@
| Date | Decision |
|------|--------|
+| 2026-02-15 | Track pane dimensions in paneSizes Map |
| 2026-02-14 | Server-side output buffer in DO for reconnect replay |
| 2026-02-14 | Render all tabs simultaneously with CSS visibility:hidden on inactive tabs |
| 2026-02-14 | DO authoritative over layout with per-tab focus |
@@ -23,6 +24,20 @@ For lightweight decisions, a single statement suffices:
For significant decisions:
+## [2026-02-15-180300] Track pane dimensions in paneSizes Map
+
+**Status**: Accepted
+
+**Context**: reconnectTerminal needed actual cols/rows but layout tree does not store them and handlePaneResize only forwards to terminal WS without persisting
+
+**Decision**: Track pane dimensions in paneSizes Map
+
+**Rationale**: In-memory Map is simplest — lost on hibernation but so are terminal WSes. Persisting to storage is unnecessary since reconnect already creates fresh PTYs. Alternative: send dimensions from frontend on reconnect — adds protocol complexity for no gain.
+
+**Consequences**: paneSizes must be updated at all 4 createTerminalWs call sites. Falls back to 80x24 for panes created before tracking or after hibernation.
+
+---
+
## [2026-02-14-233233] Server-side output buffer in DO for reconnect replay
**Status**: Accepted
diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md
index 400b032..7401c47 100644
--- a/.context/LEARNINGS.md
+++ b/.context/LEARNINGS.md
@@ -3,6 +3,7 @@
| Date | Learning |
|------|--------|
+| 2026-02-15 | paneSizes must be tracked at every terminal creation site |
| 2026-02-14 | DO non-hibernation reconnect needs server-side replay |
| 2026-02-14 | useMux() object identity churn breaks terminal lifecycle |
| 2026-02-14 | ghostty-web container element must have no children |
@@ -12,6 +13,16 @@
| 2026-02-14 | Zensical explicit nav is full override |
+## [2026-02-15-180258] paneSizes must be tracked at every terminal creation site
+
+**Context**: Code review caught that handleSessionCreate and handleTabCreate call createTerminalWs without saving to paneSizes, so reconnect falls back to 80x24
+
+**Lesson**: Any code path that calls createTerminalWs should also call paneSizes.set(ptyId, {cols, rows}). There are 4 sites: handleSessionCreate, handleTabCreate, handlePaneSplit, and reconnectTerminal (which looks up existing size).
+
+**Application**: When adding new terminal creation paths, always pair with paneSizes.set. Grep for createTerminalWs to find all call sites.
+
+---
+
## [2026-02-14-233231] DO non-hibernation reconnect needs server-side replay
**Context**: Page reload doesn't hibernate the DO. reconnectAllTerminals() checks this.terminals.has() and skips. Frontend gets blank terminals because no ring buffer replay happens from CF Sandbox.
diff --git a/.context/TASKS.md b/.context/TASKS.md
index d572277..1da6135 100644
--- a/.context/TASKS.md
+++ b/.context/TASKS.md
@@ -22,7 +22,9 @@ STRUCTURE RULES (see CONSTITUTION.md):
### Phase 1: [Name] `#priority:high`
- [ ] Clean up @kampus/wormhole package (old, no longer imported by live code) #added:2026-02-14-222749
-- [ ] Handle stale sessions on reconnect — detect dead terminal WSes and show session expired state instead of blank pane #added:2026-02-14-222749
+- [ ] Add reconnect retry limit — track per-ptyId attempt count, fail permanently after N attempts to prevent unbounded reconnect loops when sandbox is permanently dead #priority:medium #added:2026-02-15
+
+- [x] Handle stale sessions on reconnect — detect dead terminal WSes and show session expired state instead of blank pane #added:2026-02-14-222749 #done:2026-02-15
- [x] Implement tab + focus wrapper on top of @usirin/layout-tree #priority:medium #added:2026-02-14-200550 #done:2026-02-14
diff --git a/.gitignore b/.gitignore
index 5898ea5..a5d8295 100644
--- a/.gitignore
+++ b/.gitignore
@@ -202,3 +202,6 @@ ralph*.sh
# Zensical build output
site/
+
+# Git worktrees
+.worktrees/
diff --git a/apps/kamp-us/src/wormhole/PaneLayout.tsx b/apps/kamp-us/src/wormhole/PaneLayout.tsx
index 2ee701c..aa76b9b 100644
--- a/apps/kamp-us/src/wormhole/PaneLayout.tsx
+++ b/apps/kamp-us/src/wormhole/PaneLayout.tsx
@@ -27,7 +27,7 @@ export function PaneLayout() {
}}
>
- {renderChildren(tree.root, [], tab.focus, state.channels)}
+ {renderChildren(tree.root, [], tab.focus, state.channels, state.paneConnected)}
);
@@ -41,22 +41,25 @@ function renderChildren(
path: LT.StackPath,
focus: number[],
channels: Record,
+ paneConnected: Record,
) {
return stack.children.map((child, i) => {
const childPath = [...path, i];
return (
{i > 0 && (
-
- )}
+
+ )}
{child.tag === "window" ? (
- renderWindow(child as LT.Window, childPath, focus, channels)
+ renderWindow(child as LT.Window, childPath, focus, channels, paneConnected)
) : (
- {renderChildren(child as LT.Stack, childPath, focus, channels)}
+ {renderChildren(child as LT.Stack, childPath, focus, channels, paneConnected)}
)}
@@ -70,17 +73,20 @@ function renderWindow(
path: LT.StackPath,
focus: number[],
channels: Record,
+ paneConnected: Record,
) {
const channel = channels[window.key];
if (channel === undefined) return
Loading...
;
const isFocused = JSON.stringify(path) === JSON.stringify(focus);
+ const isConnected = paneConnected[window.key] ?? true;
return (
{
/* focus is managed by DO */
}}
diff --git a/apps/kamp-us/src/wormhole/TerminalPane.tsx b/apps/kamp-us/src/wormhole/TerminalPane.tsx
index 3c4d205..d2127ce 100644
--- a/apps/kamp-us/src/wormhole/TerminalPane.tsx
+++ b/apps/kamp-us/src/wormhole/TerminalPane.tsx
@@ -1,17 +1,25 @@
import type {ITheme} from "ghostty-web";
-import {useChannelTerminal} from "./use-channel-terminal.ts";
import {useMux} from "./MuxClient.tsx";
+import {useChannelTerminal} from "./use-channel-terminal.ts";
import styles from "./WormholeLayout.module.css";
interface TerminalPaneProps {
channel: number;
sessionId: string;
focused: boolean;
+ connected: boolean;
onFocus: () => void;
theme?: ITheme;
}
-export function TerminalPane({channel, sessionId, focused, onFocus, theme}: TerminalPaneProps) {
+export function TerminalPane({
+ channel,
+ sessionId,
+ focused,
+ connected,
+ onFocus,
+ theme,
+}: TerminalPaneProps) {
const {ref} = useChannelTerminal({channel, sessionId, theme});
const {splitPane, closePane} = useMux();
@@ -20,6 +28,11 @@ export function TerminalPane({channel, sessionId, focused, onFocus, theme}: Term
// biome-ignore lint/a11y/noStaticElementInteractions: terminal container, not a button