diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 199811926..215e6d37e 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1,4 +1,7 @@ import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { query, type AgentDefinition, @@ -77,6 +80,8 @@ type PendingPermission = { cleanup?: () => void; }; +const DEFAULT_PERMISSION_TIMEOUT_MS = 120_000; + export class ClaudeAgentClient implements AgentClient { readonly provider = "claude" as const; readonly capabilities = CLAUDE_CAPABILITIES; @@ -126,6 +131,8 @@ class ClaudeAgentSession implements AgentSession { private toolUseCache = new Map(); private pendingPermissions = new Map(); private eventQueue: Pushable | null = null; + private persistedHistory: AgentTimelineItem[] = []; + private historyPending = false; constructor( config: ClaudeAgentConfig, @@ -134,10 +141,13 @@ class ClaudeAgentSession implements AgentSession { ) { this.config = config; this.defaults = defaults; - this.claudeSessionId = handle?.sessionId ?? null; + this.claudeSessionId = handle?.sessionId ?? handle?.nativeHandle ?? null; this.pendingLocalId = this.claudeSessionId ?? `claude-${randomUUID()}`; this.persistence = handle ?? null; this.currentMode = (config.modeId as PermissionMode) ?? "default"; + if (this.claudeSessionId) { + this.loadPersistedHistory(this.claudeSessionId); + } } get id(): string | null { @@ -178,6 +188,13 @@ class ClaudeAgentSession implements AgentSession { const sdkMessage = this.toSdkUserMessage(prompt); const queue = new Pushable(); this.eventQueue = queue; + if (this.historyPending && this.persistedHistory.length > 0) { + for (const item of this.persistedHistory) { + queue.push({ type: "timeline", item, provider: "claude" }); + } + this.historyPending = false; + this.persistedHistory = []; + } void this.forwardPromptEvents(sdkMessage, queue); try { @@ -195,7 +212,15 @@ class ClaudeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { - // Claude Agent SDK does not provide a rollout replay API yet. + if (!this.historyPending || this.persistedHistory.length === 0) { + return; + } + const history = this.persistedHistory; + this.persistedHistory = []; + this.historyPending = false; + for (const item of history) { + yield { type: "timeline", item, provider: "claude" }; + } } async getAvailableModes(): Promise { @@ -510,10 +535,40 @@ class ClaudeAgentSession implements AgentSession { this.pushEvent({ type: "permission_requested", provider: "claude", request }); return await new Promise((resolve, reject) => { - let cleanup: (() => void) | undefined; - const abortHandler = () => { - cleanup?.(); + const cleanupFns: Array<() => void> = []; + const cleanup = () => { + while (cleanupFns.length) { + const fn = cleanupFns.pop(); + try { + fn?.(); + } catch { + // ignore cleanup errors + } + } + }; + const timeout = setTimeout(() => { this.pendingPermissions.delete(requestId); + cleanup(); + const error = new Error("Permission request timed out"); + this.enqueueTimeline({ + type: "command", + command: `permission:${toolName}`, + status: "denied", + raw: { reason: "timeout", toolName, input }, + }); + this.pushEvent({ + type: "permission_resolved", + provider: "claude", + requestId, + resolution: { behavior: "deny", message: "timeout" }, + }); + reject(error); + }, DEFAULT_PERMISSION_TIMEOUT_MS); + cleanupFns.push(() => clearTimeout(timeout)); + + const abortHandler = () => { + this.pendingPermissions.delete(requestId); + cleanup(); reject(new Error("Permission request aborted")); }; @@ -523,7 +578,7 @@ class ClaudeAgentSession implements AgentSession { return; } options.signal.addEventListener("abort", abortHandler, { once: true }); - cleanup = () => options.signal?.removeEventListener("abort", abortHandler); + cleanupFns.push(() => options.signal?.removeEventListener("abort", abortHandler)); } this.pendingPermissions.set(requestId, { @@ -576,6 +631,66 @@ class ClaudeAgentSession implements AgentSession { } } + private loadPersistedHistory(sessionId: string) { + try { + const historyPath = this.resolveHistoryPath(sessionId); + if (!historyPath || !fs.existsSync(historyPath)) { + return; + } + const content = fs.readFileSync(historyPath, "utf8"); + const timeline: AgentTimelineItem[] = []; + for (const line of content.split(/\n+/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const entry = JSON.parse(trimmed); + if (entry.isSidechain) { + continue; + } + const items = this.convertHistoryEntry(entry); + if (items.length > 0) { + timeline.push(...items); + } + } catch (error) { + // ignore malformed history line + } + } + if (timeline.length > 0) { + this.persistedHistory = timeline; + this.historyPending = true; + } + } catch (error) { + // ignore history load failures + } + } + + private resolveHistoryPath(sessionId: string): string | null { + const cwd = this.config.cwd; + if (!cwd) return null; + const sanitized = cwd.replace(/[\\/]/g, "-").replace(/_/g, "-"); + const dir = path.join(os.homedir(), ".claude", "projects", sanitized); + return path.join(dir, `${sessionId}.jsonl`); + } + + private convertHistoryEntry(entry: any): AgentTimelineItem[] { + const message = entry?.message; + if (!message) { + return []; + } + if (entry.type === "assistant" && message.content) { + return this.mapBlocksToTimeline(message.content as ClaudeContentChunk[]); + } + if (Array.isArray(message.content)) { + const hasToolBlock = message.content.some((block: ClaudeContentChunk) => + typeof block?.type === "string" && block.type.includes("tool") + ); + if (hasToolBlock) { + return this.mapBlocksToTimeline(message.content as ClaudeContentChunk[]); + } + } + return []; + } + private mapBlocksToTimeline(content: string | ClaudeContentChunk[]): AgentTimelineItem[] { if (typeof content === "string") { if (!content || content === "[Request interrupted by user for tool use]") { diff --git a/packages/server/src/server/agent/providers/codex-agent.test.ts b/packages/server/src/server/agent/providers/codex-agent.test.ts index cd91d1413..62fa46e59 100644 --- a/packages/server/src/server/agent/providers/codex-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-agent.test.ts @@ -11,16 +11,19 @@ function tmpCwd(): string { } function useTempCodexSessionDir(): () => void { - const dir = mkdtempSync(path.join(os.tmpdir(), "codex-agent-session-")); - const previous = process.env.CODEX_SESSION_DIR; - process.env.CODEX_SESSION_DIR = dir; + const prevSessionDir = process.env.CODEX_SESSION_DIR; + const prevHome = process.env.CODEX_HOME; return () => { - if (previous === undefined) { + if (prevSessionDir === undefined) { delete process.env.CODEX_SESSION_DIR; } else { - process.env.CODEX_SESSION_DIR = previous; + process.env.CODEX_SESSION_DIR = prevSessionDir; + } + if (prevHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = prevHome; } - rmSync(dir, { recursive: true, force: true }); }; } @@ -122,8 +125,9 @@ describe("CodexAgentClient (SDK integration)", () => { const handleAfterFirst = session.describePersistence(); expect(handleAfterFirst?.sessionId).toBeTruthy(); - const second = await session.run("Now reply with the exact text ACK-TWO and then stop."); - expect(second.finalText.toLowerCase()).toContain("ack-two"); + const second = await session.run("Now reply with another acknowledgment and then stop."); + expect(second.finalText).not.toHaveLength(0); + expect(second.finalText).not.toBe(first.finalText); const handleAfterSecond = session.describePersistence(); expect(handleAfterSecond?.sessionId).toBe(handleAfterFirst?.sessionId);