From 6b82b2e6830edeb85d25ace2ee160a50f5f6bd18 Mon Sep 17 00:00:00 2001 From: hannah-15 Date: Fri, 17 Apr 2026 11:10:20 -0700 Subject: [PATCH] feat(cli): add file-based session store with --resume flag --- src/cli/index.tsx | 52 +++++++++++- src/cli/sessions/index.ts | 2 + src/cli/sessions/session-store.test.ts | 105 +++++++++++++++++++++++++ src/cli/sessions/session-store.ts | 57 ++++++++++++++ src/cli/tui/components/footer.tsx | 5 +- src/cli/tui/hooks/use-agent-loop.ts | 41 +++++++++- src/coding/agents/lead-agent.ts | 6 ++ 7 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 src/cli/sessions/index.ts create mode 100644 src/cli/sessions/session-store.test.ts create mode 100644 src/cli/sessions/session-store.ts diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 006c6f4..a8c4323 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -6,11 +6,12 @@ import { render } from "ink"; import { validateIntegrity } from "@/cli/bootstrap"; import { registerCommands } from "@/cli/commands"; import { loadConfig } from "@/cli/config"; +import { listSessions, loadSession } from "@/cli/sessions"; import { SettingsLoader, SettingsWriter } from "@/cli/settings"; import { createCodingAgent, globalApprovalManager, globalAskUserQuestionManager } from "@/coding"; import { AnthropicModelProvider } from "@/community/anthropic"; import { OpenAIModelProvider } from "@/community/openai"; -import type { ModelProvider } from "@/foundation"; +import type { ModelProvider, NonSystemMessage } from "@/foundation"; import { Model } from "@/foundation"; import { App } from "./tui"; @@ -28,12 +29,50 @@ registerCommands(program); const args = process.argv.slice(2); -if (args.length > 0) { +// Parse `--resume [sessionId]` manually so the TUI can boot without triggering +// commander's default "no subcommand → print help" exit. Any other non-empty +// argv is passed to commander for subcommand dispatch (e.g. `config`). +function parseResumeFlag(argv: string[]): { present: boolean; sessionId?: string } { + const idx = argv.indexOf("--resume"); + if (idx === -1) return { present: false }; + const next = argv[idx + 1]; + const sessionId = next && !next.startsWith("-") ? next : undefined; + return { present: true, sessionId }; +} + +const resumeFlag = parseResumeFlag(args); +const remainingArgs = args.filter((arg, i) => { + if (arg === "--resume") return false; + if (resumeFlag.sessionId && i > 0 && args[i - 1] === "--resume") return false; + return true; +}); + +if (remainingArgs.length > 0) { await program.parseAsync(process.argv); } else { console.info(); await validateIntegrity(); + // Session lookup requires HELIXENT_HOME, which validateIntegrity() ensures. + let sessionId: string | undefined; + let sessionCreatedAt: string | undefined; + let resumeMessages: NonSystemMessage[] | undefined; + + if (resumeFlag.present) { + const session = resumeFlag.sessionId ? loadSession(resumeFlag.sessionId) : listSessions()[0]; + if (!session) { + const hint = resumeFlag.sessionId + ? `Session "${resumeFlag.sessionId}" not found.` + : "No saved sessions found."; + console.error(hint); + process.exit(1); + } + sessionId = session.id; + sessionCreatedAt = session.createdAt; + resumeMessages = session.messages; + console.info(`Resuming session ${session.id} (${session.messages.length} messages)\n`); + } + const config = loadConfig(); const defaultModelName = config.defaultModel ?? config.models[0]?.name; const entry = defaultModelName ? config.models.find((m) => m.name === defaultModelName) : undefined; @@ -80,11 +119,18 @@ if (args.length > 0) { loadAllowList: (cwd) => settingsLoader.loadAllowList(cwd), persistAllowedTool: (cwd, toolName) => settingsWriter.appendAllowedTool(cwd, toolName), }, + resumeMessages, }); const commands: SlashCommand[] = await loadAvailableCommands(skillsDirs); render( - + , { patchConsole: false }, diff --git a/src/cli/sessions/index.ts b/src/cli/sessions/index.ts new file mode 100644 index 0000000..0e29a98 --- /dev/null +++ b/src/cli/sessions/index.ts @@ -0,0 +1,2 @@ +export { generateSessionId, listSessions, loadSession, saveSession } from "./session-store"; +export type { Session } from "./session-store"; diff --git a/src/cli/sessions/session-store.test.ts b/src/cli/sessions/session-store.test.ts new file mode 100644 index 0000000..35a5712 --- /dev/null +++ b/src/cli/sessions/session-store.test.ts @@ -0,0 +1,105 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import type { NonSystemMessage } from "@/foundation"; + +import { generateSessionId, listSessions, loadSession, saveSession, type Session } from "./session-store"; + +let tmpHome: string; +let originalHome: string | undefined; + +beforeEach(() => { + tmpHome = mkdtempSync(path.join(tmpdir(), "helixent-session-test-")); + originalHome = Bun.env.HELIXENT_HOME; + Bun.env.HELIXENT_HOME = tmpHome; +}); + +afterEach(() => { + if (originalHome === undefined) delete Bun.env.HELIXENT_HOME; + else Bun.env.HELIXENT_HOME = originalHome; + rmSync(tmpHome, { recursive: true, force: true }); +}); + +const sampleMessages: NonSystemMessage[] = [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + { role: "assistant", content: [{ type: "text", text: "hi there" }] }, +]; + +function makeSession(overrides: Partial = {}): Session { + return { + id: "abcd1234", + createdAt: "2026-04-17T00:00:00.000Z", + updatedAt: "2026-04-17T00:00:01.000Z", + cwd: "/tmp/project", + messages: sampleMessages, + ...overrides, + }; +} + +describe("session-store", () => { + test("save → load round-trip preserves all fields", () => { + const original = makeSession(); + saveSession(original); + const loaded = loadSession(original.id); + expect(loaded).toEqual(original); + }); + + test("loadSession returns null for missing id", () => { + expect(loadSession("does-not-exist")).toBeNull(); + }); + + test("loadSession returns null for corrupt JSON (does not throw)", () => { + const dir = path.join(tmpHome, "sessions"); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, "broken.json"), "{ not valid json", "utf8"); + expect(loadSession("broken")).toBeNull(); + }); + + test("listSessions returns sessions sorted by updatedAt desc", () => { + saveSession(makeSession({ id: "old00000", updatedAt: "2026-04-01T00:00:00.000Z" })); + saveSession(makeSession({ id: "mid00000", updatedAt: "2026-04-10T00:00:00.000Z" })); + saveSession(makeSession({ id: "new00000", updatedAt: "2026-04-15T00:00:00.000Z" })); + const ids = listSessions().map((s) => s.id); + expect(ids).toEqual(["new00000", "mid00000", "old00000"]); + }); + + test("listSessions skips corrupt files without throwing", () => { + saveSession(makeSession({ id: "good0001" })); + const dir = path.join(tmpHome, "sessions"); + writeFileSync(path.join(dir, "corrupt.json"), "{ bad", "utf8"); + const ids = listSessions().map((s) => s.id); + expect(ids).toEqual(["good0001"]); + }); + + test("listSessions returns [] when no sessions dir exists", () => { + expect(listSessions()).toEqual([]); + }); + + test("saveSession overwrites existing session with same id", () => { + saveSession(makeSession({ id: "same0000", updatedAt: "2026-04-01T00:00:00.000Z" })); + saveSession( + makeSession({ + id: "same0000", + updatedAt: "2026-04-02T00:00:00.000Z", + messages: [{ role: "user", content: [{ type: "text", text: "updated" }] }], + }), + ); + const loaded = loadSession("same0000"); + expect(loaded?.updatedAt).toBe("2026-04-02T00:00:00.000Z"); + expect(loaded?.messages).toHaveLength(1); + }); + + test("generateSessionId returns 8-character string", () => { + const id = generateSessionId(); + expect(id).toHaveLength(8); + expect(id).toMatch(/^[0-9a-f]{8}$/); + }); + + test("generateSessionId returns unique ids across calls", () => { + const ids = new Set(Array.from({ length: 100 }, () => generateSessionId())); + expect(ids.size).toBe(100); + }); +}); diff --git a/src/cli/sessions/session-store.ts b/src/cli/sessions/session-store.ts new file mode 100644 index 0000000..0163014 --- /dev/null +++ b/src/cli/sessions/session-store.ts @@ -0,0 +1,57 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import { getHelixentHomePath } from "@/cli/config"; +import type { NonSystemMessage } from "@/foundation"; + +export interface Session { + id: string; + createdAt: string; + updatedAt: string; + cwd: string; + /** All conversation messages visible in the TUI (excludes the AGENTS.md preamble). */ + messages: NonSystemMessage[]; +} + +function getSessionsDir(): string { + return path.join(getHelixentHomePath(), "sessions"); +} + +function getSessionFilePath(sessionId: string): string { + return path.join(getSessionsDir(), `${sessionId}.json`); +} + +export function generateSessionId(): string { + return crypto.randomUUID().slice(0, 8); +} + +export function saveSession(session: Session): void { + const dir = getSessionsDir(); + mkdirSync(dir, { recursive: true }); + writeFileSync(getSessionFilePath(session.id), JSON.stringify(session), "utf8"); +} + +export function loadSession(sessionId: string): Session | null { + const filePath = getSessionFilePath(sessionId); + if (!existsSync(filePath)) return null; + try { + return JSON.parse(readFileSync(filePath, "utf8")) as Session; + } catch { + return null; + } +} + +export function listSessions(): Session[] { + const dir = getSessionsDir(); + if (!existsSync(dir)) return []; + const sessions: Session[] = []; + for (const file of readdirSync(dir)) { + if (!file.endsWith(".json")) continue; + try { + sessions.push(JSON.parse(readFileSync(path.join(dir, file), "utf8")) as Session); + } catch { + // skip corrupt files + } + } + return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} diff --git a/src/cli/tui/components/footer.tsx b/src/cli/tui/components/footer.tsx index f662ea6..f87f657 100644 --- a/src/cli/tui/components/footer.tsx +++ b/src/cli/tui/components/footer.tsx @@ -11,13 +11,14 @@ function formatTokenCount(count: number): string { } export function Footer() { - const { agent, tokenCount } = useAgentLoop(); + const { agent, sessionId, tokenCount } = useAgentLoop(); return ( {agent.model.name} - + + session {sessionId} {formatTokenCount(tokenCount)} tokens diff --git a/src/cli/tui/hooks/use-agent-loop.ts b/src/cli/tui/hooks/use-agent-loop.ts index 28dcddd..2a4990f 100644 --- a/src/cli/tui/hooks/use-agent-loop.ts +++ b/src/cli/tui/hooks/use-agent-loop.ts @@ -2,6 +2,7 @@ import { createContext, createElement, useCallback, useContext, useEffect, useMe import type { ReactNode } from "react"; import type { Agent } from "@/agent"; +import { generateSessionId, saveSession } from "@/cli/sessions"; import type { AssistantMessage, NonSystemMessage, UserMessage } from "@/foundation"; import type { PromptSubmission, SlashCommand } from "../command-registry"; @@ -11,6 +12,7 @@ type AgentLoopState = { agent: Agent; streaming: boolean; messages: NonSystemMessage[]; + sessionId: string; // eslint-disable-next-line no-unused-vars onSubmit: (submission: PromptSubmission) => Promise; abort: () => void; @@ -22,14 +24,26 @@ const AgentLoopContext = createContext(null); export function AgentLoopProvider({ agent, commands = [], + sessionId: initialSessionId, + sessionCreatedAt: initialSessionCreatedAt, + initialMessages = [], children, }: { agent: Agent; commands?: SlashCommand[]; + /** Session ID to persist the conversation under. A new one is generated if omitted. */ + sessionId?: string; + /** Original createdAt of the session; preserved across saves when resuming. */ + sessionCreatedAt?: string; + /** Pre-populated messages when resuming a saved session. */ + initialMessages?: NonSystemMessage[]; children: ReactNode; }) { const [streaming, setStreaming] = useState(false); - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState(initialMessages); + const sessionIdRef = useRef(initialSessionId ?? generateSessionId()); + const sessionCreatedAtRef = useRef(initialSessionCreatedAt ?? new Date().toISOString()); + const messagesRef = useRef(initialMessages); const streamingRef = useRef(streaming); const pendingMessagesRef = useRef([]); @@ -48,7 +62,11 @@ export function AgentLoopProvider({ const pending = pendingMessagesRef.current; pendingMessagesRef.current = []; - setMessages((prev) => [...prev, ...pending]); + setMessages((prev) => { + const next = [...prev, ...pending]; + messagesRef.current = next; + return next; + }); }, []); const enqueueMessage = useCallback( @@ -94,6 +112,7 @@ export function AgentLoopProvider({ if (invocation?.name === "clear") { agent.clearMessages(); flushPendingMessages(); + messagesRef.current = []; setMessages([]); clearTerminal(); return; @@ -120,7 +139,11 @@ export function AgentLoopProvider({ try { agent.setRequestedSkillName(requestedSkillName); const userMessage: UserMessage = { role: "user", content: [{ type: "text", text }] }; - setMessages((prev) => [...prev, userMessage]); + setMessages((prev) => { + const next = [...prev, userMessage]; + messagesRef.current = next; + return next; + }); const stream = agent.stream(userMessage); for await (const event of stream) { @@ -138,6 +161,17 @@ export function AgentLoopProvider({ agent.setRequestedSkillName(null); flushPendingMessages(); setStreaming(false); + try { + saveSession({ + id: sessionIdRef.current, + createdAt: sessionCreatedAtRef.current, + updatedAt: new Date().toISOString(), + cwd: process.cwd(), + messages: messagesRef.current, + }); + } catch { + // session save is best-effort; never crash the TUI + } } }, [agent, commands, enqueueMessage, flushPendingMessages], @@ -148,6 +182,7 @@ export function AgentLoopProvider({ agent, streaming, messages, + sessionId: sessionIdRef.current, onSubmit, abort, tokenCount, diff --git a/src/coding/agents/lead-agent.ts b/src/coding/agents/lead-agent.ts index 736b4f5..3009795 100644 --- a/src/coding/agents/lead-agent.ts +++ b/src/coding/agents/lead-agent.ts @@ -35,6 +35,7 @@ export async function createCodingAgent({ askUser, askUserQuestion, approvalPersistence, + resumeMessages, }: { model: Model; cwd?: string; @@ -44,6 +45,8 @@ export async function createCodingAgent({ // eslint-disable-next-line no-unused-vars askUserQuestion?: (params: AskUserQuestionParameters) => Promise; approvalPersistence?: ApprovalPersistence; + /** Prior conversation messages to restore when resuming a saved session. */ + resumeMessages?: NonSystemMessage[]; }) { const agentsFile = Bun.file(`${cwd}/AGENTS.md`); const messages: NonSystemMessage[] = []; @@ -59,6 +62,9 @@ export async function createCodingAgent({ ], }); } + if (resumeMessages?.length) { + messages.push(...resumeMessages); + } const { tool: todoTool, middleware: todoMiddleware } = createTodoSystem(); const askUserQuestionTool = askUserQuestion ? createAskUserQuestionTool(askUserQuestion) : null;