diff --git a/app/src/api/chat.ts b/app/src/api/chat.ts index 4e1357d..f92e6c8 100644 --- a/app/src/api/chat.ts +++ b/app/src/api/chat.ts @@ -18,9 +18,16 @@ export interface ClaudeChatMessage { export interface ClaudeSessionSummary { id: string; + title?: string | null; + is_fork?: boolean; + forked_from_id?: string | null; + fork_depth?: number; + needs_compact?: boolean; created_at: string | null; updated_at: string | null; message_count: number; + loaded_message_count?: number; + messages_truncated?: boolean; last_prompt: string | null; preview: string | null; cwd?: string; @@ -58,6 +65,7 @@ export type AgentStreamEvent = | { type: "text"; content: string } | ({ type: "loadout" } & AgentLoadout) | { type: "error"; content: string } + | { type: "status"; content: string } | { type: "tool_start"; id: string; name: string; input_preview?: string } | { type: "tool_finish"; id: string; ok: boolean; output_preview?: string } | { type: "thinking_delta"; content: string } diff --git a/app/src/components/MessagePane.tsx b/app/src/components/MessagePane.tsx index 3a04ed1..e747031 100644 --- a/app/src/components/MessagePane.tsx +++ b/app/src/components/MessagePane.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ChevronLeft, Copy, ExternalLink, Folder as FolderIcon, KeyRound, Plus, Send, Terminal, Wrench, X } from "lucide-react"; +import { AlertTriangle, ArrowRight, Brain, Check, ChevronDown, ChevronLeft, Copy, ExternalLink, Folder as FolderIcon, GitFork, KeyRound, Plus, Send, Terminal, Wrench, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type SyntheticEvent } from "react"; import claudeCodeLogo from "../assets/claude-code-logo.png"; import codexLogo from "../assets/codex-logo.svg"; @@ -6,88 +6,27 @@ import { useWorkshopEvent } from "../hooks/use-workshop-ws"; import { router } from "../router"; import { runPath } from "../utils/navigation"; import { isAgentProvider, providerLabel, type AgentProviderId } from "../utils/agent-provider"; +import type { + AgentLoadout, + AgentStreamEvent, + ClaudeAskUserQuestion, + ClaudeChatMessage, + ClaudeChatMessageBlock, + ClaudeMessageStream, + ClaudeSessionDetail, + ClaudeSessionSummary, +} from "../api/chat"; import { ConnectionIndicator } from "./ConnectionIndicator"; import { Markdown } from "./Markdown"; import { RaindropLogo } from "./RaindropLogo"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; -type Role = "user" | "assistant"; - -interface ClaudeChatMessage { - id: string; - role: Role; - content: string; - blocks?: ClaudeChatMessageBlock[]; - timestamp: string | null; - error?: string; -} - -type ClaudeChatMessageBlock = - | { type: "text"; text: string } - | { type: "tool"; id: string; name: string; input_preview?: string; output_preview?: string; ok?: boolean } - | { type: "thinking"; text: string }; - -interface ClaudeSessionSummary { - id: string; - created_at: string | null; - updated_at: string | null; - message_count: number; - last_prompt: string | null; - preview: string | null; - cwd?: string; -} - -interface ClaudeSessionDetail extends ClaudeSessionSummary { - messages: ClaudeChatMessage[]; -} - type AssistantMessageBlock = | { type: "text"; text: string } | { type: "tool"; id: string; name: string; input_preview?: string; output_preview?: string; ok?: boolean; state: "running" | "done" } | { type: "thinking"; text: string } | { type: "error"; text: string }; -interface ClaudeAskUserQuestion { - id: string; - session_id: string; - tool_use_id: string; - questions: ClaudeAskQuestion[]; - created_at: string; -} - -interface ClaudeAskQuestion { - question: string; - header?: string; - multiSelect: boolean; - options: Array<{ label: string; description?: string }>; -} - -interface ClaudeMessageStream { - client_message_id?: string; - session_id?: string | null; - event?: AgentStreamEvent; -} - -type AgentStreamEvent = - | { type: "text"; content: string } - | ({ type: "loadout" } & AgentLoadout) - | { type: "error"; content: string } - | { type: "tool_start"; id: string; name: string; input_preview?: string } - | { type: "tool_finish"; id: string; ok: boolean; output_preview?: string } - | { type: "thinking_delta"; content: string } - | { type: "subagent_start"; parent_id: string; subagent: string } - | { type: "provider_session"; sessionId: string } - | { type: "done" }; - -interface AgentLoadout { - tools?: string[]; - mcps?: string[]; - skills?: string[]; - plugins?: string[]; - slash_commands?: string[]; - model?: string; -} - interface SlashItem { label: string; value: string; @@ -100,6 +39,7 @@ const PROVIDER_INTRO_SEEN_KEY = "workshop:messagePane:providerIntroSeen"; const MIN_WIDTH = 360; const MAX_WIDTH = 760; const DEFAULT_WIDTH = 460; +const CHAT_DETAIL_MESSAGE_LIMIT = 120; const COLLAPSE_PREVIEW_WIDTH = MIN_WIDTH - 24; const COLLAPSE_COMMIT_WIDTH = MIN_WIDTH - 78; const COLLAPSE_HOLD_MS = 220; @@ -155,6 +95,7 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { const [sending, setSending] = useState(false); const [showList, setShowList] = useState(true); const [error, setError] = useState(null); + const [forkingSessionId, setForkingSessionId] = useState(null); const [pendingQuestions, setPendingQuestions] = useState([]); const [liveBlocks, setLiveBlocks] = useState([]); const [loadout, setLoadout] = useState(null); @@ -180,7 +121,7 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { const hiddenPendingSessionIdsRef = useRef>(new Set()); const suppressPendingUntilNextSendRef = useRef(false); - function setCollapsed(v: boolean) { + const setCollapsed = useCallback((v: boolean) => { setCollapsePreview(false); setSpringClosing(false); if (collapseHoldTimerRef.current) { @@ -193,9 +134,9 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { } setCollapsedState(v); saveCollapsed(v); - } + }, []); - function springCloseFromResize() { + const springCloseFromResize = useCallback(() => { if (springClosing || springCloseTimerRef.current) return; resizeRef.current = null; if (collapseHoldTimerRef.current) { @@ -209,7 +150,7 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { springCloseTimerRef.current = null; setCollapsed(true); }, COLLAPSE_SPRING_MS); - } + }, [setCollapsed, springClosing]); function dismissProviderIntro() { setShowProviderIntro(false); @@ -276,7 +217,7 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { window.removeEventListener("pointerup", onPointerUp); window.removeEventListener("resize", onResize); }; - }, []); + }, [springCloseFromResize]); const refreshSessions = useCallback(async () => { const res = await fetch("/api/agent/sessions"); @@ -285,7 +226,10 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { const loadSession = useCallback(async (id: string) => { setError(null); - const res = await fetch(`/api/agent/sessions/${encodeURIComponent(id)}`); + const url = provider === "codex" + ? `/api/agent/sessions/${encodeURIComponent(id)}?message_limit=${CHAT_DETAIL_MESSAGE_LIMIT}` + : `/api/agent/sessions/${encodeURIComponent(id)}`; + const res = await fetch(url); if (!res.ok) { setError(`Could not load ${providerLabel(provider)} session.`); return; @@ -296,10 +240,40 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { hiddenPendingSessionIdsRef.current.delete(id); suppressPendingUntilNextSendRef.current = false; setSelectedId(id); - setDetail(await res.json()); + const loaded = await res.json(); + setDetail(loaded); setShowList(false); }, [pendingQuestions, provider]); + const forkSession = useCallback(async (id: string) => { + if (provider !== "codex" || forkingSessionId) return; + setError(null); + setForkingSessionId(id); + try { + const res = await fetch(`/api/agent/sessions/${encodeURIComponent(id)}/fork`, { method: "POST" }); + const body = await res.json().catch(() => null); + if (!res.ok || !body?.session) { + throw new Error(body?.error ?? "Could not fork Codex chat."); + } + const session = body.session as ClaudeSessionSummary; + setSessions((current) => [session, ...current.filter((item) => item.id !== session.id)]); + setSelectedId(session.id); + setDetail({ + ...session, + loaded_message_count: 0, + messages_truncated: false, + messages: [], + }); + setLiveBlocks([]); + setShowList(false); + void refreshSessions(); + } catch (err) { + setError((err as Error).message); + } finally { + setForkingSessionId(null); + } + }, [forkingSessionId, provider, refreshSessions]); + useEffect(() => { void refreshSessions(); }, [refreshSessions]); @@ -319,7 +293,7 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { window.removeEventListener("workshop:open-message-pane", openPane); window.removeEventListener("workshop:messagePane:resetOnboarding", resetOnboarding); }; - }, []); + }, [setCollapsed]); useEffect(() => { let cancelled = false; @@ -357,6 +331,21 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { if (!draft.startsWith("/")) setShowSlash(false); }, [draft]); + useEffect(() => { + if (provider !== "codex") return; + setDetail((current) => { + if (!current || current.messages.length <= CHAT_DETAIL_MESSAGE_LIMIT) return current; + const total = Math.max(current.message_count, current.messages.length); + return { + ...current, + message_count: total, + loaded_message_count: CHAT_DETAIL_MESSAGE_LIMIT, + messages_truncated: total > CHAT_DETAIL_MESSAGE_LIMIT, + messages: current.messages.slice(-CHAT_DETAIL_MESSAGE_LIMIT), + }; + }); + }, [detail?.id, detail?.messages.length, provider]); + useEffect(() => { setActiveSlashIndex(0); }, [draft]); @@ -367,10 +356,13 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { const res = await fetch("/api/agent/provider"); if (!res.ok) return; const body = await res.json().catch(() => null); - if (!cancelled && isAgentProvider(body?.provider)) setProvider(body.provider); + if (!cancelled && isAgentProvider(body?.provider)) { + setProvider(body.provider); + void refreshSessions(); + } })(); return () => { cancelled = true; }; - }, []); + }, [refreshSessions]); useWorkshopEvent("agent_provider", (data: { provider?: string }) => { if (isAgentProvider(data.provider)) { @@ -416,7 +408,9 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { useWorkshopEvent("agent_message_stream", (data: ClaudeMessageStream) => { if (!data?.client_message_id || data.client_message_id !== activeClientMessageIdRef.current) return; - if (data.session_id) setSelectedId(data.session_id); + if (data.session_id) { + setSelectedId(data.session_id); + } const event = data.event; if (!event) return; if (event.type === "loadout") setLoadout(event); @@ -454,12 +448,16 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { timestamp: new Date().toISOString(), }; setDetail((current) => current - ? { ...current, messages: [...current.messages, optimistic] } + ? { + ...current, + messages: [...current.messages, optimistic], + } : { id: "new", created_at: optimistic.timestamp, updated_at: optimistic.timestamp, message_count: 1, + title: null, last_prompt: content, preview: content, messages: [optimistic], @@ -480,9 +478,16 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { const body = await res.json().catch(() => null); if (activeClientMessageIdRef.current !== clientMessageId) return; if (!res.ok) throw new Error(body?.error ?? `${providerLabel(provider)} request failed (${res.status})`); - if (body?.session_id) setSelectedId(body.session_id); + if (body?.session_id) { + setSelectedId(body.session_id); + } if (body?.session) { - setDetail(appendLiveCompletionIfMissing(body.session, liveBlocksRef.current)); + const capturedBlocks = liveBlocksRef.current.length + ? liveBlocksRef.current + : typeof body.text === "string" + ? [{ type: "text" as const, text: body.text }] + : []; + setDetail(appendLiveCompletionIfMissing(body.session, capturedBlocks)); } else if (typeof body?.text === "string") { const capturedBlocks = liveBlocksRef.current.length ? liveBlocksRef.current @@ -523,7 +528,13 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { } async function switchProvider(next: AgentProviderId) { - if (next === provider || switchingProvider) return; + if (switchingProvider) return; + if (next === provider) { + startNewChat(); + setShowList(true); + void refreshSessions(); + return; + } const previous = provider; setProvider(next); setError(null); @@ -610,10 +621,12 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { const visiblePendingQuestions = pendingQuestions.filter((question) => question.session_id === selectedId); const visibleLiveBlocks = visibleAssistantBlocks(liveBlocks); const showTraceDebugPrompt = !!activeRunId && messages.length === 0 && !sending && visibleLiveBlocks.length === 0; + const codexTraceForkMode = provider === "codex" && !!activeRunId; const slashItems = useMemo(() => buildSlashItems(loadout, draft, provider), [loadout, draft, provider]); const activeSlashItem = showSlash ? slashItems[activeSlashIndex] : undefined; const currentCwd = detail?.cwd ?? workspaceCwd; const currentCwdDisplay = formatCwdDisplay(currentCwd); + const currentSessionTitle = detail ? displaySessionTitle(detail, provider) : "New chat"; useEffect(() => { setActiveSlashIndex((index) => Math.min(index, Math.max(0, slashItems.length - 1))); @@ -718,8 +731,8 @@ export function MessagePane({ activeRunId }: MessagePaneProps) {
-
- {detail?.preview ?? detail?.last_prompt ?? "New chat"} +
+ {currentSessionTitle}
@@ -781,13 +794,22 @@ export function MessagePane({ activeRunId }: MessagePaneProps) { providerError={error} providerBusy={switchingProvider} showProviderIntro={showProviderIntro} + traceForkMode={codexTraceForkMode} + forkingSessionId={forkingSessionId} onProviderIntroChoice={chooseIntroProvider} onSelect={(id) => void loadSession(id)} + onFork={(id) => void forkSession(id)} onNew={startNewChat} /> ) : (
+ {detail?.messages_truncated && ( + + )} {messages.map((message) => )} {visibleLiveBlocks.length > 0 && (
@@ -1121,14 +1143,13 @@ function appendLiveCompletionIfMissing( const blocks = visibleAssistantBlocks(liveBlocks); if (!blocks.length) return session; - const lastUserIndex = findLastMessageIndex(session.messages, "user"); - const hasAssistantAfterUser = session.messages - .slice(Math.max(0, lastUserIndex + 1)) - .some((message) => message.role === "assistant" && parseAssistantBlocks(message).length > 0); - if (hasAssistantAfterUser) return session; - const content = assistantBlocksText(blocks); if (!content.trim()) return session; + const alreadyPresent = session.messages.some((message) => ( + message.role === "assistant" && + assistantBlocksText(parseAssistantBlocks(message)).trim() === content.trim() + )); + if (alreadyPresent) return session; return { ...session, @@ -1145,13 +1166,6 @@ function appendLiveCompletionIfMissing( }; } -function findLastMessageIndex(messages: ClaudeChatMessage[], role: Role): number { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === role) return i; - } - return -1; -} - function assistantBlocksText(blocks: AssistantMessageBlock[]): string { return blocks .map((block) => { @@ -1264,8 +1278,11 @@ function ChatList({ providerError, providerBusy, showProviderIntro, + traceForkMode, + forkingSessionId, onProviderIntroChoice, onSelect, + onFork, onNew, }: { sessions: ClaudeSessionSummary[]; @@ -1275,13 +1292,21 @@ function ChatList({ providerError: string | null; providerBusy: boolean; showProviderIntro: boolean; + traceForkMode: boolean; + forkingSessionId: string | null; onProviderIntroChoice: (provider: AgentProviderId) => void; onSelect: (id: string) => void; + onFork: (id: string) => void; onNew: () => void; }) { const [copiedId, setCopiedId] = useState(null); const [introProvider, setIntroProvider] = useState(provider); const [introSessions, setIntroSessions] = useState(sessions); + const visibleSessions = useMemo(() => raindropVisibleSessions(sessions, provider), [sessions, provider]); + const visibleIntroSessions = useMemo( + () => raindropVisibleSessions(introSessions, introProvider), + [introProvider, introSessions], + ); useEffect(() => { if (showProviderIntro) setIntroProvider(provider); @@ -1337,13 +1362,14 @@ function ChatList({ Your Recent {providerLabel(introProvider)} Chats
- {introSessions.length === 0 ? ( + {visibleIntroSessions.length === 0 ? (
No chats yet.
- ) : introSessions.slice(0, 4).map((session) => ( + ) : visibleIntroSessions.slice(0, 4).map((session) => ( ))}
@@ -1361,10 +1387,15 @@ function ChatList({ New chat + {traceForkMode && ( +
+ Open a Codex chat to resume it, or fork it for this trace with the fork button. +
+ )}
- {sessions.length === 0 ? ( + {visibleSessions.length === 0 ? (
No {providerLabel(provider)} chats yet.
- ) : sessions.map((session) => ( + ) : visibleSessions.map((session) => ( ))} @@ -1445,7 +1479,10 @@ function ChatListItem({ workspaceCwd, provider, copied, + forkMode, + forking, onSelect, + onFork, onCopy, }: { session: ClaudeSessionSummary; @@ -1453,11 +1490,16 @@ function ChatListItem({ workspaceCwd: string | null; provider: AgentProviderId; copied: boolean; + forkMode: boolean; + forking: boolean; onSelect: (id: string) => void; + onFork: (id: string) => void; onCopy: (event: SyntheticEvent, session: ClaudeSessionSummary) => Promise; }) { const cwd = session.cwd ?? workspaceCwd; const cwdDisplay = formatCwdDisplay(cwd); + const title = displaySessionTitle(session, provider); + const preview = provider === "codex" ? displayCodexPreview(session, title) : null; return (
-
{session.preview || "Untitled chat"}
+
{title}
{formatSessionTime(session.updated_at)}
+ {preview && ( +
+ {preview} +
+ )}
@@ -1487,6 +1534,25 @@ function ChatListItem({
{session.id.slice(0, 8)} ยท {session.message_count} messages + {forkMode && provider === "codex" && ( + + )} + ) : ( + + )} {info.label} {span.name} + {collapsed && hiddenDescendantCount > 0 && ( + + +{hiddenDescendantCount} + + )} {annotations.map((a) => ( (null); const [addingForSpan, setAddingForSpan] = useState(null); const [flashId, setFlashId] = useState(null); + const [collapsedIds, setCollapsedIds] = useState>(() => new Set()); const runId = spans[0]?.run_id ?? null; - const reportedSelectedId = selectedId && spans.some((s) => s.id === selectedId) ? selectedId : null; + const treeModel = useMemo(() => buildSpanTreeModel(spans), [spans]); + const reportedSelectedId = selectedId && treeModel.spanMap.has(selectedId) ? selectedId : null; useEffect(() => { - if (controlled || !runId || autoSelectedRunRef.current === runId) return; + if (!runId || autoSelectedRunRef.current === runId) return; autoSelectedRunRef.current = runId; - setInternalSelectedId(spans[0]?.id ?? null); + if (!controlled) setInternalSelectedId(spans[0]?.id ?? null); + setCollapsedIds(new Set()); }, [controlled, runId, spans]); useEffect(() => { @@ -321,6 +443,13 @@ export function SpanTree({ const handler = (ev: Event) => { const spanId = (ev as CustomEvent).detail?.spanId as string | undefined; if (!spanId || !spanIds.has(spanId)) return; + setCollapsedIds((prev) => { + const next = new Set(prev); + for (const parentId of getAncestorIds(spanId, treeModel.spanMap)) { + next.delete(parentId); + } + return next; + }); setInternalSelectedId(spanId); setFlashId(spanId); requestAnimationFrame(() => { @@ -331,10 +460,17 @@ export function SpanTree({ }; window.addEventListener("workshop:deep-link-span", handler); return () => window.removeEventListener("workshop:deep-link-span", handler); - }, [controlled, spans]); + }, [controlled, spans, treeModel.spanMap]); useEffect(() => { if (!selectedSpanId) return; + setCollapsedIds((prev) => { + const next = new Set(prev); + for (const parentId of getAncestorIds(selectedSpanId, treeModel.spanMap)) { + next.delete(parentId); + } + return next; + }); setFlashId(selectedSpanId); requestAnimationFrame(() => { const el = document.querySelector(`[data-span-row="${selectedSpanId}"]`); @@ -342,7 +478,7 @@ export function SpanTree({ }); const timeout = window.setTimeout(() => setFlashId(null), 1500); return () => window.clearTimeout(timeout); - }, [selectedSpanId]); + }, [selectedSpanId, treeModel.spanMap]); // Dismiss context menu on scroll / outside click / escape useEffect(() => { @@ -370,26 +506,26 @@ export function SpanTree({ return map; }, [annotations]); - const spanMap = new Map(spans.map(s => [s.id, s])); - const children = new Map(); - const roots: Span[] = []; - for (const s of spans) { - if (!s.parent_span_id || !spanMap.has(s.parent_span_id)) roots.push(s); - else { const c = children.get(s.parent_span_id) ?? []; c.push(s); children.set(s.parent_span_id, c); } - } - - const flat: { span: Span; depth: number }[] = []; - function walk(span: Span, depth: number) { - flat.push({ span, depth }); - for (const kid of children.get(span.id) ?? []) walk(kid, depth + 1); - } - for (const r of roots) walk(r, 0); + const flat = useMemo(() => getVisibleSpanRows(treeModel, collapsedIds), [treeModel, collapsedIds]); const minTime = flat.length > 0 ? Math.min(...flat.map(f => f.span.start_time_ms)) : 0; const maxTime = flat.length > 0 ? Math.max(...flat.map(f => f.span.end_time_ms)) : 0; const totalDur = maxTime - minTime || 1; - const selectedSpan = selectedId ? spanMap.get(selectedId) : null; + const selectedSpan = selectedId ? treeModel.spanMap.get(selectedId) : null; + const toggleCollapse = (span: Span) => (e: React.MouseEvent) => { + e.stopPropagation(); + const isCollapsing = !collapsedIds.has(span.id); + if (isCollapsing && selectedId && isDescendantOf(selectedId, span.id, treeModel.spanMap)) { + setSelectedId(span.id); + } + setCollapsedIds((prev) => { + const next = new Set(prev); + if (next.has(span.id)) next.delete(span.id); + else next.add(span.id); + return next; + }); + }; if (flat.length === 0) return
No spans
; @@ -411,7 +547,11 @@ export function SpanTree({ minTime={minTime} totalDur={totalDur} selected={span.id === selectedId} flashing={span.id === flashId} + hasChildren={(treeModel.children.get(span.id)?.length ?? 0) > 0} + collapsed={collapsedIds.has(span.id)} + hiddenDescendantCount={treeModel.descendantCounts.get(span.id) ?? 0} onClick={() => setSelectedId(span.id === selectedId ? null : span.id)} + onToggleCollapse={toggleCollapse(span)} onContextMenu={onCreateAnnotation ? (e, s) => setContextMenu({ spanId: s.id, x: e.clientX, y: e.clientY }) : undefined} annotations={annotationsBySpan.get(span.id) ?? []} freshIds={freshIds} diff --git a/src/agent-chat.ts b/src/agent-chat.ts index f810ae2..47c3182 100644 --- a/src/agent-chat.ts +++ b/src/agent-chat.ts @@ -36,6 +36,7 @@ export interface AgentCliChatInput { sessionId?: string | null; userMessageId?: string | null; resumeSessionId?: string | null; + forceAutoCompact?: boolean; abortSignal?: AbortSignal; } diff --git a/src/claude-sessions.ts b/src/claude-sessions.ts index 0a6386b..5902a24 100644 --- a/src/claude-sessions.ts +++ b/src/claude-sessions.ts @@ -22,9 +22,16 @@ export interface ClaudeSessionSummary { id: string; path: string; cwd: string; + title?: string | null; + is_fork?: boolean; + forked_from_id?: string | null; + fork_depth?: number; + needs_compact?: boolean; created_at: string | null; updated_at: string | null; message_count: number; + loaded_message_count?: number; + messages_truncated?: boolean; last_prompt: string | null; preview: string | null; } diff --git a/src/codex-cli-chat.ts b/src/codex-cli-chat.ts index b9a57de..7f722a0 100644 --- a/src/codex-cli-chat.ts +++ b/src/codex-cli-chat.ts @@ -26,10 +26,22 @@ export function runCodexCliChat( env: { ...process.env }, stdio: ["ignore", "pipe", "pipe"], }); + let closed = false; if (input.abortSignal) { - if (input.abortSignal.aborted) child.kill("SIGINT"); - input.abortSignal.addEventListener("abort", () => child.kill("SIGINT"), { once: true }); + const abortChild = () => { + if (closed) return; + child.kill("SIGINT"); + const timer = setTimeout(() => { + if (!closed) child.kill("SIGKILL"); + }, 3_000); + timer.unref?.(); + }; + if (input.abortSignal.aborted) abortChild(); + input.abortSignal.addEventListener("abort", abortChild, { once: true }); } + child.once("close", () => { + closed = true; + }); return consumeCodexStream(child, handlers); } @@ -45,6 +57,14 @@ export function buildCodexArgs(input: CodexCliChatInput): string[] { "-c", `mcp_servers.raindrop.env={RAINDROP_WORKSHOP_URL=${JSON.stringify(input.backendUrl)},RAINDROP_WORKSHOP_AGENT_PROVIDER="codex",RAINDROP_WORKSHOP_ANNOTATION_SOURCE=${JSON.stringify(agentAnnotationSource("codex"))}}`, ]; + if (input.forceAutoCompact) { + commonArgs.push( + "-c", + "model_auto_compact_token_limit=0", + "-c", + 'model_auto_compact_token_limit_scope="total"', + ); + } if (process.env.RAINDROP_WORKSHOP_CODEX_BYPASS_PERMISSIONS !== "0") { commonArgs.unshift("--dangerously-bypass-approvals-and-sandbox"); } else { diff --git a/src/codex-sessions.ts b/src/codex-sessions.ts index 2d6b1d8..9bc470b 100644 --- a/src/codex-sessions.ts +++ b/src/codex-sessions.ts @@ -1,6 +1,8 @@ import fs from "fs"; +import { randomUUID } from "crypto"; import os from "os"; import path from "path"; +import { Database } from "bun:sqlite"; import type { ClaudeChatMessage, ClaudeChatMessageBlock, @@ -9,23 +11,100 @@ import type { } from "./claude-sessions"; const MAX_SESSION_FILES = 300; +const SESSION_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +const DEFAULT_DETAIL_MESSAGE_LIMIT = 120; -export function listCodexSessions(cwd: string): ClaudeSessionSummary[] { +interface CodexSessionReadOptions { + messageLimit?: number; +} + +interface CodexForkMetadata { + isFork: boolean; + title: string | null; + parentId: string | null; + needsCompact: boolean; +} + +export function listCodexSessions(cwd?: string | null): ClaudeSessionSummary[] { + const metadata = readCodexSessionMetadata(); return codexSessionFiles() - .map((file) => readCodexSessionFile(file)) - .filter((session): session is ClaudeSessionDetail => !!session && session.cwd === cwd) + .map((file) => readCodexSessionSummaryFile(file, metadata)) + .filter((session): session is ClaudeSessionSummary => { + if (!session || (session.message_count === 0 && !session.is_fork)) return false; + return !cwd || session.cwd === cwd; + }) .sort((a, b) => (Date.parse(b.updated_at ?? "") || 0) - (Date.parse(a.updated_at ?? "") || 0)) - .map(({ messages: _messages, ...summary }) => summary); } -export function getCodexSession(cwd: string, sessionId: string): ClaudeSessionDetail | null { - if (!/^[a-zA-Z0-9_-]+$/.test(sessionId)) return null; - for (const file of codexSessionFiles()) { - if (!path.basename(file).includes(sessionId)) continue; - const session = readCodexSessionFile(file); - if (session?.cwd === cwd && session.id === sessionId) return session; +export function getCodexSession( + sessionId: string, + cwd?: string | null, + options: CodexSessionReadOptions = {}, +): ClaudeSessionDetail | null { + const metadata = readCodexSessionMetadata(); + const file = findCodexSessionFile(sessionId, cwd, metadata); + return file ? readCodexSessionFile(file, metadata, options) : null; +} + +export function forkCodexSession(sourceSessionId: string): ClaudeSessionSummary | null { + const metadata = readCodexSessionMetadata(); + const sourceFile = findCodexSessionFile(sourceSessionId, null, metadata); + if (!sourceFile) return null; + const source = readCodexSessionSummaryFile(sourceFile, metadata); + + const forkId = randomUUID(); + const now = new Date(); + const forkPath = codexSessionPath(now, forkId); + const sourceTitle = source?.title ?? source?.preview ?? `Codex chat ${sourceSessionId.slice(0, 8)}`; + const forkTitle = nextForkedCodexTitle(sourceTitle, sourceFile); + let forkedCurrentSessionMeta = false; + const forkedLines = fs + .readFileSync(sourceFile, "utf8") + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + if (forkedCurrentSessionMeta || !isSessionMetaLine(line)) return line; + forkedCurrentSessionMeta = true; + return forkCodexSessionLine(line, forkId, now, sourceSessionId, forkTitle); + }); + + fs.mkdirSync(path.dirname(forkPath), { recursive: true }); + fs.writeFileSync(forkPath, `${forkedLines.join("\n")}\n`); + writeForkedCodexSqliteMetadata(sourceSessionId, forkId, forkPath, forkTitle, now); + + return readCodexSessionSummaryFile(forkPath, readCodexSessionMetadata()); +} + +export function ensureForkedCodexSessionTitle( + sessionId: string, + expectedTitle?: string | null, +): ClaudeSessionSummary | null { + if (!SESSION_ID_PATTERN.test(sessionId)) return null; + + const metadata = readCodexSessionMetadata(); + const file = findCodexSessionFile(sessionId, null, metadata); + if (!file) return null; + + const forkMetadata = readForkedCodexMetadata(file); + if (!expectedTitle && !forkMetadata?.isFork) { + return readCodexSessionSummaryFile(file, metadata); } - return null; + + const title = storedForkedCodexTitle(expectedTitle ?? forkMetadata?.title ?? `Codex chat ${sessionId.slice(0, 8)}`, file); + updateForkedCodexSessionLine(file, sessionId, title); + updateCodexSqliteForkTitle(sessionId, title); + return readCodexSessionSummaryFile(file, readCodexSessionMetadata()); +} + +export function markForkedCodexSessionCompacted(sessionId: string): ClaudeSessionSummary | null { + if (!SESSION_ID_PATTERN.test(sessionId)) return null; + + const metadata = readCodexSessionMetadata(); + const file = findCodexSessionFile(sessionId, null, metadata); + if (!file) return null; + + updateForkedCodexCompactionFlag(file, sessionId, false); + return readCodexSessionSummaryFile(file, metadata); } function codexSessionFiles(): string[] { @@ -37,6 +116,283 @@ function codexSessionFiles(): string[] { .slice(0, MAX_SESSION_FILES); } +function findCodexSessionFile( + sessionId: string, + cwd?: string | null, + metadata = readCodexSessionMetadata(), +): string | null { + if (!SESSION_ID_PATTERN.test(sessionId)) return null; + for (const file of codexSessionFiles()) { + if (!path.basename(file).endsWith(`${sessionId}.jsonl`)) continue; + const session = readCodexSessionSummaryFile(file, metadata); + if (session?.id === sessionId && (!cwd || session.cwd === cwd)) return file; + } + return null; +} + +function findCodexSessionFileById(sessionId: string): string | null { + if (!SESSION_ID_PATTERN.test(sessionId)) return null; + for (const file of codexSessionFiles()) { + if (path.basename(file).endsWith(`${sessionId}.jsonl`)) return file; + } + return null; +} + +function codexSessionPath(date: Date, sessionId: string): string { + const root = path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "sessions"); + const year = String(date.getFullYear()).padStart(4, "0"); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const stamp = date.toISOString().replace(/\.\d{3}Z$/, "").replace(/:/g, "-"); + return path.join(root, year, month, day, `rollout-${stamp}-${sessionId}.jsonl`); +} + +function forkCodexSessionLine( + line: string, + forkId: string, + now: Date, + sourceSessionId: string, + forkTitle: string, +): string { + const event = parseLine(line); + if (!event || event.type !== "session_meta") return line; + + const payload = objectValue(event.payload); + if (!payload) return line; + + event.timestamp = now.toISOString(); + event.payload = { + ...payload, + id: forkId, + timestamp: now.toISOString(), + title: forkTitle, + forked_from_id: sourceSessionId, + originator: "workshop_codex_fork", + workshop_title: forkTitle, + workshop_visible_after: now.toISOString(), + workshop_needs_compact: true, + }; + return JSON.stringify(event); +} + +function nextForkedCodexTitle(title: string | null, sourceFilePath: string): string { + const cleaned = (title || "Untitled chat").replace(/\s+/g, " ").trim(); + const parsed = parseForkedTitle(cleaned); + return formatForkedTitle(Math.max(parsed.depth, forkDepthForFile(sourceFilePath)) + 1, parsed.base); +} + +function storedForkedCodexTitle(title: string | null, filePath: string): string { + const cleaned = (title || "Untitled chat").replace(/\s+/g, " ").trim(); + const parsed = parseForkedTitle(cleaned); + return formatForkedTitle(Math.max(1, parsed.depth, forkDepthForFile(filePath)), parsed.base); +} + +function parseForkedTitle(title: string): { depth: number; base: string } { + const match = title.match(/^\[forked(?:\^(\d+))?\]\s*(.*)$/i); + if (!match) return { depth: 0, base: title || "Untitled chat" }; + const depth = match[1] ? Number.parseInt(match[1], 10) : 1; + return { + depth: Number.isFinite(depth) && depth > 0 ? depth : 1, + base: match[2]?.trim() || "Untitled chat", + }; +} + +function formatForkedTitle(depth: number, base: string): string { + const cleanBase = base.replace(/\s+/g, " ").trim() || "Untitled chat"; + return depth <= 1 ? `[forked] ${cleanBase}` : `[forked^${depth}] ${cleanBase}`; +} + +function forkDepthForFile(filePath: string, seen = new Set()): number { + const metadata = readForkedCodexMetadata(filePath); + if (!metadata?.isFork) return 0; + if (!metadata.parentId || seen.has(metadata.parentId)) return 1; + + seen.add(metadata.parentId); + const parentFile = findCodexSessionFileById(metadata.parentId); + return parentFile ? 1 + forkDepthForFile(parentFile, seen) : 1; +} + +function writeForkedCodexSqliteMetadata( + sourceSessionId: string, + forkId: string, + forkPath: string, + forkTitle: string, + now: Date, +) { + const dbPath = codexStateDbPath(); + if (!dbPath) return; + + let db: Database | null = null; + try { + db = new Database(dbPath); + const source = db + .query(` + SELECT source, model_provider, cwd, sandbox_policy, approval_mode, cli_version, + first_user_message, agent_nickname, agent_role, memory_mode, model, + reasoning_effort, agent_path, thread_source, preview, git_sha, + git_branch, git_origin_url + FROM threads + WHERE id = ? + `) + .get(sourceSessionId) as Record | null; + if (!source) return; + + const seconds = Math.floor(now.getTime() / 1000); + db.query(` + INSERT OR REPLACE INTO threads ( + id, rollout_path, created_at, updated_at, source, model_provider, cwd, + title, sandbox_policy, approval_mode, tokens_used, has_user_event, + archived, archived_at, git_sha, git_branch, git_origin_url, cli_version, + first_user_message, agent_nickname, agent_role, memory_mode, model, + reasoning_effort, agent_path, created_at_ms, updated_at_ms, thread_source, + preview + ) + VALUES ( + $id, $rollout_path, $created_at, $updated_at, $source, $model_provider, $cwd, + $title, $sandbox_policy, $approval_mode, 0, 1, 0, NULL, $git_sha, + $git_branch, $git_origin_url, $cli_version, $first_user_message, + $agent_nickname, $agent_role, $memory_mode, $model, $reasoning_effort, + $agent_path, $created_at_ms, $updated_at_ms, $thread_source, $preview + ) + `).run({ + $id: forkId, + $rollout_path: forkPath, + $created_at: seconds, + $updated_at: seconds, + $source: stringValue(source.source) ?? "workshop", + $model_provider: stringValue(source.model_provider) ?? "", + $cwd: stringValue(source.cwd) ?? os.homedir(), + $title: forkTitle, + $sandbox_policy: stringValue(source.sandbox_policy) ?? "", + $approval_mode: stringValue(source.approval_mode) ?? "", + $git_sha: stringValue(source.git_sha), + $git_branch: stringValue(source.git_branch), + $git_origin_url: stringValue(source.git_origin_url), + $cli_version: stringValue(source.cli_version) ?? "", + $first_user_message: forkTitle, + $agent_nickname: stringValue(source.agent_nickname), + $agent_role: stringValue(source.agent_role), + $memory_mode: stringValue(source.memory_mode) ?? "enabled", + $model: stringValue(source.model), + $reasoning_effort: stringValue(source.reasoning_effort), + $agent_path: stringValue(source.agent_path), + $created_at_ms: now.getTime(), + $updated_at_ms: now.getTime(), + $thread_source: stringValue(source.thread_source) ?? "user", + $preview: forkTitle, + }); + } catch { + return; + } finally { + db?.close(); + } +} + +function readForkedCodexMetadata(filePath: string): CodexForkMetadata | null { + const line = firstSessionMetaLine(filePath); + if (!line) return null; + + const event = parseLine(line); + const payload = objectValue(event?.payload); + if (!payload) return null; + + const title = stringValue(payload.workshop_title) ?? stringValue(payload.title); + const parentId = stringValue(payload.forked_from_id); + return { + isFork: stringValue(payload.originator) === "workshop_codex_fork" || !!parentId, + title, + parentId, + needsCompact: payload.workshop_needs_compact === true, + }; +} + +function updateForkedCodexSessionLine(filePath: string, sessionId: string, forkTitle: string) { + updateCodexSessionMetaLine(filePath, sessionId, (payload) => ({ + ...payload, + title: forkTitle, + workshop_title: forkTitle, + originator: stringValue(payload.originator) ?? "workshop_codex_fork", + workshop_visible_after: stringValue(payload.workshop_visible_after) ?? stringValue(payload.timestamp), + })); +} + +function updateForkedCodexCompactionFlag(filePath: string, sessionId: string, needsCompact: boolean) { + updateCodexSessionMetaLine(filePath, sessionId, (payload) => ({ + ...payload, + workshop_needs_compact: needsCompact, + })); +} + +function updateCodexSessionMetaLine( + filePath: string, + sessionId: string, + updatePayload: (payload: Record) => Record, +) { + const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/); + let changed = false; + const updated = lines.map((line) => { + if (changed || !line.trim() || !isSessionMetaLine(line)) return line; + const event = parseLine(line); + const payload = objectValue(event?.payload); + if (!payload || stringValue(payload.id) !== sessionId) return line; + changed = true; + event!.payload = updatePayload(payload); + return JSON.stringify(event); + }); + if (changed) fs.writeFileSync(filePath, updated.join("\n")); +} + +function updateCodexSqliteForkTitle(sessionId: string, forkTitle: string) { + const dbPath = codexStateDbPath(); + if (!dbPath) return; + + let db: Database | null = null; + try { + db = new Database(dbPath); + db.query(` + UPDATE threads + SET title = ?, first_user_message = ?, preview = ? + WHERE id = ? + `).run(forkTitle, forkTitle, forkTitle, sessionId); + } catch { + return; + } finally { + db?.close(); + } +} + +function firstSessionMetaLine(filePath: string): string | null { + let fd: number | null = null; + try { + fd = fs.openSync(filePath, "r"); + const chunks: string[] = []; + const buffer = Buffer.alloc(64 * 1024); + let bytesRead = 0; + let totalBytes = 0; + do { + bytesRead = fs.readSync(fd, buffer, 0, buffer.length, null); + if (bytesRead <= 0) break; + totalBytes += bytesRead; + chunks.push(buffer.subarray(0, bytesRead).toString("utf8")); + const text = chunks.join(""); + const newline = text.indexOf("\n"); + if (newline >= 0) { + const line = text.slice(0, newline).replace(/\r$/, ""); + return isSessionMetaLine(line) ? line : null; + } + } while (totalBytes < 1024 * 1024); + } catch { + return null; + } finally { + if (fd != null) fs.closeSync(fd); + } + return null; +} + +function isSessionMetaLine(line: string): boolean { + return parseLine(line)?.type === "session_meta"; +} + function collectJsonlFiles(dir: string, files: string[]) { let entries: fs.Dirent[]; try { @@ -54,7 +410,218 @@ function collectJsonlFiles(dir: string, files: string[]) { } } -function readCodexSessionFile(filePath: string): ClaudeSessionDetail | null { +interface CodexSessionMetadata { + title: string | null; + preview: string | null; + cwd: string | null; + createdAt: string | null; + updatedAt: string | null; + threadSource: string | null; +} + +function readCodexSessionMetadata(): Map { + const metadata = readCodexSqliteMetadata(); + for (const [id, threadName] of readCodexSessionIndex()) { + const existing = metadata.get(id); + metadata.set(id, { + title: codexDisplayText(threadName) ?? existing?.title ?? null, + preview: existing?.preview ?? null, + cwd: existing?.cwd ?? null, + createdAt: existing?.createdAt ?? null, + updatedAt: existing?.updatedAt ?? null, + threadSource: existing?.threadSource ?? null, + }); + } + return metadata; +} + +function readCodexSqliteMetadata(): Map { + const dbPath = codexStateDbPath(); + const metadata = new Map(); + if (!dbPath) return metadata; + + let db: Database | null = null; + try { + db = new Database(dbPath, { readonly: true }); + const rows = db.query(` + SELECT id, title, preview, first_user_message, cwd, created_at_ms, updated_at_ms, created_at, updated_at, thread_source + FROM threads + WHERE archived = 0 + `).all() as Array>; + for (const row of rows) { + const id = stringValue(row.id); + if (!id) continue; + const title = codexDisplayText(stringValue(row.title)); + const preview = codexDisplayText(stringValue(row.preview)) ?? codexDisplayText(stringValue(row.first_user_message)); + metadata.set(id, { + title, + preview, + cwd: stringValue(row.cwd), + createdAt: isoFromEpoch(row.created_at_ms, row.created_at), + updatedAt: isoFromEpoch(row.updated_at_ms, row.updated_at), + threadSource: stringValue(row.thread_source), + }); + } + } catch { + return metadata; + } finally { + db?.close(); + } + return metadata; +} + +function readCodexSessionIndex(): Map { + const indexPath = path.join(codexRoot(), "session_index.jsonl"); + const entries = new Map(); + if (!fs.existsSync(indexPath)) return entries; + + const lines = fs.readFileSync(indexPath, "utf8").split(/\r?\n/).filter(Boolean); + for (const line of lines) { + const entry = parseLine(line); + const id = stringValue(entry?.id); + const threadName = stringValue(entry?.thread_name); + if (id && threadName) entries.set(id, threadName); + } + return entries; +} + +function codexStateDbPath(): string | null { + const root = codexRoot(); + let entries: string[]; + try { + entries = fs.readdirSync(root); + } catch { + return null; + } + return entries + .filter((name) => /^state_\d+\.sqlite$/.test(name)) + .map((name) => path.join(root, name)) + .sort((a, b) => safeMtimeMs(b) - safeMtimeMs(a))[0] ?? null; +} + +function codexRoot(): string { + return process.env.CODEX_HOME || path.join(os.homedir(), ".codex"); +} + +function isoFromEpoch(epochMs: unknown, epochSeconds: unknown): string | null { + const ms = numberValue(epochMs); + if (ms) return new Date(ms).toISOString(); + const seconds = numberValue(epochSeconds); + return seconds ? new Date(seconds * 1000).toISOString() : null; +} + +function readCodexSessionSummaryFile( + filePath: string, + metadata = readCodexSessionMetadata(), +): ClaudeSessionSummary | null { + if (!fs.existsSync(filePath)) return null; + let id = ""; + let cwd = ""; + let createdAt: string | null = null; + let updatedAt: string | null = null; + let lastPrompt: string | null = null; + let fallbackTitle: string | null = null; + let visibleAfterMs: number | null = null; + let threadSource: string | null = null; + let currentSessionMetaRead = false; + let messageCount = 0; + let assistantOpen = false; + let skipNextMaintenanceAssistant = false; + + const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/).filter(Boolean); + for (const line of lines) { + const event = parseLine(line); + if (!event) continue; + const timestamp = typeof event.timestamp === "string" ? event.timestamp : null; + + if (event.type === "session_meta") { + if (!currentSessionMetaRead) { + const payload = objectValue(event.payload); + if (typeof payload?.id === "string") id = payload.id; + if (typeof payload?.cwd === "string") cwd = payload.cwd; + if (typeof payload?.thread_source === "string") threadSource = payload.thread_source; + fallbackTitle = stringValue(payload?.workshop_title) ?? stringValue(payload?.title); + visibleAfterMs = forkVisibleAfterMs(payload, timestamp); + if (timestamp) { + createdAt ??= timestamp; + updatedAt = timestamp; + } + currentSessionMetaRead = true; + } + continue; + } + + if (isBeforeVisibleForkWindow(timestamp, visibleAfterMs)) continue; + if (timestamp) { + createdAt ??= timestamp; + updatedAt = timestamp; + } + + if (event.type !== "response_item") continue; + const payload = objectValue(event.payload); + if (!payload) continue; + + if (payload.type === "message") { + const role = payload.role === "user" || payload.role === "assistant" ? payload.role : null; + if (!role) continue; + const content = stripWorkshopContext(contentText(payload.content)); + if (!content.trim()) continue; + if (role === "user") { + if (isCodexHiddenUserMessage(content)) { + skipNextMaintenanceAssistant = isCodexForkCompactUserMessage(content); + continue; + } + assistantOpen = false; + lastPrompt = content; + messageCount += 1; + continue; + } + if (skipNextMaintenanceAssistant) { + skipNextMaintenanceAssistant = false; + continue; + } + if (!assistantOpen) { + messageCount += 1; + assistantOpen = true; + } + continue; + } + + if (payload.type === "function_call" && !assistantOpen) { + messageCount += 1; + assistantOpen = true; + } + } + + const indexed = metadata.get(id); + if (!id || !cwd || threadSource === "subagent" || indexed?.threadSource === "subagent") return null; + const forkMetadata = readForkedCodexMetadata(filePath); + const title = codexSessionTitle(filePath, indexed?.title, fallbackTitle, forkMetadata); + repairForkTitleIfNeeded(filePath, id, indexed?.title, title, fallbackTitle, forkMetadata); + return { + id, + path: filePath, + cwd, + title, + is_fork: forkMetadata?.isFork ?? false, + forked_from_id: forkMetadata?.parentId ?? null, + fork_depth: codexForkDepth(forkMetadata, title), + needs_compact: forkMetadata?.needsCompact ?? false, + created_at: createdAt ?? indexed?.createdAt ?? null, + updated_at: updatedAt ?? indexed?.updatedAt ?? null, + message_count: messageCount, + loaded_message_count: 0, + messages_truncated: messageCount > 0, + last_prompt: lastPrompt, + preview: previewText(lastPrompt || indexed?.preview || null), + }; +} + +function readCodexSessionFile( + filePath: string, + metadata = readCodexSessionMetadata(), + options: CodexSessionReadOptions = {}, +): ClaudeSessionDetail | null { if (!fs.existsSync(filePath)) return null; const messages: ClaudeChatMessage[] = []; const toolBlocks = new Map>(); @@ -63,16 +630,33 @@ function readCodexSessionFile(filePath: string): ClaudeSessionDetail | null { let createdAt: string | null = null; let updatedAt: string | null = null; let lastPrompt: string | null = null; - let workshopTurnOpen = false; + let fallbackTitle: string | null = null; + let visibleAfterMs: number | null = null; + let threadSource: string | null = null; + let currentSessionMetaRead = false; let assistantBlocks: ClaudeChatMessageBlock[] = []; let assistantTimestamp: string | null = null; + let skipNextMaintenanceAssistant = false; + let messageCount = 0; + let lastUserMessage: ClaudeChatMessage | null = null; + let lastVisibleMessage: ClaudeChatMessage | null = null; + const messageLimit = options.messageLimit ?? DEFAULT_DETAIL_MESSAGE_LIMIT; + + const pushMessage = (message: ClaudeChatMessage) => { + messageCount += 1; + lastVisibleMessage = message; + if (message.role === "user") lastUserMessage = message; + if (messageLimit <= 0) return; + messages.push(message); + while (messages.length > messageLimit) messages.shift(); + }; const flushAssistant = () => { if (!assistantBlocks.length) return; const content = assistantBlocksText(assistantBlocks); if (content.trim()) { - messages.push({ - id: `${id || path.basename(filePath, ".jsonl")}-${messages.length}`, + pushMessage({ + id: `${id || path.basename(filePath, ".jsonl")}-${messageCount}`, role: "assistant", content, blocks: assistantBlocks, @@ -88,18 +672,30 @@ function readCodexSessionFile(filePath: string): ClaudeSessionDetail | null { const event = parseLine(line); if (!event) continue; const timestamp = typeof event.timestamp === "string" ? event.timestamp : null; - if (timestamp) { - createdAt ??= timestamp; - updatedAt = timestamp; - } if (event.type === "session_meta") { - const payload = objectValue(event.payload); - if (typeof payload?.id === "string") id = payload.id; - if (typeof payload?.cwd === "string") cwd = payload.cwd; + if (!currentSessionMetaRead) { + const payload = objectValue(event.payload); + if (typeof payload?.id === "string") id = payload.id; + if (typeof payload?.cwd === "string") cwd = payload.cwd; + if (typeof payload?.thread_source === "string") threadSource = payload.thread_source; + fallbackTitle = stringValue(payload?.workshop_title) ?? stringValue(payload?.title); + visibleAfterMs = forkVisibleAfterMs(payload, timestamp); + if (timestamp) { + createdAt ??= timestamp; + updatedAt = timestamp; + } + currentSessionMetaRead = true; + } continue; } + if (isBeforeVisibleForkWindow(timestamp, visibleAfterMs)) continue; + if (timestamp) { + createdAt ??= timestamp; + updatedAt = timestamp; + } + if (event.type !== "response_item") continue; const payload = objectValue(event.payload); if (!payload) continue; @@ -110,16 +706,15 @@ function readCodexSessionFile(filePath: string): ClaudeSessionDetail | null { const rawContent = contentText(payload.content); if (role === "user") { flushAssistant(); - if (!isWorkshopUserMessage(rawContent)) { - workshopTurnOpen = false; - continue; - } const content = stripWorkshopContext(rawContent); if (!content.trim()) continue; + if (isCodexHiddenUserMessage(content)) { + skipNextMaintenanceAssistant = isCodexForkCompactUserMessage(content); + continue; + } lastPrompt = content; - workshopTurnOpen = true; - messages.push({ - id: `${id || path.basename(filePath, ".jsonl")}-${messages.length}`, + pushMessage({ + id: `${id || path.basename(filePath, ".jsonl")}-${messageCount}`, role, content, blocks: [{ type: "text", text: content }], @@ -128,15 +723,17 @@ function readCodexSessionFile(filePath: string): ClaudeSessionDetail | null { continue; } - if (!workshopTurnOpen) continue; const content = stripWorkshopContext(rawContent); if (!content.trim()) continue; + if (skipNextMaintenanceAssistant) { + skipNextMaintenanceAssistant = false; + continue; + } assistantBlocks.push({ type: "text", text: content }); assistantTimestamp ??= timestamp; continue; } - if (!workshopTurnOpen) continue; if (payload.type === "function_call") { const callId = stringValue(payload.call_id) ?? `${messages.length}-${assistantBlocks.length}`; const block: Extract = { @@ -162,17 +759,33 @@ function readCodexSessionFile(filePath: string): ClaudeSessionDetail | null { } flushAssistant(); - if (!id || !cwd) return null; - const previewMessage = [...messages].reverse().find((message) => message.role === "user") ?? messages[messages.length - 1]; + const indexed = metadata.get(id); + if (!id || !cwd || threadSource === "subagent" || indexed?.threadSource === "subagent") return null; + const forkMetadata = readForkedCodexMetadata(filePath); + const title = codexSessionTitle(filePath, indexed?.title, fallbackTitle, forkMetadata); + repairForkTitleIfNeeded(filePath, id, indexed?.title, title, fallbackTitle, forkMetadata); + const previewSource = + lastPrompt || + (lastUserMessage as ClaudeChatMessage | null)?.content || + (lastVisibleMessage as ClaudeChatMessage | null)?.content || + indexed?.preview || + null; return { id, path: filePath, cwd, - created_at: createdAt, - updated_at: updatedAt, - message_count: messages.length, + title, + is_fork: forkMetadata?.isFork ?? false, + forked_from_id: forkMetadata?.parentId ?? null, + fork_depth: codexForkDepth(forkMetadata, title), + needs_compact: forkMetadata?.needsCompact ?? false, + created_at: createdAt ?? indexed?.createdAt ?? null, + updated_at: updatedAt ?? indexed?.updatedAt ?? null, + message_count: messageCount, + loaded_message_count: messages.length, + messages_truncated: messageCount > messages.length, last_prompt: lastPrompt, - preview: previewText(lastPrompt || previewMessage?.content || null), + preview: previewText(previewSource), messages, }; } @@ -196,6 +809,10 @@ function stringValue(value: unknown): string | null { return typeof value === "string" ? value : null; } +function numberValue(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + function contentText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; @@ -209,8 +826,18 @@ function contentText(content: unknown): string { .join("\n"); } -function isWorkshopUserMessage(content: string): boolean { - return content.includes("") || content.includes("Raindrop Workshop chat pane"); +function isCodexHiddenUserMessage(content: string): boolean { + return ( + content.startsWith("# AGENTS.md instructions") || + content.startsWith("") || + content.startsWith("") || + content.startsWith(""); } function stripWorkshopContext(content: string): string { @@ -223,10 +850,68 @@ function stripWorkshopContext(content: string): string { function previewText(value: string | null): string | null { if (!value) return null; - const compact = value.replace(/\s+/g, " ").trim(); + const compact = value + .replace(/]*>\s*<\/image>/gi, " ") + .replace(/\[Image #[0-9]+\]/g, " ") + .replace(/\s+/g, " ") + .trim(); return compact.length > 120 ? `${compact.slice(0, 117)}...` : compact; } +function codexSessionTitle( + filePath: string, + indexedTitle: string | null | undefined, + fallbackTitle: string | null, + forkMetadata = readForkedCodexMetadata(filePath), +): string | null { + if (forkMetadata?.isFork) { + return storedForkedCodexTitle(fallbackTitle ?? indexedTitle ?? null, filePath); + } + return indexedTitle ?? fallbackTitle ?? null; +} + +function repairForkTitleIfNeeded( + filePath: string, + sessionId: string, + indexedTitle: string | null | undefined, + title: string | null, + fallbackTitle: string | null, + forkMetadata = readForkedCodexMetadata(filePath), +) { + if (!title || !forkMetadata?.isFork) return; + if (indexedTitle === title && fallbackTitle === title) return; + updateForkedCodexSessionLine(filePath, sessionId, title); + updateCodexSqliteForkTitle(sessionId, title); +} + +function codexForkDepth(forkMetadata: CodexForkMetadata | null, title: string | null): number { + if (!forkMetadata?.isFork) return 0; + return parseForkedTitle(title ?? "").depth || 1; +} + +function forkVisibleAfterMs(payload: Record | null, eventTimestamp: string | null): number | null { + if (!payload) return null; + const isFork = stringValue(payload.originator) === "workshop_codex_fork" || !!stringValue(payload.forked_from_id); + if (!isFork) return null; + return timestampMs(stringValue(payload.workshop_visible_after) ?? stringValue(payload.timestamp) ?? eventTimestamp); +} + +function isBeforeVisibleForkWindow(timestamp: string | null, visibleAfterMs: number | null): boolean { + if (visibleAfterMs == null) return false; + const ms = timestampMs(timestamp); + return ms != null && ms < visibleAfterMs; +} + +function timestampMs(timestamp: string | null): number | null { + if (!timestamp) return null; + const ms = Date.parse(timestamp); + return Number.isFinite(ms) ? ms : null; +} + +function codexDisplayText(value: string | null): string | null { + return previewText(value ? stripWorkshopContext(value) : null); +} + function assistantBlocksText(blocks: ClaudeChatMessageBlock[]): string { return blocks .map((block) => { diff --git a/src/server.ts b/src/server.ts index 42bada6..44cf26b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,7 +17,13 @@ import { discoverReplayAgents, loadAgentsConfig, saveAgentsConfig, extractContex import { resolveBuiltAppDir } from "./ui-assets"; import { setReplayTrace } from "./replay-map"; import { getClaudeSession, getLatestClaudeLoadout, listClaudeSessions, type ClaudeLoadout } from "./claude-sessions"; -import { getCodexSession, listCodexSessions } from "./codex-sessions"; +import { + ensureForkedCodexSessionTitle, + forkCodexSession, + getCodexSession, + listCodexSessions, + markForkedCodexSessionCompacted, +} from "./codex-sessions"; import { runClaudeCliChat } from "./claude-cli-chat"; import { runCodexCliChat } from "./codex-cli-chat"; import { @@ -55,6 +61,20 @@ import { } from "./annotations"; import { replayDefaultDemoTraces } from "./demo-traces"; +const CODEX_FORK_COMPACT_PROMPT = [ + "", + "Workshop maintenance turn: compact this newly forked Codex conversation before the user's trace-debugging message is sent.", + "Do not inspect files or call tools. Reply exactly: Compacted.", +].join("\n"); +const DEFAULT_CODEX_FORK_COMPACT_TIMEOUT_MS = 25_000; + +function codexForkCompactTimeoutMs(): number { + const parsed = Number.parseInt(process.env.RAINDROP_WORKSHOP_CODEX_FORK_COMPACT_TIMEOUT_MS ?? "", 10); + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_CODEX_FORK_COMPACT_TIMEOUT_MS; +} + function parseAnnotationSource(value: unknown): AnnotationSource | null { return value === "user" || value === "claude-code" || value === "codex" ? value : null; } @@ -1115,7 +1135,7 @@ export async function createServer(port: number) { } const targetProvider = requestedProvider ?? agentProvider; if (targetProvider === "codex") { - res.json(listCodexSessions(workspace.cwd)); + res.json(listCodexSessions()); return; } res.json(listClaudeSessions(workspace.cwd)); @@ -1131,7 +1151,7 @@ export async function createServer(port: number) { const workspace = activeWorkspaceOrError(res); if (!workspace) return; if (agentProvider === "codex") { - const session = getCodexSession(workspace.cwd, req.params.id); + const session = getCodexSession(req.params.id, null, { messageLimit: parseMessageLimit(req.query.message_limit) }); if (!session) { res.status(404).json({ error: "Codex session not found" }); return; @@ -1147,6 +1167,21 @@ export async function createServer(port: number) { res.json(session); }); + app.post("/api/agent/sessions/:id/fork", (req, res) => { + const workspace = activeWorkspaceOrError(res); + if (!workspace) return; + if (agentProvider !== "codex") { + res.status(400).json({ error: "Forking existing chats is only supported for Codex." }); + return; + } + const fork = forkCodexSession(req.params.id); + if (!fork) { + res.status(404).json({ error: "Codex session to fork was not found." }); + return; + } + res.json({ session: fork }); + }); + app.post("/api/agent/messages", async (req, res) => { const { content, session_id, run_id, client_message_id } = req.body ?? {}; if (typeof content !== "string" || !content.trim()) { @@ -1166,6 +1201,17 @@ export async function createServer(port: number) { if (!workspace) return; let providerSessionId = typeof session_id === "string" && session_id ? session_id : null; + let chatCwd = workspace.cwd; + let shouldCompactForkBeforeMessage = false; + let forkedCodexTitle: string | null = null; + if (requestProvider === "codex" && providerSessionId) { + const existing = getCodexSession(providerSessionId, null, { messageLimit: 1 }); + if (existing?.cwd) chatCwd = existing.cwd; + if (existing?.needs_compact) { + forkedCodexTitle = existing.title ?? null; + shouldCompactForkBeforeMessage = true; + } + } const clientMessageId = typeof client_message_id === "string" && client_message_id ? client_message_id : randomUUID(); @@ -1182,11 +1228,64 @@ export async function createServer(port: number) { broadcast("agent_message_stream", data); if (requestProvider === "claude") broadcast("claude_message_stream", data); }; + const preserveForkTitle = () => { + if (requestProvider !== "codex" || !providerSessionId) return; + const session = ensureForkedCodexSessionTitle(providerSessionId, forkedCodexTitle); + forkedCodexTitle = session?.title ?? forkedCodexTitle; + }; try { + if (requestProvider === "codex" && shouldCompactForkBeforeMessage && providerSessionId) { + broadcastStreamEvent({ type: "status", content: "Compacting forked Codex chat..." }); + broadcastStreamEvent({ type: "provider_session", sessionId: providerSessionId }); + const compactAbort = new AbortController(); + const compactTimeoutMs = codexForkCompactTimeoutMs(); + let compactTimedOut = false; + const compactTimer = setTimeout(() => { + compactTimedOut = true; + compactAbort.abort(); + }, compactTimeoutMs); + const compactResult = await runCodexCliChat({ + backendUrl: backendUrl(), + content: CODEX_FORK_COMPACT_PROMPT, + cwd: chatCwd, + runId: null, + resumeSessionId: providerSessionId, + forceAutoCompact: true, + abortSignal: compactAbort.signal, + }, { + onEvent() {}, + onProviderSession(sessionId) { + providerSessionId = sessionId; + }, + onText() {}, + onStatus() {}, + onError(nextContent) { + errorText = nextContent; + }, + }).finally(() => { + clearTimeout(compactTimer); + preserveForkTitle(); + if (providerSessionId) markForkedCodexSessionCompacted(providerSessionId); + }); + if (compactTimedOut) { + broadcastStreamEvent({ + type: "status", + content: `Codex compact did not finish within ${Math.round(compactTimeoutMs / 1000)}s; sending without waiting for it.`, + }); + } else if (compactResult.code !== 0 || errorText) { + broadcastStreamEvent({ + type: "status", + content: errorText || compactResult.stderr || "Codex compact failed; sending without compacting.", + }); + } + errorText = ""; + } + + preserveForkTitle(); const chatInput = { backendUrl: backendUrl(), content, - cwd: workspace.cwd, + cwd: chatCwd, runId: typeof run_id === "string" ? run_id : null, resumeSessionId: providerSessionId, }; @@ -1198,6 +1297,7 @@ export async function createServer(port: number) { }, onProviderSession(sessionId) { providerSessionId = sessionId; + preserveForkTitle(); broadcastStreamEvent({ type: "provider_session", sessionId }); }, onText(nextContent) { @@ -1231,6 +1331,7 @@ export async function createServer(port: number) { }, }); if (result.code !== 0 || errorText) { + preserveForkTitle(); res.status(502).json({ error: errorText || result.stderr || `${agentProviderLabel(requestProvider)} exited with code ${result.code ?? "unknown"}`, client_message_id: clientMessageId, @@ -1239,6 +1340,7 @@ export async function createServer(port: number) { }); return; } + preserveForkTitle(); res.json({ client_message_id: clientMessageId, session_id: providerSessionId, @@ -1247,7 +1349,7 @@ export async function createServer(port: number) { session: providerSessionId ? requestProvider === "claude" ? getClaudeSession(workspace.cwd, providerSessionId) - : getCodexSession(workspace.cwd, providerSessionId) + : getCodexSession(providerSessionId) : null, }); } catch (err) { @@ -1260,6 +1362,13 @@ export async function createServer(port: number) { } }); + function parseMessageLimit(value: unknown): number { + const raw = Array.isArray(value) ? value[0] : value; + const parsed = typeof raw === "string" ? Number.parseInt(raw, 10) : NaN; + if (!Number.isFinite(parsed)) return 120; + return Math.max(20, Math.min(500, parsed)); + } + app.get("/api/claude/sessions", (_req, res) => { const workspace = activeWorkspaceOrError(res); if (!workspace) return;