diff --git a/src/agent/transcript/__tests__/transcript-middleware.test.ts b/src/agent/transcript/__tests__/transcript-middleware.test.ts
new file mode 100644
index 0000000..88ae4bf
--- /dev/null
+++ b/src/agent/transcript/__tests__/transcript-middleware.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it, beforeEach, afterEach } from "bun:test";
+import { mkdtempSync, rmSync, readFileSync, readdirSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+
+import { createTranscriptMiddleware } from "../transcript-middleware";
+
+describe("createTranscriptMiddleware", () => {
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = mkdtempSync(join(tmpdir(), "transcript-mw-test-"));
+ });
+ afterEach(() => {
+ rmSync(tmpDir, { recursive: true });
+ });
+
+ it("writes existing messages on beforeAgentRun", async () => {
+ const mw = createTranscriptMiddleware({ cwd: tmpDir, projectDir: tmpDir });
+ const agentContext = {
+ prompt: "test",
+ messages: [{ role: "user" as const, content: [{ type: "text" as const, text: "hello" }] }],
+ };
+
+ await mw.beforeAgentRun!({ agentContext: agentContext as any });
+
+ const files = readdirSync(tmpDir).filter((f: string) => f.endsWith(".jsonl"));
+ expect(files).toHaveLength(1);
+
+ const content = readFileSync(join(tmpDir, files[0]!), "utf-8").trim();
+ const parsed = JSON.parse(content);
+ expect(parsed.type).toBe("user");
+ });
+
+ it("incrementally writes new messages on afterAgentStep", async () => {
+ const mw = createTranscriptMiddleware({ cwd: tmpDir, projectDir: tmpDir });
+ const agentContext = {
+ prompt: "test",
+ messages: [{ role: "user" as const, content: [{ type: "text" as const, text: "hello" }] }],
+ };
+
+ await mw.beforeAgentRun!({ agentContext: agentContext as any });
+
+ agentContext.messages.push(
+ { role: "assistant" as const, content: [{ type: "text" as const, text: "hi" }] },
+ );
+ await mw.afterAgentStep!({ agentContext: agentContext as any, step: 1 });
+
+ const files = readdirSync(tmpDir).filter((f: string) => f.endsWith(".jsonl"));
+ const lines = readFileSync(join(tmpDir, files[0]!), "utf-8").trim().split("\n");
+ expect(lines).toHaveLength(2);
+ });
+});
diff --git a/src/agent/transcript/__tests__/transcript-storage.test.ts b/src/agent/transcript/__tests__/transcript-storage.test.ts
new file mode 100644
index 0000000..3c116e1
--- /dev/null
+++ b/src/agent/transcript/__tests__/transcript-storage.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it, beforeEach, afterEach } from "bun:test";
+import { mkdtempSync, rmSync, readFileSync, utimesSync } from "fs";
+import { join } from "path";
+import { tmpdir } from "os";
+
+import { sanitizeCwd, getProjectDir, appendEntry, loadTranscript, listSessions } from "../transcript-storage";
+
+describe("sanitizeCwd", () => {
+ it("replaces path separators and colons with dashes", () => {
+ expect(sanitizeCwd("E:\\Github\\helixent")).toBe("E-Github-helixent");
+ expect(sanitizeCwd("/home/user/project")).toBe("-home-user-project");
+ });
+});
+
+describe("getProjectDir", () => {
+ it("returns path under ~/.helixent/projects/ with sanitized cwd", () => {
+ const dir = getProjectDir("/home/user/myproject");
+ expect(dir).toContain(".helixent");
+ expect(dir).toContain("projects");
+ expect(dir).toContain("-home-user-myproject");
+ });
+});
+
+describe("appendEntry", () => {
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = mkdtempSync(join(tmpdir(), "transcript-test-"));
+ });
+ afterEach(() => {
+ rmSync(tmpDir, { recursive: true });
+ });
+
+ it("appends a JSONL line to the file", () => {
+ const filePath = join(tmpDir, "test.jsonl");
+ const message = { role: "user" as const, content: [{ type: "text" as const, text: "hello" }] };
+ appendEntry(filePath, message);
+
+ const content = readFileSync(filePath, "utf-8");
+ const parsed = JSON.parse(content.trim());
+ expect(parsed.type).toBe("user");
+ expect(parsed.message).toEqual(message);
+ expect(parsed.timestamp).toBeDefined();
+ });
+
+ it("appends multiple entries on separate lines", () => {
+ const filePath = join(tmpDir, "test.jsonl");
+ const msg1 = { role: "user" as const, content: [{ type: "text" as const, text: "hi" }] };
+ const msg2 = { role: "assistant" as const, content: [{ type: "text" as const, text: "hello" }] };
+ appendEntry(filePath, msg1);
+ appendEntry(filePath, msg2);
+
+ const lines = readFileSync(filePath, "utf-8").trim().split("\n");
+ expect(lines).toHaveLength(2);
+ });
+});
+
+describe("loadTranscript", () => {
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = mkdtempSync(join(tmpdir(), "transcript-test-"));
+ });
+ afterEach(() => {
+ rmSync(tmpDir, { recursive: true });
+ });
+
+ it("reads JSONL and returns messages array", () => {
+ const filePath = join(tmpDir, "session.jsonl");
+ const msg = { role: "user" as const, content: [{ type: "text" as const, text: "test" }] };
+ appendEntry(filePath, msg);
+
+ const messages = loadTranscript(filePath);
+ expect(messages).toHaveLength(1);
+ expect(messages[0]!.role).toBe("user");
+ });
+
+ it("returns empty array for non-existent file", () => {
+ const messages = loadTranscript(join(tmpDir, "nope.jsonl"));
+ expect(messages).toHaveLength(0);
+ });
+});
+
+describe("listSessions", () => {
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = mkdtempSync(join(tmpdir(), "transcript-test-"));
+ });
+ afterEach(() => {
+ rmSync(tmpDir, { recursive: true });
+ });
+
+ it("returns empty array when no sessions exist", () => {
+ const sessions = listSessions(tmpDir);
+ expect(sessions).toHaveLength(0);
+ });
+
+ it("returns sessions sorted by mtime descending", () => {
+ const msg = { role: "user" as const, content: [{ type: "text" as const, text: "hi" }] };
+ const path1 = join(tmpDir, "aaa-111.jsonl");
+ const path2 = join(tmpDir, "bbb-222.jsonl");
+ appendEntry(path1, msg);
+ appendEntry(path1, msg);
+ appendEntry(path2, msg);
+ // Force different mtimes
+ const older = new Date(Date.now() - 10000);
+ const newer = new Date(Date.now());
+ utimesSync(path1, older, older);
+ utimesSync(path2, newer, newer);
+
+ const sessions = listSessions(tmpDir);
+ expect(sessions.length).toBeGreaterThanOrEqual(2);
+ expect(sessions[0]!.id).toBe("bbb-222");
+ expect(sessions[0]!.messageCount).toBe(1);
+ expect(sessions[1]!.id).toBe("aaa-111");
+ expect(sessions[1]!.messageCount).toBe(2);
+ });
+
+ it("respects limit parameter", () => {
+ const msg = { role: "user" as const, content: [{ type: "text" as const, text: "hi" }] };
+ appendEntry(join(tmpDir, "a.jsonl"), msg);
+ appendEntry(join(tmpDir, "b.jsonl"), msg);
+ appendEntry(join(tmpDir, "c.jsonl"), msg);
+
+ const sessions = listSessions(tmpDir, 2);
+ expect(sessions).toHaveLength(2);
+ });
+
+ it("returns empty array for non-existent directory", () => {
+ const sessions = listSessions(join(tmpDir, "nonexistent"));
+ expect(sessions).toHaveLength(0);
+ });
+});
diff --git a/src/agent/transcript/index.ts b/src/agent/transcript/index.ts
new file mode 100644
index 0000000..7e214b3
--- /dev/null
+++ b/src/agent/transcript/index.ts
@@ -0,0 +1,3 @@
+export { createTranscriptMiddleware } from "./transcript-middleware";
+export { loadTranscript, getLatestSessionPath, getProjectDir, listSessions } from "./transcript-storage";
+export type { SessionInfo } from "./transcript-storage";
diff --git a/src/agent/transcript/transcript-middleware.ts b/src/agent/transcript/transcript-middleware.ts
new file mode 100644
index 0000000..4967277
--- /dev/null
+++ b/src/agent/transcript/transcript-middleware.ts
@@ -0,0 +1,39 @@
+import { randomUUID } from "crypto";
+import { join } from "path";
+
+import type { AgentMiddleware } from "../agent-middleware";
+import { appendEntry, getProjectDir } from "./transcript-storage";
+
+/**
+ * Creates a middleware that persists all messages to a JSONL transcript file.
+ */
+export function createTranscriptMiddleware(options: {
+ cwd: string;
+ projectDir?: string;
+}): AgentMiddleware {
+ let transcriptPath: string;
+ let lastWrittenIndex = 0;
+
+ return {
+ beforeAgentRun: async ({ agentContext }) => {
+ const sessionId = randomUUID();
+ const dir = options.projectDir ?? getProjectDir(options.cwd);
+ transcriptPath = join(dir, `${sessionId}.jsonl`);
+
+ for (const msg of agentContext.messages) {
+ appendEntry(transcriptPath, msg);
+ }
+ lastWrittenIndex = agentContext.messages.length;
+ return null;
+ },
+
+ afterAgentStep: async ({ agentContext }) => {
+ const newMessages = agentContext.messages.slice(lastWrittenIndex);
+ for (const msg of newMessages) {
+ appendEntry(transcriptPath, msg);
+ }
+ lastWrittenIndex = agentContext.messages.length;
+ return null;
+ },
+ };
+}
diff --git a/src/agent/transcript/transcript-storage.ts b/src/agent/transcript/transcript-storage.ts
new file mode 100644
index 0000000..74e5aae
--- /dev/null
+++ b/src/agent/transcript/transcript-storage.ts
@@ -0,0 +1,94 @@
+import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from "fs";
+import { dirname, join } from "path";
+import { homedir } from "os";
+
+import type { NonSystemMessage } from "@/foundation";
+
+/**
+ * Replace path separators and colons with dashes for safe directory names.
+ */
+export function sanitizeCwd(cwd: string): string {
+ return cwd.replace(/[/\\:]+/g, "-");
+}
+
+/**
+ * Get the project-specific transcript directory.
+ */
+export function getProjectDir(cwd: string): string {
+ return join(homedir(), ".helixent", "projects", sanitizeCwd(cwd));
+}
+
+/**
+ * Get the path to the most recent session file for a project directory.
+ * Returns null if no sessions exist.
+ */
+export function getLatestSessionPath(cwd: string): string | null {
+ const projectDir = getProjectDir(cwd);
+ if (!existsSync(projectDir)) return null;
+
+ const files = readdirSync(projectDir)
+ .filter((f) => f.endsWith(".jsonl"))
+ .map((f) => ({ name: f, mtime: statSync(join(projectDir, f)).mtimeMs }))
+ .sort((a, b) => b.mtime - a.mtime);
+
+ return files.length > 0 ? join(projectDir, files[0]!.name) : null;
+}
+
+/**
+ * Append a message entry to the transcript file.
+ */
+export function appendEntry(filePath: string, message: NonSystemMessage): void {
+ const entry = {
+ type: message.role,
+ timestamp: new Date().toISOString(),
+ message,
+ };
+ const dir = dirname(filePath);
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
+ }
+ appendFileSync(filePath, JSON.stringify(entry) + "\n", { mode: 0o600 });
+}
+
+export type SessionInfo = {
+ id: string;
+ path: string;
+ mtime: Date;
+ messageCount: number;
+};
+
+/**
+ * List available sessions for a project directory, sorted by most recent first.
+ */
+export function listSessions(projectDir: string, limit = 5): SessionInfo[] {
+ if (!existsSync(projectDir)) return [];
+
+ return readdirSync(projectDir)
+ .filter((f) => f.endsWith(".jsonl"))
+ .map((f) => {
+ const filePath = join(projectDir, f);
+ const stat = statSync(filePath);
+ const content = readFileSync(filePath, "utf-8");
+ const lineCount = content.split("\n").filter(Boolean).length;
+ return {
+ id: f.replace(".jsonl", ""),
+ path: filePath,
+ mtime: stat.mtime,
+ messageCount: lineCount,
+ };
+ })
+ .sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
+ .slice(0, limit);
+}
+
+/**
+ * Load all messages from a transcript file.
+ */
+export function loadTranscript(filePath: string): NonSystemMessage[] {
+ if (!existsSync(filePath)) return [];
+ const content = readFileSync(filePath, "utf-8");
+ return content
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => JSON.parse(line).message as NonSystemMessage);
+}
diff --git a/src/cli/tui/app.tsx b/src/cli/tui/app.tsx
index 4b8ad6c..6400e25 100644
--- a/src/cli/tui/app.tsx
+++ b/src/cli/tui/app.tsx
@@ -10,6 +10,7 @@ import { Footer } from "./components/footer";
import { Header } from "./components/header";
import { InputBox } from "./components/input-box";
import { MessageHistoryItem } from "./components/message-history";
+import { ResumePrompt } from "./components/resume-prompt";
import { StreamingIndicator } from "./components/streaming-indicator";
import { TodoPanel } from "./components/todo-panel";
import { useAgentLoop } from "./hooks/use-agent-loop";
@@ -29,7 +30,7 @@ export function App({
commands: SlashCommand[];
supportProjectWideAllow?: boolean;
}) {
- const { streaming, messages, onSubmit, abort } = useAgentLoop();
+ const { streaming, messages, onSubmit, abort, resumeRequest, handleResumeSelect } = useAgentLoop();
const { approvalRequest, respondToApproval } = useApprovalManager();
const { askUserQuestionRequest, respondWithAnswers } = useAskUserQuestionManager();
const { latestTodos, todoSnapshots } = useMemo(() => buildTodoViewState(messages), [messages]);
@@ -72,6 +73,8 @@ export function App({
questions={askUserQuestionRequest.params.questions}
onSubmit={respondWithAnswers}
/>
+ ) : resumeRequest ? (
+
) : (
)}
diff --git a/src/cli/tui/command-registry.ts b/src/cli/tui/command-registry.ts
index 514598a..47f33b1 100644
--- a/src/cli/tui/command-registry.ts
+++ b/src/cli/tui/command-registry.ts
@@ -33,6 +33,11 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [
description: "Exit the TUI session",
type: "builtin",
},
+ {
+ name: "resume",
+ description: "Resume a previous session from this project",
+ type: "builtin",
+ },
];
/** Parsed builtin invocation: command name plus any trailing argument string. */
diff --git a/src/cli/tui/components/resume-prompt.tsx b/src/cli/tui/components/resume-prompt.tsx
new file mode 100644
index 0000000..bd171f2
--- /dev/null
+++ b/src/cli/tui/components/resume-prompt.tsx
@@ -0,0 +1,77 @@
+import { Box, Text, useInput } from "ink";
+import React, { useMemo, useState } from "react";
+
+import type { SessionInfo } from "@/agent/transcript";
+
+function formatDate(date: Date): string {
+ const y = date.getFullYear();
+ const mo = String(date.getMonth() + 1).padStart(2, "0");
+ const d = String(date.getDate()).padStart(2, "0");
+ const h = String(date.getHours()).padStart(2, "0");
+ const mi = String(date.getMinutes()).padStart(2, "0");
+ return `${y}-${mo}-${d} ${h}:${mi}`;
+}
+
+export function ResumePrompt({
+ sessions,
+ onSelect,
+}: {
+ sessions: SessionInfo[];
+ onSelect: (session: SessionInfo | null) => void;
+}) {
+ const options = useMemo(
+ () => [...sessions.map((s) => ({ type: "session" as const, session: s })), { type: "cancel" as const }],
+ [sessions],
+ );
+ const [index, setIndex] = useState(0);
+
+ useInput((_input, key) => {
+ if (key.upArrow) {
+ setIndex((i) => (i > 0 ? i - 1 : options.length - 1));
+ return;
+ }
+ if (key.downArrow) {
+ setIndex((i) => (i < options.length - 1 ? i + 1 : 0));
+ return;
+ }
+ if (key.return) {
+ const selected = options[index]!;
+ if (selected.type === "cancel") {
+ onSelect(null);
+ } else {
+ onSelect(selected.session);
+ }
+ return;
+ }
+ if (key.escape) {
+ onSelect(null);
+ }
+ });
+
+ return (
+
+ Resume a previous session:
+
+ {options.map((opt, i) => {
+ const marker = i === index ? ">" : " ";
+ const color = i === index ? "cyan" : undefined;
+ if (opt.type === "cancel") {
+ return (
+
+ {marker} [Cancel]
+
+ );
+ }
+ return (
+
+ {marker} {formatDate(opt.session.mtime)} ({opt.session.messageCount} messages)
+
+ );
+ })}
+
+
+ Up/Down to move, Enter to select, Esc to cancel
+
+
+ );
+}
diff --git a/src/cli/tui/hooks/use-agent-loop.ts b/src/cli/tui/hooks/use-agent-loop.ts
index 28dcddd..bdd5ed2 100644
--- a/src/cli/tui/hooks/use-agent-loop.ts
+++ b/src/cli/tui/hooks/use-agent-loop.ts
@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
import type { Agent } from "@/agent";
import type { AssistantMessage, NonSystemMessage, UserMessage } from "@/foundation";
+import { listSessions, getProjectDir, loadTranscript } from "@/agent/transcript";
+import type { SessionInfo } from "@/agent/transcript";
import type { PromptSubmission, SlashCommand } from "../command-registry";
import { formatHelp, resolveBuiltinCommand } from "../command-registry";
@@ -15,6 +17,9 @@ type AgentLoopState = {
onSubmit: (submission: PromptSubmission) => Promise;
abort: () => void;
tokenCount: number;
+ resumeRequest: SessionInfo[] | null;
+ // eslint-disable-next-line no-unused-vars
+ handleResumeSelect: (session: SessionInfo | null) => void;
};
const AgentLoopContext = createContext(null);
@@ -30,6 +35,7 @@ export function AgentLoopProvider({
}) {
const [streaming, setStreaming] = useState(false);
const [messages, setMessages] = useState([]);
+ const [resumeRequest, setResumeRequest] = useState(null);
const streamingRef = useRef(streaming);
const pendingMessagesRef = useRef([]);
@@ -79,6 +85,26 @@ export function AgentLoopProvider({
return calculateTotalTokens(messages);
}, [messages]);
+ const handleResumeSelect = useCallback(
+ (session: SessionInfo | null) => {
+ setResumeRequest(null);
+ if (!session) return;
+ agent.clearMessages();
+ const restored = loadTranscript(session.path);
+ for (const msg of restored) {
+ agent.messages.push(msg);
+ }
+ flushPendingMessages();
+ const summaryMsg: AssistantMessage = {
+ role: "assistant",
+ content: [{ type: "text", text: `Resumed session with ${restored.length} messages.` }],
+ };
+ const recentMessages = getLastCompleteTurn(restored);
+ setMessages([summaryMsg, ...recentMessages]);
+ },
+ [agent, flushPendingMessages],
+ );
+
const onSubmit = useCallback(
async (submission: PromptSubmission) => {
const { text, requestedSkillName } = submission;
@@ -115,6 +141,20 @@ export function AgentLoopProvider({
return;
}
+ if (invocation?.name === "resume") {
+ const sessions = listSessions(getProjectDir(process.cwd()));
+ if (sessions.length === 0) {
+ const noSessionMsg: AssistantMessage = {
+ role: "assistant",
+ content: [{ type: "text", text: "No previous sessions found." }],
+ };
+ setMessages((prev) => [...prev, noSessionMsg]);
+ } else {
+ setResumeRequest(sessions);
+ }
+ return;
+ }
+
setStreaming(true);
try {
@@ -133,7 +173,12 @@ export function AgentLoopProvider({
}
} catch (error) {
if (isAbortError(error)) return;
- throw error;
+ // Display API/model errors as assistant messages instead of crashing
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ enqueueMessage({
+ role: "assistant",
+ content: [{ type: "text", text: `Error: ${errorMessage}\n\nYou can try again.` }],
+ });
} finally {
agent.setRequestedSkillName(null);
flushPendingMessages();
@@ -151,8 +196,10 @@ export function AgentLoopProvider({
onSubmit,
abort,
tokenCount,
+ resumeRequest,
+ handleResumeSelect,
}),
- [abort, agent, messages, onSubmit, streaming, tokenCount],
+ [abort, agent, messages, onSubmit, streaming, tokenCount, resumeRequest, handleResumeSelect],
);
return createElement(AgentLoopContext.Provider, { value }, children);
@@ -192,3 +239,23 @@ function clearTerminal() {
if (!process.stdout.isTTY) return;
process.stdout.write("\u001B[2J\u001B[3J\u001B[H");
}
+
+/**
+ * Extract the last user question and the last assistant reply from
+ * the restored messages. Shows where the user left off.
+ */
+function getLastCompleteTurn(messages: NonSystemMessage[]): NonSystemMessage[] {
+ // Find the last user message
+ let lastUserIdx = -1;
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i]!.role === "user") {
+ lastUserIdx = i;
+ break;
+ }
+ }
+
+ if (lastUserIdx === -1) return messages.slice(-2);
+
+ // Return the last user message and everything after it (the assistant's response)
+ return messages.slice(lastUserIdx);
+}
diff --git a/src/coding/agents/lead-agent.ts b/src/coding/agents/lead-agent.ts
index 736b4f5..06397a1 100644
--- a/src/coding/agents/lead-agent.ts
+++ b/src/coding/agents/lead-agent.ts
@@ -3,6 +3,7 @@ import { join } from "path";
import { Agent } from "@/agent";
import { createSkillsMiddleware } from "@/agent/skills/skills-middleware";
import { createTodoSystem } from "@/agent/todos/todos";
+import { createTranscriptMiddleware } from "@/agent/transcript";
import type { Model, NonSystemMessage, ToolUseContent } from "@/foundation";
import {
@@ -63,7 +64,7 @@ export async function createCodingAgent({
const askUserQuestionTool = askUserQuestion ? createAskUserQuestionTool(askUserQuestion) : null;
- const middlewares = [createSkillsMiddleware(skillsDirs), todoMiddleware];
+ const middlewares = [createSkillsMiddleware(skillsDirs), todoMiddleware, createTranscriptMiddleware({ cwd })];
if (askUser) {
middlewares.push(
createCodingApprovalMiddleware({