Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions packages/agent/src/persistent-agent-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
lstat,
mkdir,
mkdtemp,
readFile,
readlink,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { configurePersistentAgentState } from "./persistent-agent-state";

describe("configurePersistentAgentState", () => {
let testRoot: string;
let homeDir: string;
let stateRoot: string;

beforeEach(async () => {
testRoot = await mkdtemp(path.join(tmpdir(), "persistent-agent-state-"));
homeDir = path.join(testRoot, "home");
stateRoot = path.join(testRoot, "workspace", ".posthog", "agent-state");
vi.stubEnv("CLAUDE_CONFIG_DIR", "");
vi.stubEnv("CODEX_HOME", "");
});

afterEach(async () => {
vi.unstubAllEnvs();
await rm(testRoot, { recursive: true, force: true });
});

it("routes native session state into the durable workspace root", async () => {
const claudeSkillsDir = path.join(homeDir, ".claude", "skills");
const codexConfigPath = path.join(homeDir, ".codex", "config.toml");
await mkdir(claudeSkillsDir, { recursive: true });
await mkdir(path.dirname(codexConfigPath), { recursive: true });
await writeFile(path.join(claudeSkillsDir, "catalog.txt"), "fresh");
await writeFile(codexConfigPath, "fresh = true");

await configurePersistentAgentState(stateRoot, homeDir);

const mappings = [
[".claude/projects", "claude/projects"],
[".claude/session-env", "claude/session-env"],
[".claude/plans", "claude/plans"],
[".claude/todos", "claude/todos"],
[".codex/sessions", "codex/sessions"],
[".codex/shell_snapshots", "codex/shell_snapshots"],
];

for (const [sourceRelative, targetRelative] of mappings) {
const source = path.join(homeDir, sourceRelative);
const target = path.join(stateRoot, targetRelative);
expect((await lstat(source)).isSymbolicLink()).toBe(true);
expect(path.resolve(path.dirname(source), await readlink(source))).toBe(
target,
);
await writeFile(path.join(source, "marker"), sourceRelative);
await expect(
readFile(path.join(target, "marker"), "utf-8"),
).resolves.toBe(sourceRelative);
}

await configurePersistentAgentState(stateRoot, homeDir);

await expect(
readFile(path.join(claudeSkillsDir, "catalog.txt"), "utf-8"),
).resolves.toBe("fresh");
await expect(readFile(codexConfigPath, "utf-8")).resolves.toBe(
"fresh = true",
);
});
});
85 changes: 85 additions & 0 deletions packages/agent/src/persistent-agent-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { lstat, mkdir, readlink, rm, symlink } from "node:fs/promises";
import os from "node:os";
import path from "node:path";

interface PersistentDirectory {
source: string;
target: string;
}

function isMissingPathError(error: unknown): boolean {
return (
error instanceof Error &&
"code" in error &&
(error as NodeJS.ErrnoException).code === "ENOENT"
);
}

async function linkPersistentDirectory({
source,
target,
}: PersistentDirectory): Promise<void> {
const resolvedSource = path.resolve(source);
const resolvedTarget = path.resolve(target);
if (resolvedSource === resolvedTarget) return;

await mkdir(resolvedTarget, { recursive: true });
await mkdir(path.dirname(resolvedSource), { recursive: true });

try {
const sourceStats = await lstat(resolvedSource);
if (sourceStats.isSymbolicLink()) {
const currentTarget = path.resolve(
path.dirname(resolvedSource),
await readlink(resolvedSource),
);
if (currentTarget === resolvedTarget) return;
}
await rm(resolvedSource, { recursive: true, force: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 First Opt-In Deletes Sessions

When POSTHOG_AGENT_STATE_DIR is enabled after Claude or Codex has already written native state, an existing projects or sessions directory reaches this rm before any migration happens. The directory is deleted and then replaced with a symlink to an empty durable target, so previously resumable native sessions are lost instead of being preserved.

} catch (error) {
if (!isMissingPathError(error)) throw error;
}

await symlink(resolvedTarget, resolvedSource, "dir");
}

export async function configurePersistentAgentState(
stateRoot: string,
homeDir: string = os.homedir(),
): Promise<void> {
if (!path.isAbsolute(stateRoot)) {
throw new Error("Persistent agent state root must be an absolute path");
}

const claudeConfigDir =
process.env.CLAUDE_CONFIG_DIR || path.join(homeDir, ".claude");
const codexHome = process.env.CODEX_HOME || path.join(homeDir, ".codex");
const directories: PersistentDirectory[] = [
{
source: path.join(claudeConfigDir, "projects"),
target: path.join(stateRoot, "claude", "projects"),
},
{
source: path.join(claudeConfigDir, "session-env"),
target: path.join(stateRoot, "claude", "session-env"),
},
{
source: path.join(claudeConfigDir, "plans"),
target: path.join(stateRoot, "claude", "plans"),
},
{
source: path.join(claudeConfigDir, "todos"),
target: path.join(stateRoot, "claude", "todos"),
},
{
source: path.join(codexHome, "sessions"),
target: path.join(stateRoot, "codex", "sessions"),
},
{
source: path.join(codexHome, "shell_snapshots"),
target: path.join(stateRoot, "codex", "shell_snapshots"),
},
];

await Promise.all(directories.map(linkPersistentDirectory));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Partial Symlink State Persists

If one mapping fails with a filesystem error while the other Promise.all entries are still running, some source directories may already have been removed and symlinked while others remain untouched. AgentServer.start() runs this before serve(), so that failure can stop the server and leave the next start with mixed Claude or Codex state paths.

}
46 changes: 45 additions & 1 deletion packages/agent/src/server/agent-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import {
lstat,
mkdir,
mkdtemp,
readFile,
readlink,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ContentBlock } from "@agentclientprotocol/sdk";
Expand Down Expand Up @@ -441,6 +449,42 @@ describe("AgentServer HTTP Mode", () => {
sessionInitMs: expect.any(Number),
});
}, 30000);

