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
10 changes: 10 additions & 0 deletions docs/content/docs/agents/claude.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ Expose extra tools to the agent by passing `mcpServers` to `openSession`. Both l
**Pre-install `npx`-launched servers.** A local server started with `npx -y …` writes install progress to **stdout** on its first run, which corrupts the MCP stdio handshake (you'll see `Connection closed`). Pre-install it in the VM so `npx` is silent — `await agent.process.exec("npm install -g @modelcontextprotocol/server-filesystem")` before the session — or pin the package and point `command` at the installed binary.
</Note>

## Persisting sessions across VM restarts

Session persistence is enabled for the packaged Claude Code agent. Claude Code writes each session to `CLAUDE_CONFIG_DIR`, which defaults to `/home/agentos/.claude`. The `/home/agentos` directory lives on the VM's durable storage, so persisted sessions survive VM sleep and restarts without extra configuration. Read [Persistence](/agentos/docs/persistence) for what the VM keeps across restarts.

When you prompt a session whose agent process is no longer running, agentOS restores it with the native ACP `session/resume` request. The adapter forwards that to the Claude Agent SDK's `resume` option, which runs `claude --resume <sessionId>` against the persisted session file, so the resumed session keeps its full state: conversation context, tool results, and compaction. The transcript preamble described in [Sessions](/agentos/docs/sessions#restoration) is used only when the persisted session file cannot be found. The adapter does not disable persistence and does not set `CLAUDE_CODE_SKIP_INITIAL_MESSAGES`, so the resumed session replays correctly.

<CodeSnippet file="examples/claude/client.ts" title="client.ts" region="resume" />

If you override `CLAUDE_CONFIG_DIR`, keep it under `/home/agentos` or another durable mount. Paths under `/tmp` or in-memory mounts are cleared on restart, and the resume then fails with a resource-not-found error. The session's `env` and `cwd` are stored with the session and replayed on restore, so the override applies to the resumed process too.

## Customizing the agent

Claude Code is a built-in agent, but it's just a software package under the hood. To ship your own ACP adapter, swap the underlying agent SDK, or register a tweaked build as a new agent, see [Custom Agents](/agentos/docs/agents/custom).
40 changes: 39 additions & 1 deletion examples/claude/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,44 @@ async function withMcp() {
// docs:end mcp
}

// ── Persisted sessions ────────────────────────────────────────────
//
// Claude Code writes every session to `CLAUDE_CONFIG_DIR` (default
// `/home/agentos/.claude`). That directory lives on durable VM storage, so a
// session opened before a VM restart can be resumed after it with the SDK's
// native resume rather than a transcript replay.
async function resumeAcrossRestart() {
// docs:start resume
// Keep CLAUDE_CONFIG_DIR under /home/agentos so the session file survives
// VM restarts. This is the default; set it explicitly only to relocate it.
const sessionId = "assistant";
await agent.sessions.open({
sessionId,
agent: "claude",
env: {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!,
CLAUDE_CONFIG_DIR: "/home/agentos/.claude",
},
});

await agent.sessions.prompt({
sessionId,
content: [
{ type: "text", text: "Remember that my favorite color is teal." },
],
});

// Later, after the VM has slept or restarted, prompt the same session id.
// agentOS restores it through ACP `session/resume`, which runs
// `claude --resume <sessionId>` against the persisted session file.
const result = await agent.sessions.prompt({
sessionId,
content: [{ type: "text", text: "What is my favorite color?" }],
});
console.log(result.message?.content ?? []);
// docs:end resume
}

// ── Skills + MCP together ─────────────────────────────────────────
async function withSkillAndMcp() {
const skill = `---
Expand Down Expand Up @@ -146,4 +184,4 @@ Write commit messages in the imperative mood and keep the subject under 50 chara
console.log(result.message?.content ?? []);
}

export { quickStart, withSkill, withMcp, withSkillAndMcp };
export { quickStart, withSkill, withMcp, resumeAcrossRestart, withSkillAndMcp };
176 changes: 175 additions & 1 deletion software/claude/tests/adapter.test.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path";
import { Readable, Writable } from "node:stream";
import { resolve as resolvePath } from "node:path";
import {
ClientSideConnection,
PROTOCOL_VERSION,
Expand Down Expand Up @@ -73,6 +76,39 @@ async function withAdapter(run, extraEnv = {}) {
}
}

/** Recursively collect every `<sessionId>.jsonl` Claude Code wrote under `dir`. */
function findPersistedSessionFiles(dir, sessionId) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (error.code === "ENOENT") return [];
throw error;
}
return entries.flatMap((entry) => {
const path = join(dir, entry.name);
if (entry.isDirectory()) return findPersistedSessionFiles(path, sessionId);
return entry.name === `${sessionId}.jsonl` ? [path] : [];
});
}

/** Flatten an LLMock-normalized chat message into its text fragments. */
function messageTexts(message) {
if (typeof message.content === "string") return [message.content];
if (!Array.isArray(message.content)) return [];
return message.content.flatMap((part) =>
typeof part?.text === "string" ? [part.text] : [],
);
}

async function initialize(connection) {
return await connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {},
clientInfo: { name: "agentos-test", version: "0.0.1" },
});
}

test("published Claude Agent ACP command initializes over stdio", async () => {
await withAdapter(async (connection) => {
const result = await connection.initialize({
Expand Down Expand Up @@ -239,3 +275,141 @@ test("published Claude Agent ACP prompts a second process while the first remain
await mock.stop();
}
});

test("published Claude Agent ACP resumes a persisted session natively after its adapter process restarts", async () => {
// The packaged agent keeps Claude Code's session directory on durable
// storage (`CLAUDE_CONFIG_DIR`, `/home/agentos/.claude` by default). agentOS
// restores a session after a VM restart with ACP `session/resume`, which the
// adapter maps onto the SDK `resume` option: Claude Code reloads its own
// persisted session file, so the resumed turn carries the prior context
// without any transcript being re-sent by agentOS.
const configDir = mkdtempSync(join(tmpdir(), "agentos-claude-config-"));
const mock = new LLMock({ port: 0, logLevel: "silent" });
mock.addFixtures([
{
match: { userMessage: "Reply with resume-first" },
response: { content: "resume-first" },
},
{
match: { userMessage: "Reply with resume-second" },
response: { content: "resume-second" },
},
]);
const baseUrl = await mock.start();
const env = { ANTHROPIC_BASE_URL: baseUrl, CLAUDE_CONFIG_DIR: configDir };
try {
const sessionId = await withAdapter(async (connection) => {
await initialize(connection);
const session = await connection.newSession({
cwd: packageDir,
mcpServers: [],
});
const first = await connection.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "Reply with resume-first" }],
});
assert.equal(first.stopReason, "end_turn");
return session.sessionId;
}, env);

// Session persistence is on by default: Claude Code wrote the session
// file under CLAUDE_CONFIG_DIR before its adapter process exited.
const persisted = findPersistedSessionFiles(
join(configDir, "projects"),
sessionId,
);
assert.equal(
persisted.length,
1,
`expected one persisted session file for ${sessionId} under ${configDir}`,
);

const requestsBeforeResume = mock.getRequests().length;
await withAdapter(async (connection) => {
await initialize(connection);
const resumed = await connection.resumeSession({
sessionId,
cwd: packageDir,
mcpServers: [],
});
assert.ok(
resumed.configOptions?.some((option) => option.id === "model"),
"resumed session must expose the model config option",
);
assert.ok(
resumed.configOptions?.some((option) => option.id === "effort"),
"resumed session must expose the agentOS effort config option",
);
const second = await connection.prompt({
sessionId,
prompt: [{ type: "text", text: "Reply with resume-second" }],
});
assert.equal(second.stopReason, "end_turn");
}, env);

const resumedTurn = mock
.getRequests()
.slice(requestsBeforeResume)
.map((entry) => entry.body?.messages ?? [])
.find((messages) =>
messages.some(
(message) =>
message.role === "user" &&
messageTexts(message).some((text) =>
text.includes("Reply with resume-second"),
),
),
);
assert.ok(resumedTurn, "the resumed prompt must reach the model");
const priorUser = resumedTurn.filter(
(message) =>
message.role === "user" &&
messageTexts(message).some((text) =>
text.includes("Reply with resume-first"),
),
);
const priorAssistant = resumedTurn.filter(
(message) =>
message.role === "assistant" &&
messageTexts(message).some((text) => text.includes("resume-first")),
);
assert.equal(
priorUser.length,
1,
"native resume must restore the prior user turn from the persisted session file",
);
assert.equal(
priorAssistant.length,
1,
"native resume must restore the prior assistant turn from the persisted session file",
);
} finally {
await mock.stop();
rmSync(configDir, { recursive: true, force: true });
}
});

test("published Claude Agent ACP reports a session missing from CLAUDE_CONFIG_DIR as resource not found", async () => {
// A session directory that did not survive the restart must surface the ACP
// resource-not-found error so agentOS can take its documented fallback path
// instead of silently starting an unrelated session.
const configDir = mkdtempSync(join(tmpdir(), "agentos-claude-config-"));
try {
await withAdapter(async (connection) => {
await initialize(connection);
await assert.rejects(
connection.resumeSession({
sessionId: randomUUID(),
cwd: packageDir,
mcpServers: [],
}),
(error) => {
assert.equal(error.code, -32002, `unexpected error: ${error.message}`);
return true;
},
);
}, { CLAUDE_CONFIG_DIR: configDir });
} finally {
rmSync(configDir, { recursive: true, force: true });
}
});