Services and persistence
Services, persistence, and cleanup
Use ctx.dataDir() for plugin-owned writable files. The host chooses the
persistent root and appends the plugin name. Do not infer another plugin's data
directory or use it for Project files.
ctx.dataDir() creates the folder on the first call (src/plugins/registry.ts,
dataDir). When the host passes no data root, as in tests, it falls back to the
OS temporary directory. Call it inside a handler, not while register() runs.
Use ctx.db() only with reads: ["db"]. It is a narrowed handle over the
main database. Without that grant, or when no database is wired into the
process, ctx.db() throws at the call (src/plugins/registry.ts, db):
const db = ctx.db();
db.migrate([
{
version: 1,
up(database) {
database.exec(
"CREATE TABLE IF NOT EXISTS p_my_plugin_items " +
"(id INTEGER PRIMARY KEY, value TEXT NOT NULL)"
);
}
}
]);
Migrations are ordered and run once per plugin. db.transaction(fn) runs a
synchronous function atomically and returns only after commit; returning a
Promise or other thenable is rejected and rolls back. appliedVersion() reports
the highest applied version, or 0 when none has run. Keep plugin tables namespaced with p_<plugin>_.
Do not add plugin-specific columns to core tables.
Migrations run only in the daemon process. The sub-agent runner opens the same
database without migrating, so migrate logs a skip there and changes nothing
(src/store/pluginDb.ts). Namespacing is a convention enforced in review, not
by a runtime check (src/store/pluginDb.ts, file header).
Core's awaitable session/provider paths acquire the writer asynchronously through
the same src/store/writeLock.ts boundary, then execute one uninterrupted
synchronous transaction. Plugin db.transaction keeps its synchronous contract;
an async callback must never suspend inside it. Slow synchronous plugin holds
retain caller attribution in the owning process. Daemon /health.writerLock
contains bounded operation timing aggregates with its pid; runner waits and
holds are measured and logged by that runner. No plugin capability or API version
changes with this core acquisition extension.
Cost and limits: every plugin transaction takes the one writer lock of the shared
database, so keep transactions short. An async callback inside db.transaction
is refused, not awaited (src/store/writeLock.ts, withWriteLock).
Register registerBootReconcile(fn) for idempotent repair on every daemon start.
Register registerService({ name, start, stop }) for host-managed background
work. Services start after boot reconciliation and stop when the daemon shuts
down. registerInterval(name, fn, ms) uses an unref'd timer and is cleared on
stop. Return asynchronous tick work: pending ticks are skipped, and stop drains the current tick.
Failures report once at the start of a series and at most once per ten minutes; success logs recovery.
A delegated runner loads plugin tools but does not start plugin services.
Boot reconciles run sequentially in registration order, each with a 20 second
budget. A throw is logged and raises an admin alert, but it does not stop the boot
or the reconciles after it (src/plugins/serviceRunner.ts, runBootReconciles).
Without a registration, nothing runs at boot for that plugin. The sub-agent runner never runs reconciles. The sandbox plugin
registers one as a real example (plugins/sandbox/index.mjs).
Services start in registration order after the reconciles. A start failure is
logged and raises an alert; the remaining services still start. On shutdown,
interval timers stop first, then services stop newest first, each with a 30 second
grace period (src/plugins/serviceRunner.ts, startAll and shutdownAll).
registerService refuses a registration with no name or with a start or stop
that is not a function (src/plugins/registry.ts, registerService).
A throwing or rejected interval tick is logged, and the interval keeps running.
Shutdown waits for ticks already in flight (src/plugins/registry.ts,
registerInterval). A tick costs host time every ms milliseconds, not model
calls, unless the tick starts a turn. The sandbox plugin registers a 10 second
lease-reconcile interval as a real example (plugins/sandbox/index.mjs).
Register registerUserRemoved(fn) for every account-owned row, file, secret,
or schedule. The account row still exists while the callback runs. Disabled
plugins cannot receive the callback, so boot reconciliation must also make
cleanup safe after re-enablement. Core verifies account process teardown and removes managed sessions
before these callbacks run; a failing callback refuses account deletion so it can be retried.
Terminal removes the account's remembered working directories and in-flight bookkeeping. Subagent
removes retained jobs, workflow state and interrupted recovery journals by their exact account principal;
late child events cannot recreate removed journals or publish completions. Other accounts remain untouched.
No extra cleanup service or per-account storage copy is involved.
Registering this handler is mandatory for any plugin that stores per-user state.
Nothing else reaps that state: the account id is never reused, so leftover
files or rows stay on the instance forever (src/plugins/api.ts,
registerUserRemoved). Core runs the handlers in registration order. A handler that
throws is logged, the rest still run, and the deletion request returns an error
while the account stays (src/api/routes/users.ts, account delete handler). The
admin can repeat the request. The bundled changelog, MCP, sandbox,
subagent and terminal plugins each register a handler as real examples
(plugins/*/index.mjs).
Register registerProjectRemoved(fn) for Project-owned state. Make cleanup
idempotent. The handler runs before the Project row is removed, so the id is
still known. A throwing handler is logged and Project deletion continues
(src/projects/projectService.ts, Project delete path).