it("links native agent state before initializing the session", async () => {
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
const originalCodexHome = process.env.CODEX_HOME;
const claudeConfigDir = join(repo.path, ".claude-test");
const codexHome = join(repo.path, ".codex-test");
const agentStateDir = join(repo.path, ".posthog", "agent-state");
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir;
process.env.CODEX_HOME = codexHome;

try {
await createServer({ agentStateDir }).start();

const claudeProjects = join(claudeConfigDir, "projects");
const codexSessions = join(codexHome, "sessions");
expect((await lstat(claudeProjects)).isSymbolicLink()).toBe(true);
expect((await lstat(codexSessions)).isSymbolicLink()).toBe(true);
expect(await readlink(claudeProjects)).toBe(
join(agentStateDir, "claude", "projects"),
);
expect(await readlink(codexSessions)).toBe(
join(agentStateDir, "codex", "sessions"),
);
} finally {
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
}
if (originalCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = originalCodexHome;
}
}
}, 30000);
});

describe("turn completion", () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
import type { PermissionMode } from "../execution-mode";
import { DEFAULT_CODEX_MODEL, fetchGatewayModels } from "../gateway-models";
import { HandoffCheckpointTracker } from "../handoff-checkpoint";
import { configurePersistentAgentState } from "../persistent-agent-state";
import { PostHogAPIClient } from "../posthog-api";
import {
findPrUrls,
Expand Down Expand Up @@ -635,6 +636,10 @@ export class AgentServer {
}

async start(): Promise<void> {
if (this.config.agentStateDir) {
await configurePersistentAgentState(this.config.agentStateDir);
}

await new Promise<void>((resolve) => {
this.server = serve(
{
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/server/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const envSchema = z.object({
POSTHOG_CODE_REASONING_EFFORT: z
.enum(["low", "medium", "high", "xhigh", "max"])
.optional(),
POSTHOG_AGENT_STATE_DIR: z.string().startsWith("/").optional(),
POSTHOG_TASK_RUN_EVENT_INGEST_TOKEN: z.string().min(1).optional(),
// Base URL for the event-ingest POST only; falls back to POSTHOG_API_URL when unset.
POSTHOG_TASK_RUN_EVENT_INGEST_URL: z.url().optional(),
Expand Down Expand Up @@ -174,6 +175,7 @@ program

const server = new AgentServer({
port: parseInt(options.port, 10),
agentStateDir: env.POSTHOG_AGENT_STATE_DIR,
jwtPublicKey: env.JWT_PUBLIC_KEY,
eventIngestToken: env.POSTHOG_TASK_RUN_EVENT_INGEST_TOKEN,
eventIngestBaseUrl: env.POSTHOG_TASK_RUN_EVENT_INGEST_URL,
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface ClaudeCodeConfig {

export interface AgentServerConfig {
port: number;
agentStateDir?: string;
repositoryPath?: string;
repoReadyFile?: string;
apiUrl: string;
Expand Down
Loading