feat(sandbox): recover chat session mapping after OWUI restart

Closes #5. In-process _chat_sessions dict is empty after any restart, which
silently started a fresh Claude session on the next turn and lost the prior
conversation. On cache miss we now scan the per-chat CLAUDE_CONFIG_DIR on
disk, pick the newest session JSONL by mtime, and resume from it — Claude's
own filesystem layout is already the source of truth. UUID regex guards
against unexpected files in that directory.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thomas Friedel 2026-04-18 23:04:25 +02:00
parent 3cec4dc871
commit af2e69a4bb
1 changed files with 72 additions and 0 deletions

View File

@ -61,9 +61,21 @@ _MAX_ARTIFACT_BYTES = 50 * 1024 * 1024
# OpenWebUI calls pipe() fresh for each chat turn. Same pattern as the SDK
# pipe — in-process map of chat_id → claude session_id so `claude --resume`
# picks up prior turns' state.
#
# This cache is lost on OWUI restart, but the session JSONL Claude writes to
# $CLAUDE_CONFIG_DIR/projects/<slug>/<session>.jsonl IS the authoritative
# source. _recover_session_id rebuilds the mapping from disk on cache miss.
_chat_sessions: Dict[str, str] = {}
# Valid claude session UUIDs on disk. Filter against stray files so a
# misnamed artifact can't be silently used as a session id. Claude writes
# lowercase-hex UUIDs with standard hyphenation.
_SESSION_ID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
)
# ============================================================================
# open-terminal client
# ============================================================================
@ -87,6 +99,35 @@ def _safe_chat_id(raw: str) -> str:
return _CHAT_ID_SAFE.sub("", raw) or "default"
async def _recover_session_id(
client: "OpenTerminalClient", user_id: str, workdir: str
) -> Optional[str]:
"""Find the newest session JSONL on disk and extract its session_id.
Claude stores per-project session history at
$CLAUDE_CONFIG_DIR/projects/<cwd-slug>/<session-uuid>.jsonl, and the
filename stem IS the session id (no JSON parsing required). Because we
set CLAUDE_CONFIG_DIR per-chat (`<workdir>/.claude`, see #9), every
JSONL under that tree belongs to this chat we don't need to know
Claude's cwd slugification rules.
Returns None if no session has been written yet (first turn of a chat).
`ls -t` handles the rare multi-session case (prior resume failed and
Claude started a fresh session in the same workdir) by ordering newest
first, per the issue's acceptance criteria.
"""
# `workdir` is already sanitized (alphanumerics + / . _ - ~) and must
# remain unquoted so bash expands `~`. The rest of the glob is static.
cmd = f"ls -t {workdir}/.claude/projects/*/*.jsonl 2>/dev/null | head -1"
out = (await client.run_capture(user_id, cmd)).strip()
if not out.endswith(".jsonl"):
return None
basename = out.rsplit("/", 1)[-1][: -len(".jsonl")]
if not _SESSION_ID_RE.match(basename):
return None
return basename
@dataclass
class _ProcessHandle:
process_id: str
@ -228,6 +269,17 @@ class OpenTerminalClient:
async for _ in self.stream_output(handle):
pass
async def run_capture(self, user_id: str, command: str) -> str:
"""Run a short command and return its merged stdout/stderr as a single
string. For one-shot utility commands (ls, cat of tiny files) where
we don't need streaming — keeps callers simple."""
handle = await self.start(user_id, command)
chunks: List[str] = []
async for stream, chunk in self.stream_output(handle):
if stream == "output":
chunks.append(chunk)
return "".join(chunks)
# ============================================================================
# claude CLI invocation + stream-json parsing
@ -708,6 +760,8 @@ class Pipe:
permission_mode=self.valves.PERMISSION_MODE,
allowed_tools=allowed_tools,
max_turns=self.valves.MAX_TURNS,
# Populated below once the client is open — may be recovered
# from disk if the in-process cache was cleared by a restart.
resume_session_id=_chat_sessions.get(chat_id),
anthropic_base_url=self.valves.ANTHROPIC_BASE_URL,
workdir=workdir,
@ -755,6 +809,24 @@ class Pipe:
self.valves.OPEN_TERMINAL_API_KEY,
) as client:
await client.ensure_dir(sandbox_user, workdir)
# Cache miss = either a brand-new chat OR an OWUI restart
# dropped the in-process map (#5). Claude's own on-disk
# session JSONL tells us which — look there before giving
# up and starting a fresh session.
if cfg.resume_session_id is None:
recovered = await _recover_session_id(
client, sandbox_user, workdir
)
if recovered:
cfg.resume_session_id = recovered
_chat_sessions[chat_id] = recovered
log.info(
"recovered session %s for chat %s from disk",
recovered,
chat_id,
)
before_snapshot = await _snapshot_workspace(
client, sandbox_user, workdir
)