diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx
index 07bd55a..64e4de4 100644
--- a/docs/quickstart.mdx
+++ b/docs/quickstart.mdx
@@ -22,36 +22,15 @@ icon: "rocket"
-
- Each coding agent requires API keys to connect to their respective LLM providers.
-
- ```bash
- export ANTHROPIC_API_KEY="sk-ant-..."
- export OPENAI_API_KEY="sk-..."
- ```
-
-
-
- Use `sandbox-agent credentials extract-env --export` to extract your existing API keys (Anthropic, OpenAI, etc.) from local Claude Code or Codex config files.
-
-
- Use the `mock` agent for SDK and integration testing without provider credentials.
-
-
- For per-tenant token tracking, budget enforcement, or usage-based billing, see [LLM Credentials](/llm-credentials) for gateway options like OpenRouter, LiteLLM, and Portkey.
-
-
-
-
- `SandboxAgent.start()` provisions a sandbox, starts a lightweight [Sandbox Agent server](/architecture) inside it, and connects your SDK client.
+ `SandboxAgent.start()` provisions a sandbox, starts a lightweight [Sandbox Agent server](/architecture) inside it, and connects your SDK client. Pass your LLM API keys so the agent can reach its provider.
```typescript Local
import { SandboxAgent } from "sandbox-agent";
import { local } from "sandbox-agent/local";
- // Runs on your machine. Best for local development and testing.
+ // Runs on your machine. Inherits process.env automatically.
const sdk = await SandboxAgent.start({
sandbox: local(),
});
@@ -62,7 +41,15 @@ icon: "rocket"
import { e2b } from "sandbox-agent/e2b";
const sdk = await SandboxAgent.start({
- sandbox: e2b({ create: { envs } }),
+ sandbox: e2b({
+ create: {
+ // Pass whichever keys your agent needs
+ envs: {
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY,
+ },
+ },
+ }),
});
```
@@ -71,7 +58,14 @@ icon: "rocket"
import { daytona } from "sandbox-agent/daytona";
const sdk = await SandboxAgent.start({
- sandbox: daytona({ create: { envVars } }),
+ sandbox: daytona({
+ create: {
+ envVars: {
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY,
+ },
+ },
+ }),
});
```
@@ -80,7 +74,15 @@ icon: "rocket"
import { vercel } from "sandbox-agent/vercel";
const sdk = await SandboxAgent.start({
- sandbox: vercel({ create: { runtime: "node24", env } }),
+ sandbox: vercel({
+ create: {
+ runtime: "node24",
+ env: {
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY,
+ },
+ },
+ }),
});
```
@@ -100,13 +102,16 @@ icon: "rocket"
// Good for testing. Not security-hardened like cloud sandboxes.
const sdk = await SandboxAgent.start({
sandbox: docker({
- env: [`ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`],
+ env: [
+ `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`,
+ `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`,
+ ],
}),
});
```
- Each provider handles provisioning, server installation, and networking. Install the provider's peer dependency (e.g. `@e2b/code-interpreter`, `dockerode`) in your project. See the [Deploy](/deploy/local) guides for full setup details.
+ Each provider handles provisioning, server installation, and networking. Install the provider's peer dependency (e.g. `@e2b/code-interpreter`, `dockerode`) in your project. See the [Deploy](/deploy/local) guides for full setup details. For multi-tenant billing, per-user keys, and gateway options, see [LLM Credentials](/llm-credentials).
@@ -212,10 +217,14 @@ icon: "rocket"
```typescript
import { SandboxAgent } from "sandbox-agent";
-import { local } from "sandbox-agent/local";
+import { e2b } from "sandbox-agent/e2b";
const sdk = await SandboxAgent.start({
- sandbox: local(),
+ sandbox: e2b({
+ create: {
+ envs: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY },
+ },
+ }),
});
try {
diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx
index 1b8a707..a0f9b84 100644
--- a/docs/sdk-overview.mdx
+++ b/docs/sdk-overview.mdx
@@ -23,12 +23,6 @@ The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgent` class.
-## Optional persistence drivers
-
-```bash
-npm install @sandbox-agent/persist-indexeddb@0.3.x @sandbox-agent/persist-sqlite@0.3.x @sandbox-agent/persist-postgres@0.3.x
-```
-
## Optional React components
```bash
@@ -68,15 +62,12 @@ const sdk = await SandboxAgent.connect({
controller.abort();
```
-With persistence:
+With persistence (see [Persisting Sessions](/session-persistence) for driver options):
```ts
-import { SandboxAgent } from "sandbox-agent";
-import { SQLiteSessionPersistDriver } from "@sandbox-agent/persist-sqlite";
+import { SandboxAgent, InMemorySessionPersistDriver } from "sandbox-agent";
-const persist = new SQLiteSessionPersistDriver({
- filename: "./sessions.db",
-});
+const persist = new InMemorySessionPersistDriver();
const sdk = await SandboxAgent.connect({
baseUrl: "http://127.0.0.1:2468",
diff --git a/docs/session-persistence.mdx b/docs/session-persistence.mdx
index b8328ec..8f26457 100644
--- a/docs/session-persistence.mdx
+++ b/docs/session-persistence.mdx
@@ -15,9 +15,9 @@ Each driver stores:
## Persistence drivers
-### In-memory
+### In-memory (built-in)
-Best for local dev and ephemeral workloads.
+Best for local dev and ephemeral workloads. No extra dependencies required.
```ts
import { InMemorySessionPersistDriver, SandboxAgent } from "sandbox-agent";
@@ -33,91 +33,17 @@ const sdk = await SandboxAgent.connect({
});
```
-### Rivet
-
-Recommended for sandbox orchestration with actor state.
-
-```bash
-npm install @sandbox-agent/persist-rivet@0.3.x
-```
-
-```ts
-import { actor } from "rivetkit";
-import { SandboxAgent } from "sandbox-agent";
-import { RivetSessionPersistDriver, type RivetPersistState } from "@sandbox-agent/persist-rivet";
-
-type PersistedState = RivetPersistState & {
- sandboxId: string;
- baseUrl: string;
-};
-
-export default actor({
- createState: async () => {
- return {
- sandboxId: "sbx_123",
- baseUrl: "http://127.0.0.1:2468",
- } satisfies Partial;
- },
- createVars: async (c) => {
- const persist = new RivetSessionPersistDriver(c);
- const sdk = await SandboxAgent.connect({
- baseUrl: c.state.baseUrl,
- persist,
- });
-
- const session = await sdk.resumeOrCreateSession({ id: "default", agent: "codex" });
-
- const unsubscribe = session.onEvent((event) => {
- c.broadcast("session.event", event);
- });
-
- return { sdk, session, unsubscribe };
- },
- actions: {
- sendMessage: async (c, message: string) => {
- await c.vars.session.prompt([{ type: "text", text: message }]);
- },
- },
- onSleep: async (c) => {
- c.vars.unsubscribe?.();
- await c.vars.sdk.dispose();
- },
-});
-```
-
-### IndexedDB
-
-Best for browser apps that should survive reloads.
-
-```bash
-npm install @sandbox-agent/persist-indexeddb@0.3.x
-```
-
-```ts
-import { SandboxAgent } from "sandbox-agent";
-import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb";
-
-const persist = new IndexedDbSessionPersistDriver({
- databaseName: "sandbox-agent-session-store",
-});
-
-const sdk = await SandboxAgent.connect({
- baseUrl: "http://127.0.0.1:2468",
- persist,
-});
-```
-
### SQLite
Best for local/server Node apps that need durable storage without a DB server.
```bash
-npm install @sandbox-agent/persist-sqlite@0.3.x
+npm install better-sqlite3
```
```ts
import { SandboxAgent } from "sandbox-agent";
-import { SQLiteSessionPersistDriver } from "@sandbox-agent/persist-sqlite";
+import { SQLiteSessionPersistDriver } from "./persist.ts";
const persist = new SQLiteSessionPersistDriver({
filename: "./sandbox-agent.db",
@@ -129,17 +55,19 @@ const sdk = await SandboxAgent.connect({
});
```
+See the [full SQLite example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-sqlite) for the complete driver implementation you can copy into your project.
+
### Postgres
Use when you already run Postgres and want shared relational storage.
```bash
-npm install @sandbox-agent/persist-postgres@0.3.x
+npm install pg
```
```ts
import { SandboxAgent } from "sandbox-agent";
-import { PostgresSessionPersistDriver } from "@sandbox-agent/persist-postgres";
+import { PostgresSessionPersistDriver } from "./persist.ts";
const persist = new PostgresSessionPersistDriver({
connectionString: process.env.DATABASE_URL,
@@ -152,6 +80,16 @@ const sdk = await SandboxAgent.connect({
});
```
+See the [full Postgres example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-postgres) for the complete driver implementation you can copy into your project.
+
+### IndexedDB (browser)
+
+Best for browser apps that should survive reloads. See the [Inspector source](https://github.com/nichochar/sandbox-agent/tree/main/frontend/packages/inspector/src/persist-indexeddb.ts) for a complete IndexedDB driver you can copy into your project.
+
+### Rivet
+
+Recommended for sandbox orchestration with actor state. See [Multiplayer](/multiplayer) for a full Rivet actor example with inline persistence.
+
### Custom driver
Implement `SessionPersistDriver` for custom backends.
diff --git a/examples/persist-postgres/package.json b/examples/persist-postgres/package.json
index 8114ffb..8445516 100644
--- a/examples/persist-postgres/package.json
+++ b/examples/persist-postgres/package.json
@@ -8,7 +8,6 @@
},
"dependencies": {
"@sandbox-agent/example-shared": "workspace:*",
- "@sandbox-agent/persist-postgres": "workspace:*",
"pg": "latest",
"sandbox-agent": "workspace:*"
},
diff --git a/examples/persist-postgres/src/index.ts b/examples/persist-postgres/src/index.ts
index 73f9f04..43eecbd 100644
--- a/examples/persist-postgres/src/index.ts
+++ b/examples/persist-postgres/src/index.ts
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { Client } from "pg";
import { setTimeout as delay } from "node:timers/promises";
import { SandboxAgent } from "sandbox-agent";
-import { PostgresSessionPersistDriver } from "@sandbox-agent/persist-postgres";
+import { PostgresSessionPersistDriver } from "./persist.ts";
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
import { detectAgent } from "@sandbox-agent/example-shared";
diff --git a/examples/persist-postgres/src/persist.ts b/examples/persist-postgres/src/persist.ts
new file mode 100644
index 0000000..8b79791
--- /dev/null
+++ b/examples/persist-postgres/src/persist.ts
@@ -0,0 +1,316 @@
+import { Pool, type PoolConfig } from "pg";
+import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
+
+const DEFAULT_LIST_LIMIT = 100;
+
+export interface PostgresSessionPersistDriverOptions {
+ connectionString?: string;
+ pool?: Pool;
+ poolConfig?: PoolConfig;
+ schema?: string;
+}
+
+export class PostgresSessionPersistDriver implements SessionPersistDriver {
+ private readonly pool: Pool;
+ private readonly ownsPool: boolean;
+ private readonly schema: string;
+ private readonly initialized: Promise;
+
+ constructor(options: PostgresSessionPersistDriverOptions = {}) {
+ this.schema = normalizeSchema(options.schema ?? "public");
+
+ if (options.pool) {
+ this.pool = options.pool;
+ this.ownsPool = false;
+ } else {
+ this.pool = new Pool({
+ connectionString: options.connectionString,
+ ...options.poolConfig,
+ });
+ this.ownsPool = true;
+ }
+
+ this.initialized = this.initialize();
+ }
+
+ async getSession(id: string): Promise {
+ await this.ready();
+
+ const result = await this.pool.query(
+ `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
+ FROM ${this.table("sessions")}
+ WHERE id = $1`,
+ [id],
+ );
+
+ if (result.rows.length === 0) {
+ return undefined;
+ }
+
+ return decodeSessionRow(result.rows[0]);
+ }
+
+ async listSessions(request: ListPageRequest = {}): Promise> {
+ await this.ready();
+
+ const offset = parseCursor(request.cursor);
+ const limit = normalizeLimit(request.limit);
+
+ const rowsResult = await this.pool.query(
+ `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
+ FROM ${this.table("sessions")}
+ ORDER BY created_at ASC, id ASC
+ LIMIT $1 OFFSET $2`,
+ [limit, offset],
+ );
+
+ const countResult = await this.pool.query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${this.table("sessions")}`);
+ const total = parseInteger(countResult.rows[0]?.count ?? "0");
+ const nextOffset = offset + rowsResult.rows.length;
+
+ return {
+ items: rowsResult.rows.map(decodeSessionRow),
+ nextCursor: nextOffset < total ? String(nextOffset) : undefined,
+ };
+ }
+
+ async updateSession(session: SessionRecord): Promise {
+ await this.ready();
+
+ await this.pool.query(
+ `INSERT INTO ${this.table("sessions")} (
+ id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ ON CONFLICT(id) DO UPDATE SET
+ agent = EXCLUDED.agent,
+ agent_session_id = EXCLUDED.agent_session_id,
+ last_connection_id = EXCLUDED.last_connection_id,
+ created_at = EXCLUDED.created_at,
+ destroyed_at = EXCLUDED.destroyed_at,
+ sandbox_id = EXCLUDED.sandbox_id,
+ session_init_json = EXCLUDED.session_init_json`,
+ [
+ session.id,
+ session.agent,
+ session.agentSessionId,
+ session.lastConnectionId,
+ session.createdAt,
+ session.destroyedAt ?? null,
+ session.sandboxId ?? null,
+ session.sessionInit ?? null,
+ ],
+ );
+ }
+
+ async listEvents(request: ListEventsRequest): Promise> {
+ await this.ready();
+
+ const offset = parseCursor(request.cursor);
+ const limit = normalizeLimit(request.limit);
+
+ const rowsResult = await this.pool.query(
+ `SELECT id, event_index, session_id, created_at, connection_id, sender, payload_json
+ FROM ${this.table("events")}
+ WHERE session_id = $1
+ ORDER BY event_index ASC, id ASC
+ LIMIT $2 OFFSET $3`,
+ [request.sessionId, limit, offset],
+ );
+
+ const countResult = await this.pool.query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${this.table("events")} WHERE session_id = $1`, [
+ request.sessionId,
+ ]);
+ const total = parseInteger(countResult.rows[0]?.count ?? "0");
+ const nextOffset = offset + rowsResult.rows.length;
+
+ return {
+ items: rowsResult.rows.map(decodeEventRow),
+ nextCursor: nextOffset < total ? String(nextOffset) : undefined,
+ };
+ }
+
+ async insertEvent(_sessionId: string, event: SessionEvent): Promise {
+ await this.ready();
+
+ await this.pool.query(
+ `INSERT INTO ${this.table("events")} (
+ id, event_index, session_id, created_at, connection_id, sender, payload_json
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)
+ ON CONFLICT(id) DO UPDATE SET
+ event_index = EXCLUDED.event_index,
+ session_id = EXCLUDED.session_id,
+ created_at = EXCLUDED.created_at,
+ connection_id = EXCLUDED.connection_id,
+ sender = EXCLUDED.sender,
+ payload_json = EXCLUDED.payload_json`,
+ [event.id, event.eventIndex, event.sessionId, event.createdAt, event.connectionId, event.sender, event.payload],
+ );
+ }
+
+ async close(): Promise {
+ if (!this.ownsPool) {
+ return;
+ }
+ await this.pool.end();
+ }
+
+ private async ready(): Promise {
+ await this.initialized;
+ }
+
+ private table(name: "sessions" | "events"): string {
+ return `"${this.schema}"."${name}"`;
+ }
+
+ private async initialize(): Promise {
+ await this.pool.query(`CREATE SCHEMA IF NOT EXISTS "${this.schema}"`);
+
+ await this.pool.query(`
+ CREATE TABLE IF NOT EXISTS ${this.table("sessions")} (
+ id TEXT PRIMARY KEY,
+ agent TEXT NOT NULL,
+ agent_session_id TEXT NOT NULL,
+ last_connection_id TEXT NOT NULL,
+ created_at BIGINT NOT NULL,
+ destroyed_at BIGINT,
+ sandbox_id TEXT,
+ session_init_json JSONB
+ )
+ `);
+
+ await this.pool.query(`
+ ALTER TABLE ${this.table("sessions")}
+ ADD COLUMN IF NOT EXISTS sandbox_id TEXT
+ `);
+
+ await this.pool.query(`
+ CREATE TABLE IF NOT EXISTS ${this.table("events")} (
+ id TEXT PRIMARY KEY,
+ event_index BIGINT NOT NULL,
+ session_id TEXT NOT NULL,
+ created_at BIGINT NOT NULL,
+ connection_id TEXT NOT NULL,
+ sender TEXT NOT NULL,
+ payload_json JSONB NOT NULL
+ )
+ `);
+
+ await this.pool.query(`
+ ALTER TABLE ${this.table("events")}
+ ALTER COLUMN id TYPE TEXT USING id::TEXT
+ `);
+
+ await this.pool.query(`
+ ALTER TABLE ${this.table("events")}
+ ADD COLUMN IF NOT EXISTS event_index BIGINT
+ `);
+
+ await this.pool.query(`
+ WITH ranked AS (
+ SELECT id, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC) AS ranked_index
+ FROM ${this.table("events")}
+ )
+ UPDATE ${this.table("events")} AS current_events
+ SET event_index = ranked.ranked_index
+ FROM ranked
+ WHERE current_events.id = ranked.id
+ AND current_events.event_index IS NULL
+ `);
+
+ await this.pool.query(`
+ ALTER TABLE ${this.table("events")}
+ ALTER COLUMN event_index SET NOT NULL
+ `);
+
+ await this.pool.query(`
+ CREATE INDEX IF NOT EXISTS idx_events_session_order
+ ON ${this.table("events")}(session_id, event_index, id)
+ `);
+ }
+}
+
+type SessionRow = {
+ id: string;
+ agent: string;
+ agent_session_id: string;
+ last_connection_id: string;
+ created_at: string | number;
+ destroyed_at: string | number | null;
+ sandbox_id: string | null;
+ session_init_json: unknown | null;
+};
+
+type EventRow = {
+ id: string | number;
+ event_index: string | number;
+ session_id: string;
+ created_at: string | number;
+ connection_id: string;
+ sender: string;
+ payload_json: unknown;
+};
+
+function decodeSessionRow(row: SessionRow): SessionRecord {
+ return {
+ id: row.id,
+ agent: row.agent,
+ agentSessionId: row.agent_session_id,
+ lastConnectionId: row.last_connection_id,
+ createdAt: parseInteger(row.created_at),
+ destroyedAt: row.destroyed_at === null ? undefined : parseInteger(row.destroyed_at),
+ sandboxId: row.sandbox_id ?? undefined,
+ sessionInit: row.session_init_json ? (row.session_init_json as SessionRecord["sessionInit"]) : undefined,
+ };
+}
+
+function decodeEventRow(row: EventRow): SessionEvent {
+ return {
+ id: String(row.id),
+ eventIndex: parseInteger(row.event_index),
+ sessionId: row.session_id,
+ createdAt: parseInteger(row.created_at),
+ connectionId: row.connection_id,
+ sender: parseSender(row.sender),
+ payload: row.payload_json as SessionEvent["payload"],
+ };
+}
+
+function normalizeLimit(limit: number | undefined): number {
+ if (!Number.isFinite(limit) || (limit ?? 0) < 1) {
+ return DEFAULT_LIST_LIMIT;
+ }
+ return Math.floor(limit as number);
+}
+
+function parseCursor(cursor: string | undefined): number {
+ if (!cursor) {
+ return 0;
+ }
+ const parsed = Number.parseInt(cursor, 10);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ return 0;
+ }
+ return parsed;
+}
+
+function parseInteger(value: string | number): number {
+ const parsed = typeof value === "number" ? value : Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed)) {
+ throw new Error(`Invalid integer value returned by postgres: ${String(value)}`);
+ }
+ return parsed;
+}
+
+function parseSender(value: string): SessionEvent["sender"] {
+ if (value === "agent" || value === "client") {
+ return value;
+ }
+ throw new Error(`Invalid sender value returned by postgres: ${value}`);
+}
+
+function normalizeSchema(schema: string): string {
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(schema)) {
+ throw new Error(`Invalid schema name '${schema}'. Use letters, numbers, and underscores only.`);
+ }
+ return schema;
+}
diff --git a/examples/persist-sqlite/package.json b/examples/persist-sqlite/package.json
index 8b7b822..be6bf0d 100644
--- a/examples/persist-sqlite/package.json
+++ b/examples/persist-sqlite/package.json
@@ -8,10 +8,11 @@
},
"dependencies": {
"@sandbox-agent/example-shared": "workspace:*",
- "@sandbox-agent/persist-sqlite": "workspace:*",
+ "better-sqlite3": "^11.0.0",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
+ "@types/better-sqlite3": "^7.0.0",
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest"
diff --git a/examples/persist-sqlite/src/index.ts b/examples/persist-sqlite/src/index.ts
index d2c4ef2..943e902 100644
--- a/examples/persist-sqlite/src/index.ts
+++ b/examples/persist-sqlite/src/index.ts
@@ -1,5 +1,5 @@
import { SandboxAgent } from "sandbox-agent";
-import { SQLiteSessionPersistDriver } from "@sandbox-agent/persist-sqlite";
+import { SQLiteSessionPersistDriver } from "./persist.ts";
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
import { detectAgent } from "@sandbox-agent/example-shared";
diff --git a/examples/persist-sqlite/src/persist.ts b/examples/persist-sqlite/src/persist.ts
new file mode 100644
index 0000000..b04b0fc
--- /dev/null
+++ b/examples/persist-sqlite/src/persist.ts
@@ -0,0 +1,294 @@
+import Database from "better-sqlite3";
+import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
+
+const DEFAULT_LIST_LIMIT = 100;
+
+export interface SQLiteSessionPersistDriverOptions {
+ filename?: string;
+}
+
+export class SQLiteSessionPersistDriver implements SessionPersistDriver {
+ private readonly db: Database.Database;
+
+ constructor(options: SQLiteSessionPersistDriverOptions = {}) {
+ this.db = new Database(options.filename ?? ":memory:");
+ this.initialize();
+ }
+
+ async getSession(id: string): Promise {
+ const row = this.db
+ .prepare(
+ `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
+ FROM sessions WHERE id = ?`,
+ )
+ .get(id) as SessionRow | undefined;
+
+ if (!row) {
+ return undefined;
+ }
+
+ return decodeSessionRow(row);
+ }
+
+ async listSessions(request: ListPageRequest = {}): Promise> {
+ const offset = parseCursor(request.cursor);
+ const limit = normalizeLimit(request.limit);
+
+ const rows = this.db
+ .prepare(
+ `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
+ FROM sessions
+ ORDER BY created_at ASC, id ASC
+ LIMIT ? OFFSET ?`,
+ )
+ .all(limit, offset) as SessionRow[];
+
+ const countRow = this.db.prepare(`SELECT COUNT(*) as count FROM sessions`).get() as { count: number };
+ const nextOffset = offset + rows.length;
+
+ return {
+ items: rows.map(decodeSessionRow),
+ nextCursor: nextOffset < countRow.count ? String(nextOffset) : undefined,
+ };
+ }
+
+ async updateSession(session: SessionRecord): Promise {
+ this.db
+ .prepare(
+ `INSERT INTO sessions (
+ id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ agent = excluded.agent,
+ agent_session_id = excluded.agent_session_id,
+ last_connection_id = excluded.last_connection_id,
+ created_at = excluded.created_at,
+ destroyed_at = excluded.destroyed_at,
+ sandbox_id = excluded.sandbox_id,
+ session_init_json = excluded.session_init_json`,
+ )
+ .run(
+ session.id,
+ session.agent,
+ session.agentSessionId,
+ session.lastConnectionId,
+ session.createdAt,
+ session.destroyedAt ?? null,
+ session.sandboxId ?? null,
+ session.sessionInit ? JSON.stringify(session.sessionInit) : null,
+ );
+ }
+
+ async listEvents(request: ListEventsRequest): Promise> {
+ const offset = parseCursor(request.cursor);
+ const limit = normalizeLimit(request.limit);
+
+ const rows = this.db
+ .prepare(
+ `SELECT id, event_index, session_id, created_at, connection_id, sender, payload_json
+ FROM events
+ WHERE session_id = ?
+ ORDER BY event_index ASC, id ASC
+ LIMIT ? OFFSET ?`,
+ )
+ .all(request.sessionId, limit, offset) as EventRow[];
+
+ const countRow = this.db.prepare(`SELECT COUNT(*) as count FROM events WHERE session_id = ?`).get(request.sessionId) as { count: number };
+
+ const nextOffset = offset + rows.length;
+
+ return {
+ items: rows.map(decodeEventRow),
+ nextCursor: nextOffset < countRow.count ? String(nextOffset) : undefined,
+ };
+ }
+
+ async insertEvent(_sessionId: string, event: SessionEvent): Promise {
+ this.db
+ .prepare(
+ `INSERT INTO events (
+ id, event_index, session_id, created_at, connection_id, sender, payload_json
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ event_index = excluded.event_index,
+ session_id = excluded.session_id,
+ created_at = excluded.created_at,
+ connection_id = excluded.connection_id,
+ sender = excluded.sender,
+ payload_json = excluded.payload_json`,
+ )
+ .run(event.id, event.eventIndex, event.sessionId, event.createdAt, event.connectionId, event.sender, JSON.stringify(event.payload));
+ }
+
+ close(): void {
+ this.db.close();
+ }
+
+ private initialize(): void {
+ this.db.exec(`
+ CREATE TABLE IF NOT EXISTS sessions (
+ id TEXT PRIMARY KEY,
+ agent TEXT NOT NULL,
+ agent_session_id TEXT NOT NULL,
+ last_connection_id TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ destroyed_at INTEGER,
+ sandbox_id TEXT,
+ session_init_json TEXT
+ )
+ `);
+
+ const sessionColumns = this.db.prepare(`PRAGMA table_info(sessions)`).all() as TableInfoRow[];
+ if (!sessionColumns.some((column) => column.name === "sandbox_id")) {
+ this.db.exec(`ALTER TABLE sessions ADD COLUMN sandbox_id TEXT`);
+ }
+
+ this.ensureEventsTable();
+ }
+
+ private ensureEventsTable(): void {
+ const tableInfo = this.db.prepare(`PRAGMA table_info(events)`).all() as TableInfoRow[];
+ if (tableInfo.length === 0) {
+ this.createEventsTable();
+ return;
+ }
+
+ const idColumn = tableInfo.find((column) => column.name === "id");
+ const hasEventIndex = tableInfo.some((column) => column.name === "event_index");
+ const idType = (idColumn?.type ?? "").trim().toUpperCase();
+ const idIsText = idType === "TEXT";
+
+ if (!idIsText || !hasEventIndex) {
+ this.rebuildEventsTable(hasEventIndex);
+ }
+
+ this.db.exec(`
+ CREATE INDEX IF NOT EXISTS idx_events_session_order
+ ON events(session_id, event_index, id)
+ `);
+ }
+
+ private createEventsTable(): void {
+ this.db.exec(`
+ CREATE TABLE IF NOT EXISTS events (
+ id TEXT PRIMARY KEY,
+ event_index INTEGER NOT NULL,
+ session_id TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ connection_id TEXT NOT NULL,
+ sender TEXT NOT NULL,
+ payload_json TEXT NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_events_session_order
+ ON events(session_id, event_index, id)
+ `);
+ }
+
+ private rebuildEventsTable(hasEventIndex: boolean): void {
+ this.db.exec(`
+ ALTER TABLE events RENAME TO events_legacy;
+ `);
+
+ this.createEventsTable();
+
+ if (hasEventIndex) {
+ this.db.exec(`
+ INSERT INTO events (id, event_index, session_id, created_at, connection_id, sender, payload_json)
+ SELECT
+ CAST(id AS TEXT),
+ COALESCE(event_index, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC)),
+ session_id,
+ created_at,
+ connection_id,
+ sender,
+ payload_json
+ FROM events_legacy
+ `);
+ } else {
+ this.db.exec(`
+ INSERT INTO events (id, event_index, session_id, created_at, connection_id, sender, payload_json)
+ SELECT
+ CAST(id AS TEXT),
+ ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC),
+ session_id,
+ created_at,
+ connection_id,
+ sender,
+ payload_json
+ FROM events_legacy
+ `);
+ }
+
+ this.db.exec(`DROP TABLE events_legacy`);
+ }
+}
+
+type SessionRow = {
+ id: string;
+ agent: string;
+ agent_session_id: string;
+ last_connection_id: string;
+ created_at: number;
+ destroyed_at: number | null;
+ sandbox_id: string | null;
+ session_init_json: string | null;
+};
+
+type EventRow = {
+ id: string;
+ event_index: number;
+ session_id: string;
+ created_at: number;
+ connection_id: string;
+ sender: "client" | "agent";
+ payload_json: string;
+};
+
+type TableInfoRow = {
+ name: string;
+ type: string;
+};
+
+function decodeSessionRow(row: SessionRow): SessionRecord {
+ return {
+ id: row.id,
+ agent: row.agent,
+ agentSessionId: row.agent_session_id,
+ lastConnectionId: row.last_connection_id,
+ createdAt: row.created_at,
+ destroyedAt: row.destroyed_at ?? undefined,
+ sandboxId: row.sandbox_id ?? undefined,
+ sessionInit: row.session_init_json ? (JSON.parse(row.session_init_json) as SessionRecord["sessionInit"]) : undefined,
+ };
+}
+
+function decodeEventRow(row: EventRow): SessionEvent {
+ return {
+ id: row.id,
+ eventIndex: row.event_index,
+ sessionId: row.session_id,
+ createdAt: row.created_at,
+ connectionId: row.connection_id,
+ sender: row.sender,
+ payload: JSON.parse(row.payload_json),
+ };
+}
+
+function normalizeLimit(limit: number | undefined): number {
+ if (!Number.isFinite(limit) || (limit ?? 0) < 1) {
+ return DEFAULT_LIST_LIMIT;
+ }
+ return Math.floor(limit as number);
+}
+
+function parseCursor(cursor: string | undefined): number {
+ if (!cursor) {
+ return 0;
+ }
+ const parsed = Number.parseInt(cursor, 10);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ return 0;
+ }
+ return parsed;
+}
diff --git a/frontend/packages/inspector/package.json b/frontend/packages/inspector/package.json
index 9671ecb..45b7224 100644
--- a/frontend/packages/inspector/package.json
+++ b/frontend/packages/inspector/package.json
@@ -6,10 +6,10 @@
"type": "module",
"scripts": {
"dev": "vite",
- "build": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && vite build",
+ "build": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/react build && vite build",
"preview": "vite preview",
- "typecheck": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && tsc --noEmit",
- "test": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && vitest run"
+ "typecheck": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/react build && tsc --noEmit",
+ "test": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/react build && vitest run"
},
"devDependencies": {
"@sandbox-agent/react": "workspace:*",
@@ -23,7 +23,6 @@
"vitest": "^3.0.0"
},
"dependencies": {
- "@sandbox-agent/persist-indexeddb": "workspace:*",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx
index ac06904..f6e319c 100644
--- a/frontend/packages/inspector/src/App.tsx
+++ b/frontend/packages/inspector/src/App.tsx
@@ -24,7 +24,7 @@ type ConfigOption = {
};
type AgentModeInfo = { id: string; name: string; description: string };
type AgentModelInfo = { id: string; name?: string };
-import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb";
+import { IndexedDbSessionPersistDriver } from "./persist-indexeddb";
import ChatPanel from "./components/chat/ChatPanel";
import ConnectScreen from "./components/ConnectScreen";
import DebugPanel, { type DebugTab } from "./components/debug/DebugPanel";
diff --git a/frontend/packages/inspector/src/persist-indexeddb.ts b/frontend/packages/inspector/src/persist-indexeddb.ts
new file mode 100644
index 0000000..b6af1c8
--- /dev/null
+++ b/frontend/packages/inspector/src/persist-indexeddb.ts
@@ -0,0 +1,314 @@
+import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
+
+const DEFAULT_DB_NAME = "sandbox-agent-session-store";
+const DEFAULT_DB_VERSION = 2;
+const SESSIONS_STORE = "sessions";
+const EVENTS_STORE = "events";
+const EVENTS_BY_SESSION_INDEX = "by_session_index";
+const DEFAULT_LIST_LIMIT = 100;
+
+export interface IndexedDbSessionPersistDriverOptions {
+ databaseName?: string;
+ databaseVersion?: number;
+ indexedDb?: IDBFactory;
+}
+
+export class IndexedDbSessionPersistDriver implements SessionPersistDriver {
+ private readonly indexedDb: IDBFactory;
+ private readonly dbName: string;
+ private readonly dbVersion: number;
+ private readonly dbPromise: Promise;
+
+ constructor(options: IndexedDbSessionPersistDriverOptions = {}) {
+ const indexedDb = options.indexedDb ?? globalThis.indexedDB;
+ if (!indexedDb) {
+ throw new Error("IndexedDB is not available in this runtime.");
+ }
+
+ this.indexedDb = indexedDb;
+ this.dbName = options.databaseName ?? DEFAULT_DB_NAME;
+ this.dbVersion = options.databaseVersion ?? DEFAULT_DB_VERSION;
+ this.dbPromise = this.openDatabase();
+ }
+
+ async getSession(id: string): Promise {
+ const db = await this.dbPromise;
+ const row = await requestToPromise(db.transaction(SESSIONS_STORE, "readonly").objectStore(SESSIONS_STORE).get(id));
+ if (!row || typeof row !== "object") {
+ return undefined;
+ }
+ return decodeSessionRow(row as SessionRow);
+ }
+
+ async listSessions(request: ListPageRequest = {}): Promise> {
+ const db = await this.dbPromise;
+ const rows = await getAllRows(db, SESSIONS_STORE);
+
+ rows.sort((a, b) => {
+ if (a.createdAt !== b.createdAt) {
+ return a.createdAt - b.createdAt;
+ }
+ return a.id.localeCompare(b.id);
+ });
+
+ const offset = parseCursor(request.cursor);
+ const limit = normalizeLimit(request.limit);
+ const slice = rows.slice(offset, offset + limit).map(decodeSessionRow);
+ const nextOffset = offset + slice.length;
+
+ return {
+ items: slice,
+ nextCursor: nextOffset < rows.length ? String(nextOffset) : undefined,
+ };
+ }
+
+ async updateSession(session: SessionRecord): Promise {
+ const db = await this.dbPromise;
+ await transactionPromise(db, [SESSIONS_STORE], "readwrite", (tx) => {
+ tx.objectStore(SESSIONS_STORE).put(encodeSessionRow(session));
+ });
+ }
+
+ async listEvents(request: ListEventsRequest): Promise> {
+ const db = await this.dbPromise;
+ const rows = (await getAllRows(db, EVENTS_STORE)).filter((row) => row.sessionId === request.sessionId).sort(compareEventRowsByOrder);
+
+ const offset = parseCursor(request.cursor);
+ const limit = normalizeLimit(request.limit);
+ const slice = rows.slice(offset, offset + limit).map(decodeEventRow);
+ const nextOffset = offset + slice.length;
+
+ return {
+ items: slice,
+ nextCursor: nextOffset < rows.length ? String(nextOffset) : undefined,
+ };
+ }
+
+ async insertEvent(_sessionId: string, event: SessionEvent): Promise {
+ const db = await this.dbPromise;
+ await transactionPromise(db, [EVENTS_STORE], "readwrite", (tx) => {
+ tx.objectStore(EVENTS_STORE).put(encodeEventRow(event));
+ });
+ }
+
+ async close(): Promise {
+ const db = await this.dbPromise;
+ db.close();
+ }
+
+ private openDatabase(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = this.indexedDb.open(this.dbName, this.dbVersion);
+
+ request.onupgradeneeded = () => {
+ const db = request.result;
+
+ if (!db.objectStoreNames.contains(SESSIONS_STORE)) {
+ db.createObjectStore(SESSIONS_STORE, { keyPath: "id" });
+ }
+
+ if (!db.objectStoreNames.contains(EVENTS_STORE)) {
+ const events = db.createObjectStore(EVENTS_STORE, { keyPath: "id" });
+ events.createIndex(EVENTS_BY_SESSION_INDEX, ["sessionId", "eventIndex", "id"], {
+ unique: false,
+ });
+ } else {
+ const tx = request.transaction;
+ if (!tx) {
+ return;
+ }
+ const events = tx.objectStore(EVENTS_STORE);
+ if (!events.indexNames.contains(EVENTS_BY_SESSION_INDEX)) {
+ events.createIndex(EVENTS_BY_SESSION_INDEX, ["sessionId", "eventIndex", "id"], {
+ unique: false,
+ });
+ }
+ }
+ };
+
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error ?? new Error("Unable to open IndexedDB"));
+ });
+ }
+}
+
+type SessionRow = {
+ id: string;
+ agent: string;
+ agentSessionId: string;
+ lastConnectionId: string;
+ createdAt: number;
+ destroyedAt?: number;
+ sandboxId?: string;
+ sessionInit?: SessionRecord["sessionInit"];
+};
+
+type EventRow = {
+ id: number | string;
+ eventIndex?: number;
+ sessionId: string;
+ createdAt: number;
+ connectionId: string;
+ sender: "client" | "agent";
+ payload: unknown;
+};
+
+function encodeSessionRow(session: SessionRecord): SessionRow {
+ return {
+ id: session.id,
+ agent: session.agent,
+ agentSessionId: session.agentSessionId,
+ lastConnectionId: session.lastConnectionId,
+ createdAt: session.createdAt,
+ destroyedAt: session.destroyedAt,
+ sandboxId: session.sandboxId,
+ sessionInit: session.sessionInit,
+ };
+}
+
+function decodeSessionRow(row: SessionRow): SessionRecord {
+ return {
+ id: row.id,
+ agent: row.agent,
+ agentSessionId: row.agentSessionId,
+ lastConnectionId: row.lastConnectionId,
+ createdAt: row.createdAt,
+ destroyedAt: row.destroyedAt,
+ sandboxId: row.sandboxId,
+ sessionInit: row.sessionInit,
+ };
+}
+
+function encodeEventRow(event: SessionEvent): EventRow {
+ return {
+ id: event.id,
+ eventIndex: event.eventIndex,
+ sessionId: event.sessionId,
+ createdAt: event.createdAt,
+ connectionId: event.connectionId,
+ sender: event.sender,
+ payload: event.payload,
+ };
+}
+
+function decodeEventRow(row: EventRow): SessionEvent {
+ return {
+ id: String(row.id),
+ eventIndex: parseEventIndex(row.eventIndex, row.id),
+ sessionId: row.sessionId,
+ createdAt: row.createdAt,
+ connectionId: row.connectionId,
+ sender: row.sender,
+ payload: row.payload as SessionEvent["payload"],
+ };
+}
+
+async function getAllRows(db: IDBDatabase, storeName: string): Promise {
+ return await transactionPromise(db, [storeName], "readonly", async (tx) => {
+ const request = tx.objectStore(storeName).getAll();
+ return (await requestToPromise(request)) as T[];
+ });
+}
+
+function normalizeLimit(limit: number | undefined): number {
+ if (!Number.isFinite(limit) || (limit ?? 0) < 1) {
+ return DEFAULT_LIST_LIMIT;
+ }
+ return Math.floor(limit as number);
+}
+
+function parseCursor(cursor: string | undefined): number {
+ if (!cursor) {
+ return 0;
+ }
+ const parsed = Number.parseInt(cursor, 10);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ return 0;
+ }
+ return parsed;
+}
+
+function compareEventRowsByOrder(a: EventRow, b: EventRow): number {
+ const indexA = parseEventIndex(a.eventIndex, a.id);
+ const indexB = parseEventIndex(b.eventIndex, b.id);
+ if (indexA !== indexB) {
+ return indexA - indexB;
+ }
+ return String(a.id).localeCompare(String(b.id));
+}
+
+function parseEventIndex(value: number | undefined, fallback: number | string): number {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ return Math.max(0, Math.floor(value));
+ }
+
+ const parsed = Number.parseInt(String(fallback), 10);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ return 0;
+ }
+ return parsed;
+}
+
+function requestToPromise(request: IDBRequest): Promise {
+ return new Promise((resolve, reject) => {
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
+ });
+}
+
+function transactionPromise(db: IDBDatabase, stores: string[], mode: IDBTransactionMode, run: (tx: IDBTransaction) => T | Promise): Promise {
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(stores, mode);
+ let settled = false;
+ let resultValue: T | undefined;
+ let runCompleted = false;
+ let txCompleted = false;
+
+ function tryResolve() {
+ if (settled || !runCompleted || !txCompleted) {
+ return;
+ }
+ settled = true;
+ resolve(resultValue as T);
+ }
+
+ tx.oncomplete = () => {
+ txCompleted = true;
+ tryResolve();
+ };
+
+ tx.onerror = () => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ reject(tx.error ?? new Error("IndexedDB transaction failed"));
+ };
+
+ tx.onabort = () => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ reject(tx.error ?? new Error("IndexedDB transaction aborted"));
+ };
+
+ Promise.resolve(run(tx))
+ .then((value) => {
+ resultValue = value;
+ runCompleted = true;
+ tryResolve();
+ })
+ .catch((error) => {
+ if (!settled) {
+ settled = true;
+ reject(error);
+ }
+ try {
+ tx.abort();
+ } catch {
+ // no-op
+ }
+ });
+ });
+}
diff --git a/sdks/persist-indexeddb/README.md b/sdks/persist-indexeddb/README.md
new file mode 100644
index 0000000..e2962ef
--- /dev/null
+++ b/sdks/persist-indexeddb/README.md
@@ -0,0 +1,5 @@
+# @sandbox-agent/persist-indexeddb
+
+> **Deprecated:** This package has been deprecated and removed.
+
+Copy the driver source directly into your project. See the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance.
diff --git a/sdks/persist-indexeddb/package.json b/sdks/persist-indexeddb/package.json
index 179e0be..599b951 100644
--- a/sdks/persist-indexeddb/package.json
+++ b/sdks/persist-indexeddb/package.json
@@ -16,23 +16,16 @@
"import": "./dist/index.js"
}
},
- "dependencies": {
- "sandbox-agent": "workspace:*"
- },
"files": [
"dist"
],
"scripts": {
"build": "tsup",
- "typecheck": "tsc --noEmit",
- "test": "vitest run",
- "test:watch": "vitest"
+ "typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.0.0",
- "fake-indexeddb": "^6.2.4",
"tsup": "^8.0.0",
- "typescript": "^5.7.0",
- "vitest": "^3.0.0"
+ "typescript": "^5.7.0"
}
}
diff --git a/sdks/persist-indexeddb/src/index.ts b/sdks/persist-indexeddb/src/index.ts
index b6af1c8..d704d05 100644
--- a/sdks/persist-indexeddb/src/index.ts
+++ b/sdks/persist-indexeddb/src/index.ts
@@ -1,314 +1,5 @@
-import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
-
-const DEFAULT_DB_NAME = "sandbox-agent-session-store";
-const DEFAULT_DB_VERSION = 2;
-const SESSIONS_STORE = "sessions";
-const EVENTS_STORE = "events";
-const EVENTS_BY_SESSION_INDEX = "by_session_index";
-const DEFAULT_LIST_LIMIT = 100;
-
-export interface IndexedDbSessionPersistDriverOptions {
- databaseName?: string;
- databaseVersion?: number;
- indexedDb?: IDBFactory;
-}
-
-export class IndexedDbSessionPersistDriver implements SessionPersistDriver {
- private readonly indexedDb: IDBFactory;
- private readonly dbName: string;
- private readonly dbVersion: number;
- private readonly dbPromise: Promise;
-
- constructor(options: IndexedDbSessionPersistDriverOptions = {}) {
- const indexedDb = options.indexedDb ?? globalThis.indexedDB;
- if (!indexedDb) {
- throw new Error("IndexedDB is not available in this runtime.");
- }
-
- this.indexedDb = indexedDb;
- this.dbName = options.databaseName ?? DEFAULT_DB_NAME;
- this.dbVersion = options.databaseVersion ?? DEFAULT_DB_VERSION;
- this.dbPromise = this.openDatabase();
- }
-
- async getSession(id: string): Promise {
- const db = await this.dbPromise;
- const row = await requestToPromise(db.transaction(SESSIONS_STORE, "readonly").objectStore(SESSIONS_STORE).get(id));
- if (!row || typeof row !== "object") {
- return undefined;
- }
- return decodeSessionRow(row as SessionRow);
- }
-
- async listSessions(request: ListPageRequest = {}): Promise> {
- const db = await this.dbPromise;
- const rows = await getAllRows(db, SESSIONS_STORE);
-
- rows.sort((a, b) => {
- if (a.createdAt !== b.createdAt) {
- return a.createdAt - b.createdAt;
- }
- return a.id.localeCompare(b.id);
- });
-
- const offset = parseCursor(request.cursor);
- const limit = normalizeLimit(request.limit);
- const slice = rows.slice(offset, offset + limit).map(decodeSessionRow);
- const nextOffset = offset + slice.length;
-
- return {
- items: slice,
- nextCursor: nextOffset < rows.length ? String(nextOffset) : undefined,
- };
- }
-
- async updateSession(session: SessionRecord): Promise {
- const db = await this.dbPromise;
- await transactionPromise(db, [SESSIONS_STORE], "readwrite", (tx) => {
- tx.objectStore(SESSIONS_STORE).put(encodeSessionRow(session));
- });
- }
-
- async listEvents(request: ListEventsRequest): Promise> {
- const db = await this.dbPromise;
- const rows = (await getAllRows(db, EVENTS_STORE)).filter((row) => row.sessionId === request.sessionId).sort(compareEventRowsByOrder);
-
- const offset = parseCursor(request.cursor);
- const limit = normalizeLimit(request.limit);
- const slice = rows.slice(offset, offset + limit).map(decodeEventRow);
- const nextOffset = offset + slice.length;
-
- return {
- items: slice,
- nextCursor: nextOffset < rows.length ? String(nextOffset) : undefined,
- };
- }
-
- async insertEvent(_sessionId: string, event: SessionEvent): Promise {
- const db = await this.dbPromise;
- await transactionPromise(db, [EVENTS_STORE], "readwrite", (tx) => {
- tx.objectStore(EVENTS_STORE).put(encodeEventRow(event));
- });
- }
-
- async close(): Promise {
- const db = await this.dbPromise;
- db.close();
- }
-
- private openDatabase(): Promise {
- return new Promise((resolve, reject) => {
- const request = this.indexedDb.open(this.dbName, this.dbVersion);
-
- request.onupgradeneeded = () => {
- const db = request.result;
-
- if (!db.objectStoreNames.contains(SESSIONS_STORE)) {
- db.createObjectStore(SESSIONS_STORE, { keyPath: "id" });
- }
-
- if (!db.objectStoreNames.contains(EVENTS_STORE)) {
- const events = db.createObjectStore(EVENTS_STORE, { keyPath: "id" });
- events.createIndex(EVENTS_BY_SESSION_INDEX, ["sessionId", "eventIndex", "id"], {
- unique: false,
- });
- } else {
- const tx = request.transaction;
- if (!tx) {
- return;
- }
- const events = tx.objectStore(EVENTS_STORE);
- if (!events.indexNames.contains(EVENTS_BY_SESSION_INDEX)) {
- events.createIndex(EVENTS_BY_SESSION_INDEX, ["sessionId", "eventIndex", "id"], {
- unique: false,
- });
- }
- }
- };
-
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => reject(request.error ?? new Error("Unable to open IndexedDB"));
- });
- }
-}
-
-type SessionRow = {
- id: string;
- agent: string;
- agentSessionId: string;
- lastConnectionId: string;
- createdAt: number;
- destroyedAt?: number;
- sandboxId?: string;
- sessionInit?: SessionRecord["sessionInit"];
-};
-
-type EventRow = {
- id: number | string;
- eventIndex?: number;
- sessionId: string;
- createdAt: number;
- connectionId: string;
- sender: "client" | "agent";
- payload: unknown;
-};
-
-function encodeSessionRow(session: SessionRecord): SessionRow {
- return {
- id: session.id,
- agent: session.agent,
- agentSessionId: session.agentSessionId,
- lastConnectionId: session.lastConnectionId,
- createdAt: session.createdAt,
- destroyedAt: session.destroyedAt,
- sandboxId: session.sandboxId,
- sessionInit: session.sessionInit,
- };
-}
-
-function decodeSessionRow(row: SessionRow): SessionRecord {
- return {
- id: row.id,
- agent: row.agent,
- agentSessionId: row.agentSessionId,
- lastConnectionId: row.lastConnectionId,
- createdAt: row.createdAt,
- destroyedAt: row.destroyedAt,
- sandboxId: row.sandboxId,
- sessionInit: row.sessionInit,
- };
-}
-
-function encodeEventRow(event: SessionEvent): EventRow {
- return {
- id: event.id,
- eventIndex: event.eventIndex,
- sessionId: event.sessionId,
- createdAt: event.createdAt,
- connectionId: event.connectionId,
- sender: event.sender,
- payload: event.payload,
- };
-}
-
-function decodeEventRow(row: EventRow): SessionEvent {
- return {
- id: String(row.id),
- eventIndex: parseEventIndex(row.eventIndex, row.id),
- sessionId: row.sessionId,
- createdAt: row.createdAt,
- connectionId: row.connectionId,
- sender: row.sender,
- payload: row.payload as SessionEvent["payload"],
- };
-}
-
-async function getAllRows(db: IDBDatabase, storeName: string): Promise {
- return await transactionPromise(db, [storeName], "readonly", async (tx) => {
- const request = tx.objectStore(storeName).getAll();
- return (await requestToPromise(request)) as T[];
- });
-}
-
-function normalizeLimit(limit: number | undefined): number {
- if (!Number.isFinite(limit) || (limit ?? 0) < 1) {
- return DEFAULT_LIST_LIMIT;
- }
- return Math.floor(limit as number);
-}
-
-function parseCursor(cursor: string | undefined): number {
- if (!cursor) {
- return 0;
- }
- const parsed = Number.parseInt(cursor, 10);
- if (!Number.isFinite(parsed) || parsed < 0) {
- return 0;
- }
- return parsed;
-}
-
-function compareEventRowsByOrder(a: EventRow, b: EventRow): number {
- const indexA = parseEventIndex(a.eventIndex, a.id);
- const indexB = parseEventIndex(b.eventIndex, b.id);
- if (indexA !== indexB) {
- return indexA - indexB;
- }
- return String(a.id).localeCompare(String(b.id));
-}
-
-function parseEventIndex(value: number | undefined, fallback: number | string): number {
- if (typeof value === "number" && Number.isFinite(value)) {
- return Math.max(0, Math.floor(value));
- }
-
- const parsed = Number.parseInt(String(fallback), 10);
- if (!Number.isFinite(parsed) || parsed < 0) {
- return 0;
- }
- return parsed;
-}
-
-function requestToPromise(request: IDBRequest): Promise {
- return new Promise((resolve, reject) => {
- request.onsuccess = () => resolve(request.result);
- request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
- });
-}
-
-function transactionPromise(db: IDBDatabase, stores: string[], mode: IDBTransactionMode, run: (tx: IDBTransaction) => T | Promise): Promise {
- return new Promise((resolve, reject) => {
- const tx = db.transaction(stores, mode);
- let settled = false;
- let resultValue: T | undefined;
- let runCompleted = false;
- let txCompleted = false;
-
- function tryResolve() {
- if (settled || !runCompleted || !txCompleted) {
- return;
- }
- settled = true;
- resolve(resultValue as T);
- }
-
- tx.oncomplete = () => {
- txCompleted = true;
- tryResolve();
- };
-
- tx.onerror = () => {
- if (settled) {
- return;
- }
- settled = true;
- reject(tx.error ?? new Error("IndexedDB transaction failed"));
- };
-
- tx.onabort = () => {
- if (settled) {
- return;
- }
- settled = true;
- reject(tx.error ?? new Error("IndexedDB transaction aborted"));
- };
-
- Promise.resolve(run(tx))
- .then((value) => {
- resultValue = value;
- runCompleted = true;
- tryResolve();
- })
- .catch((error) => {
- if (!settled) {
- settled = true;
- reject(error);
- }
- try {
- tx.abort();
- } catch {
- // no-op
- }
- });
- });
-}
+throw new Error(
+ "@sandbox-agent/persist-indexeddb has been deprecated and removed. " +
+ "Copy the reference implementation into your project instead. " +
+ "See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-indexeddb",
+);
diff --git a/sdks/persist-indexeddb/tests/driver.test.ts b/sdks/persist-indexeddb/tests/driver.test.ts
deleted file mode 100644
index 5c743be..0000000
--- a/sdks/persist-indexeddb/tests/driver.test.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import "fake-indexeddb/auto";
-import { describe, it, expect } from "vitest";
-import { IndexedDbSessionPersistDriver } from "../src/index.ts";
-
-function uniqueDbName(prefix: string): string {
- return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
-}
-
-describe("IndexedDbSessionPersistDriver", () => {
- it("stores and pages sessions and events", async () => {
- const dbName = uniqueDbName("indexeddb-driver");
- const driver = new IndexedDbSessionPersistDriver({ databaseName: dbName });
-
- await driver.updateSession({
- id: "s-1",
- agent: "mock",
- agentSessionId: "a-1",
- lastConnectionId: "c-1",
- createdAt: 100,
- });
-
- await driver.updateSession({
- id: "s-2",
- agent: "mock",
- agentSessionId: "a-2",
- lastConnectionId: "c-2",
- createdAt: 200,
- destroyedAt: 300,
- });
-
- await driver.insertEvent("s-1", {
- id: "evt-1",
- eventIndex: 1,
- sessionId: "s-1",
- createdAt: 1,
- connectionId: "c-1",
- sender: "client",
- payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } },
- });
-
- await driver.insertEvent("s-1", {
- id: "evt-2",
- eventIndex: 2,
- sessionId: "s-1",
- createdAt: 2,
- connectionId: "c-1",
- sender: "agent",
- payload: { jsonrpc: "2.0", method: "session/update", params: { sessionId: "a-1" } },
- });
-
- const loaded = await driver.getSession("s-2");
- expect(loaded?.destroyedAt).toBe(300);
-
- const page1 = await driver.listSessions({ limit: 1 });
- expect(page1.items).toHaveLength(1);
- expect(page1.items[0]?.id).toBe("s-1");
- expect(page1.nextCursor).toBeTruthy();
-
- const page2 = await driver.listSessions({ cursor: page1.nextCursor, limit: 1 });
- expect(page2.items).toHaveLength(1);
- expect(page2.items[0]?.id).toBe("s-2");
- expect(page2.nextCursor).toBeUndefined();
-
- const eventsPage = await driver.listEvents({ sessionId: "s-1", limit: 10 });
- expect(eventsPage.items).toHaveLength(2);
- expect(eventsPage.items[0]?.id).toBe("evt-1");
- expect(eventsPage.items[0]?.eventIndex).toBe(1);
- expect(eventsPage.items[1]?.id).toBe("evt-2");
- expect(eventsPage.items[1]?.eventIndex).toBe(2);
-
- await driver.close();
- });
-
- it("persists across driver instances for same database", async () => {
- const dbName = uniqueDbName("indexeddb-reopen");
-
- {
- const driver = new IndexedDbSessionPersistDriver({ databaseName: dbName });
- await driver.updateSession({
- id: "s-1",
- agent: "mock",
- agentSessionId: "a-1",
- lastConnectionId: "c-1",
- createdAt: 1,
- });
- await driver.close();
- }
-
- {
- const driver = new IndexedDbSessionPersistDriver({ databaseName: dbName });
- const session = await driver.getSession("s-1");
- expect(session?.id).toBe("s-1");
- await driver.close();
- }
- });
-});
diff --git a/sdks/persist-indexeddb/tests/integration.test.ts b/sdks/persist-indexeddb/tests/integration.test.ts
deleted file mode 100644
index 4a27ac5..0000000
--- a/sdks/persist-indexeddb/tests/integration.test.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import "fake-indexeddb/auto";
-import { describe, it, expect, beforeAll, afterAll } from "vitest";
-import { existsSync, mkdtempSync, rmSync } from "node:fs";
-import { dirname, join, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-import { tmpdir } from "node:os";
-import { SandboxAgent } from "sandbox-agent";
-import { spawnSandboxAgent, type SandboxAgentSpawnHandle } from "../../typescript/src/spawn.ts";
-import { prepareMockAgentDataHome } from "../../typescript/tests/helpers/mock-agent.ts";
-import { IndexedDbSessionPersistDriver } from "../src/index.ts";
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-
-function findBinary(): string | null {
- if (process.env.SANDBOX_AGENT_BIN) {
- return process.env.SANDBOX_AGENT_BIN;
- }
-
- const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")];
-
- for (const p of cargoPaths) {
- if (existsSync(p)) {
- return p;
- }
- }
-
- return null;
-}
-
-function uniqueDbName(prefix: string): string {
- return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
-}
-
-const BINARY_PATH = findBinary();
-if (!BINARY_PATH) {
- throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN.");
-}
-if (!process.env.SANDBOX_AGENT_BIN) {
- process.env.SANDBOX_AGENT_BIN = BINARY_PATH;
-}
-
-describe("IndexedDB persistence end-to-end", () => {
- let handle: SandboxAgentSpawnHandle;
- let baseUrl: string;
- let token: string;
- let dataHome: string;
-
- beforeAll(async () => {
- dataHome = mkdtempSync(join(tmpdir(), "indexeddb-integration-"));
- prepareMockAgentDataHome(dataHome);
-
- handle = await spawnSandboxAgent({
- enabled: true,
- log: "silent",
- timeoutMs: 30000,
- env: {
- XDG_DATA_HOME: dataHome,
- HOME: dataHome,
- USERPROFILE: dataHome,
- APPDATA: join(dataHome, "AppData", "Roaming"),
- LOCALAPPDATA: join(dataHome, "AppData", "Local"),
- },
- });
- baseUrl = handle.baseUrl;
- token = handle.token;
- });
-
- afterAll(async () => {
- await handle.dispose();
- rmSync(dataHome, { recursive: true, force: true });
- });
-
- it("restores sessions/events across sdk instances", async () => {
- const dbName = uniqueDbName("sandbox-agent-browser-e2e");
-
- const persist1 = new IndexedDbSessionPersistDriver({ databaseName: dbName });
- const sdk1 = await SandboxAgent.connect({
- baseUrl,
- token,
- persist: persist1,
- replayMaxEvents: 40,
- replayMaxChars: 16000,
- });
-
- const created = await sdk1.createSession({ agent: "mock" });
- await created.prompt([{ type: "text", text: "indexeddb-first" }]);
- const firstConnectionId = created.lastConnectionId;
-
- await sdk1.dispose();
- await persist1.close();
-
- const persist2 = new IndexedDbSessionPersistDriver({ databaseName: dbName });
- const sdk2 = await SandboxAgent.connect({
- baseUrl,
- token,
- persist: persist2,
- replayMaxEvents: 40,
- replayMaxChars: 16000,
- });
-
- const restored = await sdk2.resumeSession(created.id);
- expect(restored.lastConnectionId).not.toBe(firstConnectionId);
-
- await restored.prompt([{ type: "text", text: "indexeddb-second" }]);
-
- const sessions = await sdk2.listSessions({ limit: 20 });
- expect(sessions.items.some((entry) => entry.id === created.id)).toBe(true);
-
- const events = await sdk2.getEvents({ sessionId: created.id, limit: 1000 });
- expect(events.items.length).toBeGreaterThan(0);
-
- const replayInjected = events.items.find((event) => {
- if (event.sender !== "client") {
- return false;
- }
- const payload = event.payload as Record;
- const method = payload.method;
- const params = payload.params as Record | undefined;
- const prompt = Array.isArray(params?.prompt) ? params?.prompt : [];
- const firstBlock = prompt[0] as Record | undefined;
- return method === "session/prompt" && typeof firstBlock?.text === "string" && firstBlock.text.includes("Previous session history is replayed below");
- });
-
- expect(replayInjected).toBeTruthy();
-
- await sdk2.dispose();
- await persist2.close();
- });
-});
diff --git a/sdks/persist-postgres/README.md b/sdks/persist-postgres/README.md
new file mode 100644
index 0000000..61550cd
--- /dev/null
+++ b/sdks/persist-postgres/README.md
@@ -0,0 +1,5 @@
+# @sandbox-agent/persist-postgres
+
+> **Deprecated:** This package has been deprecated and removed. The implementation now lives as a copy-paste reference in [`examples/persist-postgres`](../../examples/persist-postgres).
+
+Install `pg` directly and copy the driver source into your project. See the [full example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-postgres).
diff --git a/sdks/persist-postgres/package.json b/sdks/persist-postgres/package.json
index 49bd9f1..caa49f6 100644
--- a/sdks/persist-postgres/package.json
+++ b/sdks/persist-postgres/package.json
@@ -1,7 +1,7 @@
{
"name": "@sandbox-agent/persist-postgres",
"version": "0.3.2",
- "description": "PostgreSQL persistence driver for the Sandbox Agent TypeScript SDK",
+ "description": "PostgreSQL persistence driver for the Sandbox Agent TypeScript SDK (DEPRECATED)",
"license": "Apache-2.0",
"repository": {
"type": "git",
@@ -16,24 +16,16 @@
"import": "./dist/index.js"
}
},
- "dependencies": {
- "pg": "^8.16.3",
- "sandbox-agent": "workspace:*"
- },
"files": [
"dist"
],
"scripts": {
"build": "tsup",
- "typecheck": "tsc --noEmit",
- "test": "vitest run",
- "test:watch": "vitest"
+ "typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.0.0",
- "@types/pg": "^8.15.6",
"tsup": "^8.0.0",
- "typescript": "^5.7.0",
- "vitest": "^3.0.0"
+ "typescript": "^5.7.0"
}
}
diff --git a/sdks/persist-postgres/src/index.ts b/sdks/persist-postgres/src/index.ts
index 8b79791..0ae3e99 100644
--- a/sdks/persist-postgres/src/index.ts
+++ b/sdks/persist-postgres/src/index.ts
@@ -1,316 +1,5 @@
-import { Pool, type PoolConfig } from "pg";
-import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
-
-const DEFAULT_LIST_LIMIT = 100;
-
-export interface PostgresSessionPersistDriverOptions {
- connectionString?: string;
- pool?: Pool;
- poolConfig?: PoolConfig;
- schema?: string;
-}
-
-export class PostgresSessionPersistDriver implements SessionPersistDriver {
- private readonly pool: Pool;
- private readonly ownsPool: boolean;
- private readonly schema: string;
- private readonly initialized: Promise;
-
- constructor(options: PostgresSessionPersistDriverOptions = {}) {
- this.schema = normalizeSchema(options.schema ?? "public");
-
- if (options.pool) {
- this.pool = options.pool;
- this.ownsPool = false;
- } else {
- this.pool = new Pool({
- connectionString: options.connectionString,
- ...options.poolConfig,
- });
- this.ownsPool = true;
- }
-
- this.initialized = this.initialize();
- }
-
- async getSession(id: string): Promise {
- await this.ready();
-
- const result = await this.pool.query(
- `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
- FROM ${this.table("sessions")}
- WHERE id = $1`,
- [id],
- );
-
- if (result.rows.length === 0) {
- return undefined;
- }
-
- return decodeSessionRow(result.rows[0]);
- }
-
- async listSessions(request: ListPageRequest = {}): Promise> {
- await this.ready();
-
- const offset = parseCursor(request.cursor);
- const limit = normalizeLimit(request.limit);
-
- const rowsResult = await this.pool.query(
- `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
- FROM ${this.table("sessions")}
- ORDER BY created_at ASC, id ASC
- LIMIT $1 OFFSET $2`,
- [limit, offset],
- );
-
- const countResult = await this.pool.query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${this.table("sessions")}`);
- const total = parseInteger(countResult.rows[0]?.count ?? "0");
- const nextOffset = offset + rowsResult.rows.length;
-
- return {
- items: rowsResult.rows.map(decodeSessionRow),
- nextCursor: nextOffset < total ? String(nextOffset) : undefined,
- };
- }
-
- async updateSession(session: SessionRecord): Promise {
- await this.ready();
-
- await this.pool.query(
- `INSERT INTO ${this.table("sessions")} (
- id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
- ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
- ON CONFLICT(id) DO UPDATE SET
- agent = EXCLUDED.agent,
- agent_session_id = EXCLUDED.agent_session_id,
- last_connection_id = EXCLUDED.last_connection_id,
- created_at = EXCLUDED.created_at,
- destroyed_at = EXCLUDED.destroyed_at,
- sandbox_id = EXCLUDED.sandbox_id,
- session_init_json = EXCLUDED.session_init_json`,
- [
- session.id,
- session.agent,
- session.agentSessionId,
- session.lastConnectionId,
- session.createdAt,
- session.destroyedAt ?? null,
- session.sandboxId ?? null,
- session.sessionInit ?? null,
- ],
- );
- }
-
- async listEvents(request: ListEventsRequest): Promise> {
- await this.ready();
-
- const offset = parseCursor(request.cursor);
- const limit = normalizeLimit(request.limit);
-
- const rowsResult = await this.pool.query(
- `SELECT id, event_index, session_id, created_at, connection_id, sender, payload_json
- FROM ${this.table("events")}
- WHERE session_id = $1
- ORDER BY event_index ASC, id ASC
- LIMIT $2 OFFSET $3`,
- [request.sessionId, limit, offset],
- );
-
- const countResult = await this.pool.query<{ count: string }>(`SELECT COUNT(*) AS count FROM ${this.table("events")} WHERE session_id = $1`, [
- request.sessionId,
- ]);
- const total = parseInteger(countResult.rows[0]?.count ?? "0");
- const nextOffset = offset + rowsResult.rows.length;
-
- return {
- items: rowsResult.rows.map(decodeEventRow),
- nextCursor: nextOffset < total ? String(nextOffset) : undefined,
- };
- }
-
- async insertEvent(_sessionId: string, event: SessionEvent): Promise {
- await this.ready();
-
- await this.pool.query(
- `INSERT INTO ${this.table("events")} (
- id, event_index, session_id, created_at, connection_id, sender, payload_json
- ) VALUES ($1, $2, $3, $4, $5, $6, $7)
- ON CONFLICT(id) DO UPDATE SET
- event_index = EXCLUDED.event_index,
- session_id = EXCLUDED.session_id,
- created_at = EXCLUDED.created_at,
- connection_id = EXCLUDED.connection_id,
- sender = EXCLUDED.sender,
- payload_json = EXCLUDED.payload_json`,
- [event.id, event.eventIndex, event.sessionId, event.createdAt, event.connectionId, event.sender, event.payload],
- );
- }
-
- async close(): Promise {
- if (!this.ownsPool) {
- return;
- }
- await this.pool.end();
- }
-
- private async ready(): Promise {
- await this.initialized;
- }
-
- private table(name: "sessions" | "events"): string {
- return `"${this.schema}"."${name}"`;
- }
-
- private async initialize(): Promise {
- await this.pool.query(`CREATE SCHEMA IF NOT EXISTS "${this.schema}"`);
-
- await this.pool.query(`
- CREATE TABLE IF NOT EXISTS ${this.table("sessions")} (
- id TEXT PRIMARY KEY,
- agent TEXT NOT NULL,
- agent_session_id TEXT NOT NULL,
- last_connection_id TEXT NOT NULL,
- created_at BIGINT NOT NULL,
- destroyed_at BIGINT,
- sandbox_id TEXT,
- session_init_json JSONB
- )
- `);
-
- await this.pool.query(`
- ALTER TABLE ${this.table("sessions")}
- ADD COLUMN IF NOT EXISTS sandbox_id TEXT
- `);
-
- await this.pool.query(`
- CREATE TABLE IF NOT EXISTS ${this.table("events")} (
- id TEXT PRIMARY KEY,
- event_index BIGINT NOT NULL,
- session_id TEXT NOT NULL,
- created_at BIGINT NOT NULL,
- connection_id TEXT NOT NULL,
- sender TEXT NOT NULL,
- payload_json JSONB NOT NULL
- )
- `);
-
- await this.pool.query(`
- ALTER TABLE ${this.table("events")}
- ALTER COLUMN id TYPE TEXT USING id::TEXT
- `);
-
- await this.pool.query(`
- ALTER TABLE ${this.table("events")}
- ADD COLUMN IF NOT EXISTS event_index BIGINT
- `);
-
- await this.pool.query(`
- WITH ranked AS (
- SELECT id, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC) AS ranked_index
- FROM ${this.table("events")}
- )
- UPDATE ${this.table("events")} AS current_events
- SET event_index = ranked.ranked_index
- FROM ranked
- WHERE current_events.id = ranked.id
- AND current_events.event_index IS NULL
- `);
-
- await this.pool.query(`
- ALTER TABLE ${this.table("events")}
- ALTER COLUMN event_index SET NOT NULL
- `);
-
- await this.pool.query(`
- CREATE INDEX IF NOT EXISTS idx_events_session_order
- ON ${this.table("events")}(session_id, event_index, id)
- `);
- }
-}
-
-type SessionRow = {
- id: string;
- agent: string;
- agent_session_id: string;
- last_connection_id: string;
- created_at: string | number;
- destroyed_at: string | number | null;
- sandbox_id: string | null;
- session_init_json: unknown | null;
-};
-
-type EventRow = {
- id: string | number;
- event_index: string | number;
- session_id: string;
- created_at: string | number;
- connection_id: string;
- sender: string;
- payload_json: unknown;
-};
-
-function decodeSessionRow(row: SessionRow): SessionRecord {
- return {
- id: row.id,
- agent: row.agent,
- agentSessionId: row.agent_session_id,
- lastConnectionId: row.last_connection_id,
- createdAt: parseInteger(row.created_at),
- destroyedAt: row.destroyed_at === null ? undefined : parseInteger(row.destroyed_at),
- sandboxId: row.sandbox_id ?? undefined,
- sessionInit: row.session_init_json ? (row.session_init_json as SessionRecord["sessionInit"]) : undefined,
- };
-}
-
-function decodeEventRow(row: EventRow): SessionEvent {
- return {
- id: String(row.id),
- eventIndex: parseInteger(row.event_index),
- sessionId: row.session_id,
- createdAt: parseInteger(row.created_at),
- connectionId: row.connection_id,
- sender: parseSender(row.sender),
- payload: row.payload_json as SessionEvent["payload"],
- };
-}
-
-function normalizeLimit(limit: number | undefined): number {
- if (!Number.isFinite(limit) || (limit ?? 0) < 1) {
- return DEFAULT_LIST_LIMIT;
- }
- return Math.floor(limit as number);
-}
-
-function parseCursor(cursor: string | undefined): number {
- if (!cursor) {
- return 0;
- }
- const parsed = Number.parseInt(cursor, 10);
- if (!Number.isFinite(parsed) || parsed < 0) {
- return 0;
- }
- return parsed;
-}
-
-function parseInteger(value: string | number): number {
- const parsed = typeof value === "number" ? value : Number.parseInt(value, 10);
- if (!Number.isFinite(parsed)) {
- throw new Error(`Invalid integer value returned by postgres: ${String(value)}`);
- }
- return parsed;
-}
-
-function parseSender(value: string): SessionEvent["sender"] {
- if (value === "agent" || value === "client") {
- return value;
- }
- throw new Error(`Invalid sender value returned by postgres: ${value}`);
-}
-
-function normalizeSchema(schema: string): string {
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(schema)) {
- throw new Error(`Invalid schema name '${schema}'. Use letters, numbers, and underscores only.`);
- }
- return schema;
-}
+throw new Error(
+ "@sandbox-agent/persist-postgres has been deprecated and removed. " +
+ "Copy the reference implementation from examples/persist-postgres into your project instead. " +
+ "See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-postgres",
+);
diff --git a/sdks/persist-postgres/tests/integration.test.ts b/sdks/persist-postgres/tests/integration.test.ts
deleted file mode 100644
index ddd4123..0000000
--- a/sdks/persist-postgres/tests/integration.test.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
-import { execFileSync } from "node:child_process";
-import { existsSync, mkdtempSync, rmSync } from "node:fs";
-import { dirname, join, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-import { tmpdir } from "node:os";
-import { randomUUID } from "node:crypto";
-import { Client } from "pg";
-import { SandboxAgent } from "sandbox-agent";
-import { spawnSandboxAgent, type SandboxAgentSpawnHandle } from "../../typescript/src/spawn.ts";
-import { prepareMockAgentDataHome } from "../../typescript/tests/helpers/mock-agent.ts";
-import { PostgresSessionPersistDriver } from "../src/index.ts";
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-
-function findBinary(): string | null {
- if (process.env.SANDBOX_AGENT_BIN) {
- return process.env.SANDBOX_AGENT_BIN;
- }
-
- const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")];
-
- for (const p of cargoPaths) {
- if (existsSync(p)) {
- return p;
- }
- }
-
- return null;
-}
-
-const BINARY_PATH = findBinary();
-if (!BINARY_PATH) {
- throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN.");
-}
-if (!process.env.SANDBOX_AGENT_BIN) {
- process.env.SANDBOX_AGENT_BIN = BINARY_PATH;
-}
-
-interface PostgresContainer {
- containerId: string;
- connectionString: string;
-}
-
-describe("Postgres persistence driver", () => {
- let handle: SandboxAgentSpawnHandle;
- let baseUrl: string;
- let token: string;
- let dataHome: string;
- let postgres: PostgresContainer | null = null;
-
- beforeAll(async () => {
- dataHome = mkdtempSync(join(tmpdir(), "postgres-integration-"));
- prepareMockAgentDataHome(dataHome);
-
- handle = await spawnSandboxAgent({
- enabled: true,
- log: "silent",
- timeoutMs: 30000,
- env: {
- XDG_DATA_HOME: dataHome,
- HOME: dataHome,
- USERPROFILE: dataHome,
- APPDATA: join(dataHome, "AppData", "Roaming"),
- LOCALAPPDATA: join(dataHome, "AppData", "Local"),
- },
- });
- baseUrl = handle.baseUrl;
- token = handle.token;
- });
-
- beforeEach(async () => {
- postgres = await startPostgresContainer();
- });
-
- afterEach(() => {
- if (postgres) {
- stopPostgresContainer(postgres.containerId);
- postgres = null;
- }
- });
-
- afterAll(async () => {
- await handle.dispose();
- rmSync(dataHome, { recursive: true, force: true });
- });
-
- it("persists session/event history across SDK instances and supports replay restore", async () => {
- const connectionString = requirePostgres(postgres).connectionString;
-
- const persist1 = new PostgresSessionPersistDriver({
- connectionString,
- });
-
- const sdk1 = await SandboxAgent.connect({
- baseUrl,
- token,
- persist: persist1,
- replayMaxEvents: 40,
- replayMaxChars: 16000,
- });
-
- const created = await sdk1.createSession({ agent: "mock" });
- await created.prompt([{ type: "text", text: "postgres-first" }]);
- const firstConnectionId = created.lastConnectionId;
-
- await sdk1.dispose();
- await persist1.close();
-
- const persist2 = new PostgresSessionPersistDriver({
- connectionString,
- });
- const sdk2 = await SandboxAgent.connect({
- baseUrl,
- token,
- persist: persist2,
- replayMaxEvents: 40,
- replayMaxChars: 16000,
- });
-
- const restored = await sdk2.resumeSession(created.id);
- expect(restored.lastConnectionId).not.toBe(firstConnectionId);
-
- await restored.prompt([{ type: "text", text: "postgres-second" }]);
-
- const sessions = await sdk2.listSessions({ limit: 20 });
- expect(sessions.items.some((entry) => entry.id === created.id)).toBe(true);
-
- const events = await sdk2.getEvents({ sessionId: created.id, limit: 1000 });
- expect(events.items.length).toBeGreaterThan(0);
- expect(events.items.every((event) => typeof event.id === "string")).toBe(true);
- expect(events.items.every((event) => Number.isInteger(event.eventIndex))).toBe(true);
-
- for (let i = 1; i < events.items.length; i += 1) {
- expect(events.items[i]!.eventIndex).toBeGreaterThanOrEqual(events.items[i - 1]!.eventIndex);
- }
-
- const replayInjected = events.items.find((event) => {
- if (event.sender !== "client") {
- return false;
- }
- const payload = event.payload as Record;
- const method = payload.method;
- const params = payload.params as Record | undefined;
- const prompt = Array.isArray(params?.prompt) ? params?.prompt : [];
- const firstBlock = prompt[0] as Record | undefined;
- return method === "session/prompt" && typeof firstBlock?.text === "string" && firstBlock.text.includes("Previous session history is replayed below");
- });
- expect(replayInjected).toBeTruthy();
-
- await sdk2.dispose();
- await persist2.close();
- });
-});
-
-async function startPostgresContainer(): Promise {
- const name = `sandbox-agent-postgres-${randomUUID()}`;
- const containerId = runDockerCommand([
- "run",
- "-d",
- "--rm",
- "--name",
- name,
- "-e",
- "POSTGRES_USER=postgres",
- "-e",
- "POSTGRES_PASSWORD=postgres",
- "-e",
- "POSTGRES_DB=sandboxagent",
- "-p",
- "127.0.0.1::5432",
- "postgres:16-alpine",
- ]);
-
- const portOutput = runDockerCommand(["port", containerId, "5432/tcp"]);
- const port = parsePort(portOutput);
- const connectionString = `postgres://postgres:postgres@127.0.0.1:${port}/sandboxagent`;
- await waitForPostgres(connectionString);
-
- return {
- containerId,
- connectionString,
- };
-}
-
-function stopPostgresContainer(containerId: string): void {
- try {
- runDockerCommand(["rm", "-f", containerId]);
- } catch {
- // Container may already be gone when test teardown runs.
- }
-}
-
-function runDockerCommand(args: string[]): string {
- return execFileSync("docker", args, {
- encoding: "utf8",
- stdio: ["ignore", "pipe", "pipe"],
- }).trim();
-}
-
-function parsePort(output: string): string {
- const firstLine = output.split("\n")[0]?.trim() ?? "";
- const match = firstLine.match(/:(\d+)$/);
- if (!match) {
- throw new Error(`Failed to parse docker port output: '${output}'`);
- }
- return match[1];
-}
-
-async function waitForPostgres(connectionString: string): Promise {
- const timeoutMs = 30000;
- const deadline = Date.now() + timeoutMs;
- let lastError: unknown;
-
- while (Date.now() < deadline) {
- const client = new Client({ connectionString });
- try {
- await client.connect();
- await client.query("SELECT 1");
- await client.end();
- return;
- } catch (error) {
- lastError = error;
- try {
- await client.end();
- } catch {
- // Ignore cleanup failures while retrying.
- }
- await delay(250);
- }
- }
-
- throw new Error(`Postgres container did not become ready: ${String(lastError)}`);
-}
-
-function delay(ms: number): Promise {
- return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
-}
-
-function requirePostgres(container: PostgresContainer | null): PostgresContainer {
- if (!container) {
- throw new Error("Postgres container was not initialized for this test.");
- }
- return container;
-}
diff --git a/sdks/persist-rivet/README.md b/sdks/persist-rivet/README.md
new file mode 100644
index 0000000..49e4a7a
--- /dev/null
+++ b/sdks/persist-rivet/README.md
@@ -0,0 +1,5 @@
+# @sandbox-agent/persist-rivet
+
+> **Deprecated:** This package has been deprecated and removed.
+
+Copy the driver source directly into your project. See the [session persistence docs](https://sandboxagent.dev/session-persistence) for guidance.
diff --git a/sdks/persist-rivet/package.json b/sdks/persist-rivet/package.json
index 723e3b4..b297670 100644
--- a/sdks/persist-rivet/package.json
+++ b/sdks/persist-rivet/package.json
@@ -16,30 +16,16 @@
"import": "./dist/index.js"
}
},
- "dependencies": {
- "sandbox-agent": "workspace:*"
- },
- "peerDependencies": {
- "rivetkit": ">=0.5.0"
- },
- "peerDependenciesMeta": {
- "rivetkit": {
- "optional": true
- }
- },
"files": [
"dist"
],
"scripts": {
"build": "tsup",
- "typecheck": "tsc --noEmit",
- "test": "vitest run",
- "test:watch": "vitest"
+ "typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsup": "^8.0.0",
- "typescript": "^5.7.0",
- "vitest": "^3.0.0"
+ "typescript": "^5.7.0"
}
}
diff --git a/sdks/persist-rivet/src/index.ts b/sdks/persist-rivet/src/index.ts
index b89b7df..8e77d33 100644
--- a/sdks/persist-rivet/src/index.ts
+++ b/sdks/persist-rivet/src/index.ts
@@ -1,168 +1,5 @@
-import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
-
-/** Structural type compatible with rivetkit's ActorContext without importing it. */
-export interface ActorContextLike {
- state: Record;
-}
-
-export interface RivetPersistData {
- sessions: Record;
- events: Record;
-}
-
-export type RivetPersistState = {
- _sandboxAgentPersist: RivetPersistData;
-};
-
-export interface RivetSessionPersistDriverOptions {
- /** Maximum number of sessions to retain. Oldest are evicted first. Default: 1024. */
- maxSessions?: number;
- /** Maximum events per session. Oldest are trimmed first. Default: 500. */
- maxEventsPerSession?: number;
- /** Key on `c.state` where persist data is stored. Default: `"_sandboxAgentPersist"`. */
- stateKey?: string;
-}
-
-const DEFAULT_MAX_SESSIONS = 1024;
-const DEFAULT_MAX_EVENTS_PER_SESSION = 500;
-const DEFAULT_LIST_LIMIT = 100;
-const DEFAULT_STATE_KEY = "_sandboxAgentPersist";
-
-export class RivetSessionPersistDriver implements SessionPersistDriver {
- private readonly maxSessions: number;
- private readonly maxEventsPerSession: number;
- private readonly stateKey: string;
- private readonly ctx: ActorContextLike;
-
- constructor(ctx: ActorContextLike, options: RivetSessionPersistDriverOptions = {}) {
- this.ctx = ctx;
- this.maxSessions = normalizeCap(options.maxSessions, DEFAULT_MAX_SESSIONS);
- this.maxEventsPerSession = normalizeCap(options.maxEventsPerSession, DEFAULT_MAX_EVENTS_PER_SESSION);
- this.stateKey = options.stateKey ?? DEFAULT_STATE_KEY;
-
- // Auto-initialize if absent; preserve existing data on actor wake.
- if (!this.ctx.state[this.stateKey]) {
- this.ctx.state[this.stateKey] = { sessions: {}, events: {} } satisfies RivetPersistData;
- }
- }
-
- private get data(): RivetPersistData {
- return this.ctx.state[this.stateKey] as RivetPersistData;
- }
-
- async getSession(id: string): Promise {
- const session = this.data.sessions[id];
- return session ? cloneSessionRecord(session) : undefined;
- }
-
- async listSessions(request: ListPageRequest = {}): Promise> {
- const sorted = Object.values(this.data.sessions).sort((a, b) => {
- if (a.createdAt !== b.createdAt) {
- return a.createdAt - b.createdAt;
- }
- return a.id.localeCompare(b.id);
- });
- const page = paginate(sorted, request);
- return {
- items: page.items.map(cloneSessionRecord),
- nextCursor: page.nextCursor,
- };
- }
-
- async updateSession(session: SessionRecord): Promise {
- this.data.sessions[session.id] = { ...session };
-
- if (!this.data.events[session.id]) {
- this.data.events[session.id] = [];
- }
-
- const ids = Object.keys(this.data.sessions);
- if (ids.length <= this.maxSessions) {
- return;
- }
-
- const overflow = ids.length - this.maxSessions;
- const removable = Object.values(this.data.sessions)
- .sort((a, b) => {
- if (a.createdAt !== b.createdAt) {
- return a.createdAt - b.createdAt;
- }
- return a.id.localeCompare(b.id);
- })
- .slice(0, overflow)
- .map((s) => s.id);
-
- for (const sessionId of removable) {
- delete this.data.sessions[sessionId];
- delete this.data.events[sessionId];
- }
- }
-
- async listEvents(request: ListEventsRequest): Promise> {
- const all = [...(this.data.events[request.sessionId] ?? [])].sort((a, b) => {
- if (a.eventIndex !== b.eventIndex) {
- return a.eventIndex - b.eventIndex;
- }
- return a.id.localeCompare(b.id);
- });
- const page = paginate(all, request);
- return {
- items: page.items.map(cloneSessionEvent),
- nextCursor: page.nextCursor,
- };
- }
-
- async insertEvent(sessionId: string, event: SessionEvent): Promise {
- const events = this.data.events[sessionId] ?? [];
- events.push(cloneSessionEvent(event));
-
- if (events.length > this.maxEventsPerSession) {
- events.splice(0, events.length - this.maxEventsPerSession);
- }
-
- this.data.events[sessionId] = events;
- }
-}
-
-function cloneSessionRecord(session: SessionRecord): SessionRecord {
- return {
- ...session,
- sessionInit: session.sessionInit ? (JSON.parse(JSON.stringify(session.sessionInit)) as SessionRecord["sessionInit"]) : undefined,
- };
-}
-
-function cloneSessionEvent(event: SessionEvent): SessionEvent {
- return {
- ...event,
- payload: JSON.parse(JSON.stringify(event.payload)) as SessionEvent["payload"],
- };
-}
-
-function normalizeCap(value: number | undefined, fallback: number): number {
- if (!Number.isFinite(value) || (value ?? 0) < 1) {
- return fallback;
- }
- return Math.floor(value as number);
-}
-
-function paginate(items: T[], request: ListPageRequest): ListPage {
- const offset = parseCursor(request.cursor);
- const limit = normalizeCap(request.limit, DEFAULT_LIST_LIMIT);
- const slice = items.slice(offset, offset + limit);
- const nextOffset = offset + slice.length;
- return {
- items: slice,
- nextCursor: nextOffset < items.length ? String(nextOffset) : undefined,
- };
-}
-
-function parseCursor(cursor: string | undefined): number {
- if (!cursor) {
- return 0;
- }
- const parsed = Number.parseInt(cursor, 10);
- if (!Number.isFinite(parsed) || parsed < 0) {
- return 0;
- }
- return parsed;
-}
+throw new Error(
+ "@sandbox-agent/persist-rivet has been deprecated and removed. " +
+ "Copy the reference implementation into your project instead. " +
+ "See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-rivet",
+);
diff --git a/sdks/persist-rivet/tests/driver.test.ts b/sdks/persist-rivet/tests/driver.test.ts
deleted file mode 100644
index 839cffe..0000000
--- a/sdks/persist-rivet/tests/driver.test.ts
+++ /dev/null
@@ -1,236 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { RivetSessionPersistDriver } from "../src/index.ts";
-import type { RivetPersistData } from "../src/index.ts";
-
-function makeCtx() {
- return { state: {} as Record };
-}
-
-describe("RivetSessionPersistDriver", () => {
- it("auto-initializes state on construction", () => {
- const ctx = makeCtx();
- new RivetSessionPersistDriver(ctx);
- const data = ctx.state._sandboxAgentPersist as RivetPersistData;
- expect(data).toBeDefined();
- expect(data.sessions).toEqual({});
- expect(data.events).toEqual({});
- });
-
- it("preserves existing state on construction (actor wake)", async () => {
- const ctx = makeCtx();
- const driver1 = new RivetSessionPersistDriver(ctx);
-
- await driver1.updateSession({
- id: "s-1",
- agent: "mock",
- agentSessionId: "a-1",
- lastConnectionId: "c-1",
- createdAt: 100,
- });
-
- // Simulate actor wake: new driver instance, same state object
- const driver2 = new RivetSessionPersistDriver(ctx);
- const session = await driver2.getSession("s-1");
- expect(session?.id).toBe("s-1");
- expect(session?.createdAt).toBe(100);
- });
-
- it("stores and retrieves sessions", async () => {
- const driver = new RivetSessionPersistDriver(makeCtx());
-
- await driver.updateSession({
- id: "s-1",
- agent: "mock",
- agentSessionId: "a-1",
- lastConnectionId: "c-1",
- createdAt: 100,
- });
-
- await driver.updateSession({
- id: "s-2",
- agent: "mock",
- agentSessionId: "a-2",
- lastConnectionId: "c-2",
- createdAt: 200,
- destroyedAt: 300,
- });
-
- const loaded = await driver.getSession("s-2");
- expect(loaded?.destroyedAt).toBe(300);
-
- const missing = await driver.getSession("s-nonexistent");
- expect(missing).toBeUndefined();
- });
-
- it("pages sessions sorted by createdAt", async () => {
- const driver = new RivetSessionPersistDriver(makeCtx());
-
- await driver.updateSession({
- id: "s-1",
- agent: "mock",
- agentSessionId: "a-1",
- lastConnectionId: "c-1",
- createdAt: 100,
- });
-
- await driver.updateSession({
- id: "s-2",
- agent: "mock",
- agentSessionId: "a-2",
- lastConnectionId: "c-2",
- createdAt: 200,
- });
-
- const page1 = await driver.listSessions({ limit: 1 });
- expect(page1.items).toHaveLength(1);
- expect(page1.items[0]?.id).toBe("s-1");
- expect(page1.nextCursor).toBeTruthy();
-
- const page2 = await driver.listSessions({ cursor: page1.nextCursor, limit: 1 });
- expect(page2.items).toHaveLength(1);
- expect(page2.items[0]?.id).toBe("s-2");
- expect(page2.nextCursor).toBeUndefined();
- });
-
- it("stores and pages events", async () => {
- const driver = new RivetSessionPersistDriver(makeCtx());
-
- await driver.updateSession({
- id: "s-1",
- agent: "mock",
- agentSessionId: "a-1",
- lastConnectionId: "c-1",
- createdAt: 1,
- });
-
- await driver.insertEvent("s-1", {
- id: "evt-1",
- eventIndex: 1,
- sessionId: "s-1",
- createdAt: 1,
- connectionId: "c-1",
- sender: "client",
- payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } },
- });
-
- await driver.insertEvent("s-1", {
- id: "evt-2",
- eventIndex: 2,
- sessionId: "s-1",
- createdAt: 2,
- connectionId: "c-1",
- sender: "agent",
- payload: { jsonrpc: "2.0", method: "session/update", params: { sessionId: "a-1" } },
- });
-
- const eventsPage = await driver.listEvents({ sessionId: "s-1", limit: 10 });
- expect(eventsPage.items).toHaveLength(2);
- expect(eventsPage.items[0]?.id).toBe("evt-1");
- expect(eventsPage.items[0]?.eventIndex).toBe(1);
- expect(eventsPage.items[1]?.id).toBe("evt-2");
- expect(eventsPage.items[1]?.eventIndex).toBe(2);
- });
-
- it("evicts oldest sessions when maxSessions exceeded", async () => {
- const driver = new RivetSessionPersistDriver(makeCtx(), { maxSessions: 2 });
-
- await driver.updateSession({
- id: "s-1",
- agent: "mock",
- agentSessionId: "a-1",
- lastConnectionId: "c-1",
- createdAt: 100,
- });
-
- await driver.updateSession({
- id: "s-2",
- agent: "mock",
- agentSessionId: "a-2",
- lastConnectionId: "c-2",
- createdAt: 200,
- });
-
- // Adding a third session should evict the oldest (s-1)
- await driver.updateSession({
- id: "s-3",
- agent: "mock",
- agentSessionId: "a-3",
- lastConnectionId: "c-3",
- createdAt: 300,
- });
-
- expect(await driver.getSession("s-1")).toBeUndefined();
- expect(await driver.getSession("s-2")).toBeDefined();
- expect(await driver.getSession("s-3")).toBeDefined();
- });
-
- it("trims oldest events when maxEventsPerSession exceeded", async () => {
- const driver = new RivetSessionPersistDriver(makeCtx(), { maxEventsPerSession: 2 });
-
- await driver.updateSession({
- id: "s-1",
- agent: "mock",
- agentSessionId: "a-1",
- lastConnectionId: "c-1",
- createdAt: 1,
- });
-
- for (let i = 1; i <= 3; i++) {
- await driver.insertEvent("s-1", {
- id: `evt-${i}`,
- eventIndex: i,
- sessionId: "s-1",
- createdAt: i,
- connectionId: "c-1",
- sender: "client",
- payload: { jsonrpc: "2.0", method: "session/prompt", params: { sessionId: "a-1" } },
- });
- }
-
- const page = await driver.listEvents({ sessionId: "s-1" });
- expect(page.items).toHaveLength(2);
- // Oldest event (evt-1) should be trimmed
- expect(page.items[0]?.id).toBe("evt-2");
- expect(page.items[1]?.id).toBe("evt-3");
- });
-
- it("clones data to prevent external mutation", async () => {
- const driver = new RivetSessionPersistDriver(makeCtx());
-
- await driver.updateSession({
- id: "s-1",
- agent: "mock",
- agentSessionId: "a-1",
- lastConnectionId: "c-1",
- createdAt: 1,
- });
-
- const s1 = await driver.getSession("s-1");
- const s2 = await driver.getSession("s-1");
- expect(s1).toEqual(s2);
- expect(s1).not.toBe(s2); // Different object references
- });
-
- it("supports custom stateKey", async () => {
- const ctx = makeCtx();
- const driver = new RivetSessionPersistDriver(ctx, { stateKey: "myPersist" });
-
- await driver.updateSession({
- id: "s-1",
- agent: "mock",
- agentSessionId: "a-1",
- lastConnectionId: "c-1",
- createdAt: 1,
- });
-
- expect((ctx.state.myPersist as RivetPersistData).sessions["s-1"]).toBeDefined();
- expect(ctx.state._sandboxAgentPersist).toBeUndefined();
- });
-
- it("returns empty results for unknown session events", async () => {
- const driver = new RivetSessionPersistDriver(makeCtx());
- const page = await driver.listEvents({ sessionId: "nonexistent" });
- expect(page.items).toHaveLength(0);
- expect(page.nextCursor).toBeUndefined();
- });
-});
diff --git a/sdks/persist-sqlite/README.md b/sdks/persist-sqlite/README.md
new file mode 100644
index 0000000..c1fe980
--- /dev/null
+++ b/sdks/persist-sqlite/README.md
@@ -0,0 +1,5 @@
+# @sandbox-agent/persist-sqlite
+
+> **Deprecated:** This package has been deprecated and removed. The implementation now lives as a copy-paste reference in [`examples/persist-sqlite`](../../examples/persist-sqlite).
+
+Install `better-sqlite3` directly and copy the driver source into your project. See the [full example](https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-sqlite).
diff --git a/sdks/persist-sqlite/package.json b/sdks/persist-sqlite/package.json
index 852e384..3deef42 100644
--- a/sdks/persist-sqlite/package.json
+++ b/sdks/persist-sqlite/package.json
@@ -16,24 +16,17 @@
"import": "./dist/index.js"
}
},
- "dependencies": {
- "better-sqlite3": "^11.0.0",
- "sandbox-agent": "workspace:*"
- },
+ "dependencies": {},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
- "typecheck": "tsc --noEmit",
- "test": "vitest run",
- "test:watch": "vitest"
+ "typecheck": "tsc --noEmit"
},
"devDependencies": {
- "@types/better-sqlite3": "^7.0.0",
"@types/node": "^22.0.0",
"tsup": "^8.0.0",
- "typescript": "^5.7.0",
- "vitest": "^3.0.0"
+ "typescript": "^5.7.0"
}
}
diff --git a/sdks/persist-sqlite/src/index.ts b/sdks/persist-sqlite/src/index.ts
index b04b0fc..75386b2 100644
--- a/sdks/persist-sqlite/src/index.ts
+++ b/sdks/persist-sqlite/src/index.ts
@@ -1,294 +1,5 @@
-import Database from "better-sqlite3";
-import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionPersistDriver, SessionRecord } from "sandbox-agent";
-
-const DEFAULT_LIST_LIMIT = 100;
-
-export interface SQLiteSessionPersistDriverOptions {
- filename?: string;
-}
-
-export class SQLiteSessionPersistDriver implements SessionPersistDriver {
- private readonly db: Database.Database;
-
- constructor(options: SQLiteSessionPersistDriverOptions = {}) {
- this.db = new Database(options.filename ?? ":memory:");
- this.initialize();
- }
-
- async getSession(id: string): Promise {
- const row = this.db
- .prepare(
- `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
- FROM sessions WHERE id = ?`,
- )
- .get(id) as SessionRow | undefined;
-
- if (!row) {
- return undefined;
- }
-
- return decodeSessionRow(row);
- }
-
- async listSessions(request: ListPageRequest = {}): Promise> {
- const offset = parseCursor(request.cursor);
- const limit = normalizeLimit(request.limit);
-
- const rows = this.db
- .prepare(
- `SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
- FROM sessions
- ORDER BY created_at ASC, id ASC
- LIMIT ? OFFSET ?`,
- )
- .all(limit, offset) as SessionRow[];
-
- const countRow = this.db.prepare(`SELECT COUNT(*) as count FROM sessions`).get() as { count: number };
- const nextOffset = offset + rows.length;
-
- return {
- items: rows.map(decodeSessionRow),
- nextCursor: nextOffset < countRow.count ? String(nextOffset) : undefined,
- };
- }
-
- async updateSession(session: SessionRecord): Promise {
- this.db
- .prepare(
- `INSERT INTO sessions (
- id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(id) DO UPDATE SET
- agent = excluded.agent,
- agent_session_id = excluded.agent_session_id,
- last_connection_id = excluded.last_connection_id,
- created_at = excluded.created_at,
- destroyed_at = excluded.destroyed_at,
- sandbox_id = excluded.sandbox_id,
- session_init_json = excluded.session_init_json`,
- )
- .run(
- session.id,
- session.agent,
- session.agentSessionId,
- session.lastConnectionId,
- session.createdAt,
- session.destroyedAt ?? null,
- session.sandboxId ?? null,
- session.sessionInit ? JSON.stringify(session.sessionInit) : null,
- );
- }
-
- async listEvents(request: ListEventsRequest): Promise> {
- const offset = parseCursor(request.cursor);
- const limit = normalizeLimit(request.limit);
-
- const rows = this.db
- .prepare(
- `SELECT id, event_index, session_id, created_at, connection_id, sender, payload_json
- FROM events
- WHERE session_id = ?
- ORDER BY event_index ASC, id ASC
- LIMIT ? OFFSET ?`,
- )
- .all(request.sessionId, limit, offset) as EventRow[];
-
- const countRow = this.db.prepare(`SELECT COUNT(*) as count FROM events WHERE session_id = ?`).get(request.sessionId) as { count: number };
-
- const nextOffset = offset + rows.length;
-
- return {
- items: rows.map(decodeEventRow),
- nextCursor: nextOffset < countRow.count ? String(nextOffset) : undefined,
- };
- }
-
- async insertEvent(_sessionId: string, event: SessionEvent): Promise {
- this.db
- .prepare(
- `INSERT INTO events (
- id, event_index, session_id, created_at, connection_id, sender, payload_json
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(id) DO UPDATE SET
- event_index = excluded.event_index,
- session_id = excluded.session_id,
- created_at = excluded.created_at,
- connection_id = excluded.connection_id,
- sender = excluded.sender,
- payload_json = excluded.payload_json`,
- )
- .run(event.id, event.eventIndex, event.sessionId, event.createdAt, event.connectionId, event.sender, JSON.stringify(event.payload));
- }
-
- close(): void {
- this.db.close();
- }
-
- private initialize(): void {
- this.db.exec(`
- CREATE TABLE IF NOT EXISTS sessions (
- id TEXT PRIMARY KEY,
- agent TEXT NOT NULL,
- agent_session_id TEXT NOT NULL,
- last_connection_id TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- destroyed_at INTEGER,
- sandbox_id TEXT,
- session_init_json TEXT
- )
- `);
-
- const sessionColumns = this.db.prepare(`PRAGMA table_info(sessions)`).all() as TableInfoRow[];
- if (!sessionColumns.some((column) => column.name === "sandbox_id")) {
- this.db.exec(`ALTER TABLE sessions ADD COLUMN sandbox_id TEXT`);
- }
-
- this.ensureEventsTable();
- }
-
- private ensureEventsTable(): void {
- const tableInfo = this.db.prepare(`PRAGMA table_info(events)`).all() as TableInfoRow[];
- if (tableInfo.length === 0) {
- this.createEventsTable();
- return;
- }
-
- const idColumn = tableInfo.find((column) => column.name === "id");
- const hasEventIndex = tableInfo.some((column) => column.name === "event_index");
- const idType = (idColumn?.type ?? "").trim().toUpperCase();
- const idIsText = idType === "TEXT";
-
- if (!idIsText || !hasEventIndex) {
- this.rebuildEventsTable(hasEventIndex);
- }
-
- this.db.exec(`
- CREATE INDEX IF NOT EXISTS idx_events_session_order
- ON events(session_id, event_index, id)
- `);
- }
-
- private createEventsTable(): void {
- this.db.exec(`
- CREATE TABLE IF NOT EXISTS events (
- id TEXT PRIMARY KEY,
- event_index INTEGER NOT NULL,
- session_id TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- connection_id TEXT NOT NULL,
- sender TEXT NOT NULL,
- payload_json TEXT NOT NULL
- );
-
- CREATE INDEX IF NOT EXISTS idx_events_session_order
- ON events(session_id, event_index, id)
- `);
- }
-
- private rebuildEventsTable(hasEventIndex: boolean): void {
- this.db.exec(`
- ALTER TABLE events RENAME TO events_legacy;
- `);
-
- this.createEventsTable();
-
- if (hasEventIndex) {
- this.db.exec(`
- INSERT INTO events (id, event_index, session_id, created_at, connection_id, sender, payload_json)
- SELECT
- CAST(id AS TEXT),
- COALESCE(event_index, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC)),
- session_id,
- created_at,
- connection_id,
- sender,
- payload_json
- FROM events_legacy
- `);
- } else {
- this.db.exec(`
- INSERT INTO events (id, event_index, session_id, created_at, connection_id, sender, payload_json)
- SELECT
- CAST(id AS TEXT),
- ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at ASC, id ASC),
- session_id,
- created_at,
- connection_id,
- sender,
- payload_json
- FROM events_legacy
- `);
- }
-
- this.db.exec(`DROP TABLE events_legacy`);
- }
-}
-
-type SessionRow = {
- id: string;
- agent: string;
- agent_session_id: string;
- last_connection_id: string;
- created_at: number;
- destroyed_at: number | null;
- sandbox_id: string | null;
- session_init_json: string | null;
-};
-
-type EventRow = {
- id: string;
- event_index: number;
- session_id: string;
- created_at: number;
- connection_id: string;
- sender: "client" | "agent";
- payload_json: string;
-};
-
-type TableInfoRow = {
- name: string;
- type: string;
-};
-
-function decodeSessionRow(row: SessionRow): SessionRecord {
- return {
- id: row.id,
- agent: row.agent,
- agentSessionId: row.agent_session_id,
- lastConnectionId: row.last_connection_id,
- createdAt: row.created_at,
- destroyedAt: row.destroyed_at ?? undefined,
- sandboxId: row.sandbox_id ?? undefined,
- sessionInit: row.session_init_json ? (JSON.parse(row.session_init_json) as SessionRecord["sessionInit"]) : undefined,
- };
-}
-
-function decodeEventRow(row: EventRow): SessionEvent {
- return {
- id: row.id,
- eventIndex: row.event_index,
- sessionId: row.session_id,
- createdAt: row.created_at,
- connectionId: row.connection_id,
- sender: row.sender,
- payload: JSON.parse(row.payload_json),
- };
-}
-
-function normalizeLimit(limit: number | undefined): number {
- if (!Number.isFinite(limit) || (limit ?? 0) < 1) {
- return DEFAULT_LIST_LIMIT;
- }
- return Math.floor(limit as number);
-}
-
-function parseCursor(cursor: string | undefined): number {
- if (!cursor) {
- return 0;
- }
- const parsed = Number.parseInt(cursor, 10);
- if (!Number.isFinite(parsed) || parsed < 0) {
- return 0;
- }
- return parsed;
-}
+throw new Error(
+ "@sandbox-agent/persist-sqlite has been deprecated and removed. " +
+ "Copy the reference implementation from examples/persist-sqlite into your project instead. " +
+ "See https://github.com/nichochar/sandbox-agent/tree/main/examples/persist-sqlite",
+);
diff --git a/sdks/persist-sqlite/tests/integration.test.ts b/sdks/persist-sqlite/tests/integration.test.ts
deleted file mode 100644
index 376406c..0000000
--- a/sdks/persist-sqlite/tests/integration.test.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { describe, it, expect, beforeAll, afterAll } from "vitest";
-import { existsSync, mkdtempSync, rmSync } from "node:fs";
-import { dirname, join, resolve } from "node:path";
-import { fileURLToPath } from "node:url";
-import { tmpdir } from "node:os";
-import { SandboxAgent } from "sandbox-agent";
-import { spawnSandboxAgent, type SandboxAgentSpawnHandle } from "../../typescript/src/spawn.ts";
-import { prepareMockAgentDataHome } from "../../typescript/tests/helpers/mock-agent.ts";
-import { SQLiteSessionPersistDriver } from "../src/index.ts";
-
-const __dirname = dirname(fileURLToPath(import.meta.url));
-
-function findBinary(): string | null {
- if (process.env.SANDBOX_AGENT_BIN) {
- return process.env.SANDBOX_AGENT_BIN;
- }
-
- const cargoPaths = [resolve(__dirname, "../../../target/debug/sandbox-agent"), resolve(__dirname, "../../../target/release/sandbox-agent")];
-
- for (const p of cargoPaths) {
- if (existsSync(p)) {
- return p;
- }
- }
-
- return null;
-}
-
-const BINARY_PATH = findBinary();
-if (!BINARY_PATH) {
- throw new Error("sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN.");
-}
-if (!process.env.SANDBOX_AGENT_BIN) {
- process.env.SANDBOX_AGENT_BIN = BINARY_PATH;
-}
-
-describe("SQLite persistence driver", () => {
- let handle: SandboxAgentSpawnHandle;
- let baseUrl: string;
- let token: string;
- let dataHome: string;
-
- beforeAll(async () => {
- dataHome = mkdtempSync(join(tmpdir(), "sqlite-integration-"));
- prepareMockAgentDataHome(dataHome);
-
- handle = await spawnSandboxAgent({
- enabled: true,
- log: "silent",
- timeoutMs: 30000,
- env: {
- XDG_DATA_HOME: dataHome,
- HOME: dataHome,
- USERPROFILE: dataHome,
- APPDATA: join(dataHome, "AppData", "Roaming"),
- LOCALAPPDATA: join(dataHome, "AppData", "Local"),
- },
- });
- baseUrl = handle.baseUrl;
- token = handle.token;
- });
-
- afterAll(async () => {
- await handle.dispose();
- rmSync(dataHome, { recursive: true, force: true });
- });
-
- it("persists session/event history across SDK instances and supports replay restore", async () => {
- const tempDir = mkdtempSync(join(tmpdir(), "sqlite-persist-"));
- const dbPath = join(tempDir, "session-store.db");
-
- const persist1 = new SQLiteSessionPersistDriver({ filename: dbPath });
- const sdk1 = await SandboxAgent.connect({
- baseUrl,
- token,
- persist: persist1,
- replayMaxEvents: 40,
- replayMaxChars: 16000,
- });
-
- const created = await sdk1.createSession({ agent: "mock" });
- await created.prompt([{ type: "text", text: "sqlite-first" }]);
- const firstConnectionId = created.lastConnectionId;
-
- await sdk1.dispose();
- persist1.close();
-
- const persist2 = new SQLiteSessionPersistDriver({ filename: dbPath });
- const sdk2 = await SandboxAgent.connect({
- baseUrl,
- token,
- persist: persist2,
- replayMaxEvents: 40,
- replayMaxChars: 16000,
- });
-
- const restored = await sdk2.resumeSession(created.id);
- expect(restored.lastConnectionId).not.toBe(firstConnectionId);
-
- await restored.prompt([{ type: "text", text: "sqlite-second" }]);
-
- const sessions = await sdk2.listSessions({ limit: 20 });
- expect(sessions.items.some((entry) => entry.id === created.id)).toBe(true);
-
- const events = await sdk2.getEvents({ sessionId: created.id, limit: 1000 });
- expect(events.items.length).toBeGreaterThan(0);
- expect(events.items.every((event) => typeof event.id === "string")).toBe(true);
- expect(events.items.every((event) => Number.isInteger(event.eventIndex))).toBe(true);
-
- for (let i = 1; i < events.items.length; i += 1) {
- expect(events.items[i]!.eventIndex).toBeGreaterThanOrEqual(events.items[i - 1]!.eventIndex);
- }
-
- const replayInjected = events.items.find((event) => {
- if (event.sender !== "client") {
- return false;
- }
- const payload = event.payload as Record;
- const method = payload.method;
- const params = payload.params as Record | undefined;
- const prompt = Array.isArray(params?.prompt) ? params?.prompt : [];
- const firstBlock = prompt[0] as Record | undefined;
- return method === "session/prompt" && typeof firstBlock?.text === "string" && firstBlock.text.includes("Previous session history is replayed below");
- });
- expect(replayInjected).toBeTruthy();
-
- await sdk2.dispose();
- persist2.close();
- rmSync(tempDir, { recursive: true, force: true });
- });
-});
diff --git a/sdks/persist-sqlite/vitest.config.ts b/sdks/persist-sqlite/vitest.config.ts
deleted file mode 100644
index 8a85a83..0000000
--- a/sdks/persist-sqlite/vitest.config.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { defineConfig } from "vitest/config";
-
-export default defineConfig({
- test: {
- include: ["tests/**/*.test.ts"],
- testTimeout: 60000,
- },
-});
diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts
index 94fbc3e..e94b521 100644
--- a/sdks/typescript/src/client.ts
+++ b/sdks/typescript/src/client.ts
@@ -1770,7 +1770,7 @@ export class SandboxAgent {
};
try {
- await this.persist.insertEvent(event);
+ await this.persist.insertEvent(localSessionId, event);
break;
} catch (error) {
if (!isSessionEventIndexConflict(error) || attempt === MAX_EVENT_INDEX_INSERT_RETRIES - 1) {
diff --git a/sdks/typescript/src/providers/daytona.ts b/sdks/typescript/src/providers/daytona.ts
index eb34b88..fcae45e 100644
--- a/sdks/typescript/src/providers/daytona.ts
+++ b/sdks/typescript/src/providers/daytona.ts
@@ -7,15 +7,17 @@ const DEFAULT_PREVIEW_TTL_SECONDS = 4 * 60 * 60;
type DaytonaCreateParams = NonNullable[0]>;
+type DaytonaCreateOverrides = Partial;
+
export interface DaytonaProviderOptions {
- create?: DaytonaCreateParams | (() => DaytonaCreateParams | Promise);
+ create?: DaytonaCreateOverrides | (() => DaytonaCreateOverrides | Promise);
image?: string;
agentPort?: number;
previewTtlSeconds?: number;
deleteTimeoutSeconds?: number;
}
-async function resolveCreateOptions(value: DaytonaProviderOptions["create"]): Promise {
+async function resolveCreateOptions(value: DaytonaProviderOptions["create"]): Promise {
if (!value) return undefined;
if (typeof value === "function") return await value();
return value;
diff --git a/sdks/typescript/tests/integration.test.ts b/sdks/typescript/tests/integration.test.ts
index 003b0dd..295e688 100644
--- a/sdks/typescript/tests/integration.test.ts
+++ b/sdks/typescript/tests/integration.test.ts
@@ -70,19 +70,19 @@ class StrictUniqueSessionPersistDriver implements SessionPersistDriver {
return this.events.listEvents(request);
}
- async insertEvent(event: SessionEvent): Promise {
+ async insertEvent(sessionId: string, event: SessionEvent): Promise {
await sleep(5);
- const indexes = this.eventIndexesBySession.get(event.sessionId) ?? new Set();
+ const indexes = this.eventIndexesBySession.get(sessionId) ?? new Set();
if (indexes.has(event.eventIndex)) {
throw new Error("UNIQUE constraint failed: sandbox_agent_events.session_id, sandbox_agent_events.event_index");
}
indexes.add(event.eventIndex);
- this.eventIndexesBySession.set(event.sessionId, indexes);
+ this.eventIndexesBySession.set(sessionId, indexes);
await sleep(5);
- await this.events.insertEvent(event);
+ await this.events.insertEvent(sessionId, event);
}
}