plugins/checkpoint-orchestrator.ts V2 entry: Plugin.define({ id, setup })
├─ resolves the calling session and project (SessionResolver)
├─ registers 8 tools (ctx.tool.transform)
├─ registers 2 session hooks (prompt, compaction)
├─ subscribes to the event stream (session.compacted)
└─ delegates every decision to ↓
plugins/core/service.ts Session-scoped policy
├─ save / restore / clear / status / list
├─ snapshot policy and undo guarantees
├─ legacy import and explicit adoption
└─ uses ↓
plugins/core/store.ts SQLite storage (node:sqlite)
plugins/core/{schema,legacy,handoff,paths,types,util}.ts
core/* imports no OpenCode SDK code. That is deliberate: the test suite
imports the compiled dist/core/*.js directly, so the tests exercise production
code rather than a copy of it. The entry file is thin wiring, and it is covered
by tests/plugin.test.js, which drives the real default export through a fake
V2 context.
node:sqlite. It is the only candidate that is built into both runtimes this
project has to run in, so it adds no dependency, no native build step, and no ABI
to match.
Evidence, gathered by running the same conformance probe in both runtimes
(tests/driver-conformance.cjs, results in the table below):
| Capability | Node 22.23.2 (SQLite 3.51.3) | OpenCode runtime: Bun 1.4.2 (SQLite 3.53.2) |
|---|---|---|
node:sqlite available |
yes | yes |
bun:sqlite |
n/a | yes |
better-sqlite3 |
loads | loads (Bun compatibility layer) |
WAL, synchronous=NORMAL, busy_timeout |
yes | yes |
| Foreign keys enforced, cascade delete | yes | yes |
| Transactions, savepoints, rollback | yes | yes |
integrity_check, foreign_key_check |
yes | yes |
| Concurrent handles / writers | yes | yes |
JSON functions (json_extract) |
yes | yes |
Why not better-sqlite3: it is a V8 native addon. It only loads in the OpenCode
runtime because Bun ships a compatibility implementation, not the real addon, so
the implementation under test would differ between CI (Node) and production
(Bun). It also reintroduces exactly the install-time failure mode this project
has hit before: a platform-specific binary that must match the runtime ABI when a
user installs the package.
Why not bun:sqlite: it is Bun-only, so the whole test suite would have to run
under Bun, and the plugin would stop working if a plugin host ever used Node.
node:sqlite is marked experimental in Node (a warning is printed). It has been
stable in practice across the API surface used here, and the alternatives carry
larger risks. The driver is isolated behind CheckpointStore, so replacing it is
a single-file change.
projects(id PK, directory UNIQUE, canonical, created_at, updated_at)
sessions(id PK, project_id → projects, parent_id, title, created_at, updated_at)
checkpoints(session_id PK → sessions, project_id → projects, title, objective, status,
summary, current_plan, completed_steps, next_steps, blockers, files_touched,
notes, schema_version, synthesized, created_at, updated_at)
snapshots(id PK, session_id → sessions, project_id → projects, name, kind, captured_at,
payload, schema_version, restored_at, UNIQUE(session_id, name))
session_runtime(session_id PK → sessions, messages_since_save, last_injected_hash,
last_autosave_at, updated_at)
migrations(id PK, project_id, applied_at, details)- Arrays are stored as JSON text: they are opaque task state, and normalizing them into tables would add joins without adding integrity.
- Snapshot
payloadis a frozen JSON copy of the checkpoint at capture time. synthesizeddistinguishes real saved state from a placeholder created when compaction happens with nothing saved.checkpointsandsnapshotscarry a composite foreign key tosessions(id, project_id), so a row cannot claim a project its session does not belong to.PRAGMA user_versiontracks the schema. Migrations are forward-only, run in a transaction, and an unknown future version is refused rather than used. Migration 2 replacesUNIQUE(projects.directory)with a plain index: project identity is the id, and a host may report two ids for one canonical directory (a checkout plus a worktree of it, or a moved repository).
- Session isolation.
session_idis the scoping key for every read and write. A missing or malformed id throws (requireId) instead of bindingnulland silently matching nothing. - Atomic read-merge-write.
save()performs its read, merge and write inside oneBEGIN IMMEDIATEtransaction, so two writers on the same session cannot lose each other's fields. - No phantom progress. An unchanged payload is not written, does not move
updatedAt, and does not create a snapshot. - Undo is never pruned away. Pruning keeps the newest
historyLimitsnapshots and the newestpre-restoresnapshot. - Collision-free auto snapshot names.
auto-<ms>-<pid><seq>. A bare timestamp is not unique: two writers in the same millisecond would collide onUNIQUE(session_id, name)and silently replace history. - Derived artifacts only.
handoff.mdis written from committed rows and is never read back as authoritative state. Its path ischeckpoint-orchestrator/handoff-<sanitized session id>.md, so sessions cannot collide or escape the state directory. - Legacy import is additive and gated. Originals are copied (never deleted),
the import is transactional and idempotent via a ledger row, and legacy state
lands in a
legacy::<projectID>namespace rather than in a live session. A payload that does not match this plugin's schema is reported and skipped, so a same-named file owned by another tool can never be consumed. - Namespaced persistence. Everything this plugin writes lives under its own
namespace — the database directory, and
.opencode/state/checkpoint-orchestrator/for derived exports and migration backups — so co-installed plugins sharing.opencode/cannot collide with it.
- WAL journalling keeps readers from blocking the writer, and
synchronous = NORMALavoids anfsyncper commit. On a slow filesystem this matters: measured here, onefsynccosts roughly 0.7–1.5 s, soFULLwould make every save visibly slow. busy_timeout = 5000plus a small retry loop aroundBEGIN IMMEDIATEhandles a competing writer or checkpoint. The first schema creation is itself oneBEGIN IMMEDIATEtransaction that re-readsPRAGMA user_versionafter taking the lock, because several processes can open a brand-new database at the same instant and all observe version 0 before any of them commits.- Crash safety is not assumed:
tests/store.test.jsSIGKILLs a child process in the middle of an open transaction and asserts that the committed write survived, the uncommitted one did not, andintegrity_checkstill reportsok. Multi-process access is exercised with four concurrent writer processes, six concurrent first-opens of one new database, and six concurrent legacy imports.
| Operation | Time |
|---|---|
| Open a brand-new database (schema + first WAL setup) | ~2.7 s, once per database |
| Re-open an existing database | ~0 ms |
| One full save (transaction + checkpoint upsert + snapshot + prune) | ~80 ms |
| 100 checkpoint loads | ~5 ms |
| Close (WAL checkpoint) | ~1 s |