OpenCode 2.0.10 replaced the lifecycle events the generated plugin listened
for (session.idle and friends) with session.execution.{succeeded,failed,
interrupted}, and it runs one long-lived service behind every CLI: closing a
terminal is not a session end, so sessions captured through the old binding
rarely produced a summary page or a baton. The plugin also re-ran two `git`
processes per event, lost startup context after the first model request,
dropped content-only tool results, and shared queue and spool state across
the per-location instances OpenCode 2 loads.
Plugin (install_hooks.rs, render_shared.rs, the node host fixture):
- binds session.execution.*, session.text.ended, session.moved,
session.deleted, session.compaction.started and the context/prompt/tool
hooks; every name was checked against the OpenCode 2.0.14 binary;
- claims startup context once per root session and re-injects it on every
model request; child sessions never claim it or publish a baton;
- keeps queue, spool and cleanup state per location instance; spool names
can no longer collide within a millisecond, and a torn-down host cancels
what is still in flight only after its final session-ends had the drain
budget (all generated TypeScript integrations share this runtime now);
- opt-in assistant capture (--capture-assistant) hands the last completed
text to the native hook, which sanitizes and caps it before the spool or
the wire, and falls back to the plain stop hook if that binary cannot run.
Server (router.rs, ops.rs, reader.rs, writer.rs):
- a completed root turn is a turn checkpoint: sessions/<id>.md and the
automatic baton are refreshed deterministically (no LLM) in the session
row's own scope, keeping one open baton per live session, refreshed in
place and audited; a checkpoint that lost the race with the session's end
touches nothing;
- an explicit, keyed session.moved rebinds the live session row to its new
directory on first delivery (compare-and-set on the cwd it left), so the
session's end and checkpoints follow it;
- a SessionEnd whose resolved scope drifted under the same cwd (a
.ai-memory.toml appeared mid-session) ends the session instead of
stranding it open; scope-drifted ordinary events are recorded as upstream
records any other drifted event;
- the latest captured assistant excerpt rides in the automatic baton; it is
not rendered into the git-tracked session page.
Docs: install.md, support-matrix.md, auto-scope.md, SECURITY.md and
DATA_HANDLING.md describe the checkpoint, routing and capture behavior.
Verified: cargo fmt --all -- --check; cargo clippy --workspace --all-targets
-- -D warnings; cargo test --workspace --all-targets (3569 passed, on release/2.5); the node
host fixture runs under cargo test (node >= 22.6) and fails if unload aborts
deliveries before its session-ends. Live on Windows 11 with OpenCode 2.0.14:
after a 64 -> 66 store migration, the running OpenCode service loaded the
regenerated plugin and one real turn through it (a Code Mode call to memory_status)
recorded its prompt, tool events and stop, logged "turn checkpoint written;
native session remains open", left the session open, wrote
sessions/<id>.md and exactly one open baton carrying the captured
assistant excerpt, which the session page does not contain.
16 KiB
[auto_scope] isolation modes
ai-memory serve publishes a process-shared "currently active project"
pointer that MCP read tools consult when the caller omits workspace /
project. The pointer is fed by foreground lifecycle hooks: session start,
user prompt, and pre-tool events that resolve a cwd to a real project update
the pointer so read tools answer for the project the agent is actually in, not
the server's static --project default. Completion and shutdown events still
land in their resolved project, but do not advance shared fallback slots: a
delayed post-tool, stop, or session-end tail from an older process must not
redirect a newer session's unscoped reads.
Since v1.39 that pointer is keyed by the caller's own coordinate by
default (per_actor), so two harnesses in one project — or two operators on
one server — cannot overwrite each other's notion of "current project".
Before v1.39 the default was a single process-wide slot (single). That is
right for exactly one harness at a time and collapses as soon as there are
two: a hook firing from ~/repo-A overwrites the slot that a concurrent
memory_query (with no explicit project) in ~/repo-B was about to read.
Because unscoped writes resolve through the same pointer, that could also
land a memory_write_page in the wrong project.
The [auto_scope] config block still selects the mode explicitly, including
single for anyone who wants the historical slot back.
Modes
mode |
Key | When to use |
|---|---|---|
single |
(none — global slot) | The pre-v1.39 behaviour. One operator, one harness at a time; last write wins. Opt in only if you want it. |
per_session |
session_id |
Session-aware clients/bridges that forward the hook session id on every MCP request. |
per_actor |
(qualified identity, session_id), with an identity-only no-session slot |
Default. Isolates parallel harnesses and separate operators. Falls back to the shared slot for a caller with no coordinate, and for an install where nothing has ever been keyed (no lifecycle hooks); fails closed on a genuine session mismatch. |
Both opt-in modes still publish foreground activity to the single slot in
parallel, so a caller with no actor identity (anonymous probe, legacy code
path) sees the most recently active project rather than an empty pointer.
Non-foreground events refresh only their exact keyed entry when one exists.
That preserves legacy behavior without letting a delayed tail take over, but
it is not per-session isolation; use explicit workspace + project
arguments when a client cannot send actor identity and concurrent runs matter.
Explicit scope arguments fail closed. A project argument is resolved
inside the active workspace first, then inside the server's default
workspace; if neither contains that project, the tool returns an error
instead of falling back to the active/default project. A workspace
argument must be paired with project, and read/admin maintenance
paths use find-only lookups so typos do not create empty scopes.
Implementation contract
Scope resolution is centralized in ai_memory_store::ScopeResolver and its
explicit helpers:
lookup_existing_scopefor read, search, maintenance, retention, embed, and destructive paths. It never creates workspaces or projects.create_explicit_scopefor explicit write/create paths only.resolve_many_existing_scopesfor multi-project search scopes, with deduplication and max-scope validation.ScopeResolver::resolve_read_argsandresolve_write_argsfor MCP tools that also need actor-scoped active-project fallback.
New MCP, admin, or web API routes should use those helpers instead of
hand-rolling find_workspace / find_project chains. PRs that touch scope
resolution should include table-driven tests for partial scope rejection,
missing explicit scope, active-project precedence, and cross-workspace
isolation.
Configuration
[auto_scope]
mode = "per_actor" # "per_actor" (default since v1.39) | "per_session" | "single"
session_ttl_secs = 3600 # TTL for per-key entries (default 1 h)
max_entries = 4096 # hard cap; oldest insertions evicted first
Environment-variable overrides follow the standard
AI_MEMORY_<SECTION>__<KEY> shape:
AI_MEMORY_AUTO_SCOPE__MODE=per_actor
AI_MEMORY_AUTO_SCOPE__SESSION_TTL_SECS=7200
AI_MEMORY_AUTO_SCOPE__MAX_ENTRIES=8192
Where the actor identity comes from
| Source | Populates |
|---|---|
Hook payload (/hook?event=…&agent=…) |
session_id, agent |
Auth middleware (rung 1 root with root_username) |
user ← root_username |
| Auth middleware (rung 1b trusted proxy) | username or OIDC (issuer, subject) pair |
| Auth middleware (rung 2 DB user) | user ← users.username |
MCP request header X-Memory-Actor-Session-Id |
session_id for tool calls |
MCP request _meta["ai.opencode/sessionID"] (OpenCode 2) |
native lifecycle session_id for tool calls, before transport-ID fallback |
MCP request header Mcp-Session-Id |
fallback session_id for tool calls |
| Anonymous / no token | empty actor → single slot |
X-Memory-Actor-Session-Id means the agent-run session id from the
lifecycle-hook payload. It is not an OIDC/Keycloak login session: the
provider's JWT sid claim identifies an IdP browser/device session and
must not be used as ai-memory's actor session key.
per_session reads from session_id; per_actor reads from both the
qualified identity and session_id. In per_actor, a request that has identity but
no session id can use that user's latest no-session slot instead of the
process-wide single slot. A request that does carry a session id must
match a hook-published keyed entry; if it does not, ai-memory falls back
to the server's baked default rather than another session's latest
project.
The composite (identity, session_id) key namespaces only these active-project
pointers. The durable SessionId stored for hook observations remains global:
if another owner reuses an already-owned id, ai-memory drops that hook before it
can append observations or publish a pointer for the foreign actor.
Owner and agent are what identify a session; scope is not. The same operator's
session legitimately produces events in another project when its cwd moves, so
a differing (workspace, project) is recorded rather than rejected — see
[routing] mid_session
for how those events are attributed. The one exception is a terminal event: a
SessionEnd naming a different scope than its session is not that session's
end, so it is dropped rather than ending someone else's session — unless it
comes from the session's own cwd. Then the scope drifted under the same
directory (a .ai-memory.toml appeared mid-session), and the end closes the
session in the scope it was recorded in.
The OpenCode 2 adapter reports a native session.moved explicitly, with the
directory the session left and a stable ingest key. Its first delivery rebinds
the live session row to the new scope and cwd, provided the stored cwd still
matches the one it left, so the session's later end and checkpoints land where
it now runs; earlier observations keep their scope. A redelivered move never
rebinds again.
Client requirements
Lifecycle hooks already include the agent-run session id in their payloads. MCP tool calls are separate HTTP requests, and most built-in MCP client config files can only declare static URL/auth headers. Static configs cannot inject the current agent-run session id into every tool call.
OpenCode 2 (2.0.4+) sends the native session id on every tool call as
CallToolRequest.params._meta["ai.opencode/sessionID"], including Code Mode
and subagent calls; initialize carries none. Its Mcp-Session-Id is shared
by every session in a directory, so the metadata key is what tells concurrent
sessions apart. It is a routing coordinate, not authentication, and takes
precedence over the transport header. With the generated OpenCode 2 lifecycle
adapter, no separate bridge is needed.
Claude Code can opt into ai-memory's session-aware stdio bridge:
ai-memory install-mcp --client claude-code --session-aware --apply
The bridge reads the CLAUDE_CODE_SESSION_ID that Claude supplies to its stdio
MCP subprocess and forwards it as X-Memory-Actor-Session-Id while preserving
the configured remote endpoint and bearer token. Existing Claude Code installs
stay on the static HTTP transport unless this flag is used.
Claude Code's stdio MCP subprocess keeps the id it received at startup across
/clear, even though subsequent hooks receive the new id. On
--continue/--resume without an explicit id, Claude may also give the MCP
subprocess the startup id rather than the resumed id. Restart Claude Code after
/clear when exact session-key continuity matters, and prefer
--resume <session-id> for explicit resumes. The bridge deliberately fails
when no CLAUDE_CODE_SESSION_ID is available instead of silently degrading to
the shared single slot.
Use per_session only when your client or bridge can send the same
opaque session id from the hook payload on each MCP request as
X-Memory-Actor-Session-Id, native MCP metadata as above, or Mcp-Session-Id. Otherwise
requests that carry a different MCP session id fail closed to the baked
default, while requests with no usable actor identity still degrade to
the legacy single slot.
OIDC/Keycloak authentication can identify the human user, client, and
agent, but it does not automatically identify the current coding-agent
session. If a gateway validates a Keycloak JWT, it should propagate
X-Memory-Actor-User or the Issuer + Sub pair, plus optional Client /
Agent; it should only emit
X-Memory-Actor-Session-Id when a real agent session id has been
forwarded by a session-aware bridge.
For built-in installs that use static MCP config, prefer:
singlefor one operator / one active project at a time.per_actorwith multi-user bearer auth when several humans share one server. It isolates users via authenticated actor keys; same-user concurrent sessions still need explicitworkspace+projectargs or a session-aware bridge when the MCP client cannot forward the hook session id.per_sessionplus Claude Code'sinstall-mcp --session-awarebridge when one operator runs concurrent Claude Code sessions in different projects.
Pairing with multi-user mode
per_actor is most useful when the engine is in multi-user mode (see
docs/users.md) — each native API credential resolves through
api_credentials to its owning users row, so the auth middleware tags
every request with the right user. With [auto_scope] mode = "per_actor", two
authenticated users running concurrent agent sessions through the same
engine no longer overwrite each other's "current project" pointer for
MCP calls; if their clients also forward session ids, concurrent
sessions by the same user are isolated too.
Single-user installs can use per_session alone (no token_pepper,
no users row) only when the client/bridge forwards the session id on
MCP calls. Claude Code has the opt-in bridge above; with other stock static MCP
configs, use explicit workspace + project arguments for concurrent windows.
Surviving a restart
The pointer is process memory: restarting the daemon — which is exactly what the packages tell you to do after an upgrade — empties it, including for sessions that are still open. Until the next foreground hook event lands, every keyed read misses, and an unscoped read used to resolve through the baked default scope and report an empty project through the success path. Nothing in the answer or the log said "scope unresolved", so an agent asking "what do we have here?" was told "nothing" while thousands of observations sat in the DB.
serve therefore seeds a read-side fallback slot at startup from the most
recently active project already recorded in SQLite, so a keyed miss right after
a restart degrades to real data instead of an empty default. Four bounds keep
that narrow:
- Reads only. The seed is a reconstruction, not an observed publish, so it lives in its own slot. An unscoped write still resolves as if nothing were published — it fails closed on a genuine mismatch and otherwise uses the configured default, exactly as before. Nothing in a restart should retarget where a page lands.
- Keyed entries are never reconstructed, so a keyed hit still wins and per-actor isolation is unchanged.
- Bounded by the same TTL as a per-key entry (
session_ttl_secs, default one hour). Activity older than that would have aged out of a live pointer, so a server that was down overnight starts with no fallback at all. - The first foreground hook event supersedes it for every caller, exactly
as it overwrites any other shared-slot value. Admin invalidations
(
purge-project,move-project, workspace removal) reach the seed too, so a scope that no longer exists cannot keep answering from it.
Reads deliberately do not fail closed on an unresolved pointer. A keyed
miss is also the normal shape of the pre-publish window (hooks are
fire-and-forget, so a session's first read can arrive before its SessionStart
write lands) and of TTL/cap eviction on a busy server. Erroring there would
turn both into spurious failures; seeding a real fallback fixes the silent
answer without touching them.
Memory footprint
Per-key entries are tiny: two Uuid-sized ids + an Instant. With
the default max_entries = 4096, the map worst-cases at ~tens of KB
even on a corporate engine fielding hundreds of concurrent sessions.
The TTL ensures stale entries (closed Claude Code windows, dropped
hook clients) age out within an hour; the cap drops the oldest
insertions first if the TTL window is somehow exceeded.
Upgrading to v1.39
The default changed from single to per_actor. For most installs nothing
observable changes:
| your setup | before | after |
|---|---|---|
| one harness, hooks installed | shared slot | keyed slot for that session — same project |
| static MCP client (bearer, no session id) | shared slot | that operator's identity-only slot — same project |
| client that forwards no identity at all | shared slot | shared slot, unchanged |
| MCP-only install, no lifecycle hooks | shared slot | shared slot — nothing is ever keyed, so the fallback applies |
| two harnesses / two operators | one slot, last write wins | one slot each |
The single behavioural change is deliberate: a client that forwards a session id which does not match any published hook activity no longer inherits the shared slot. Answering it from there is what routed a request into whichever project published last — on a shared server, potentially another operator's. Such a caller now resolves to the server's configured default instead.
To restore the old behaviour exactly:
[auto_scope]
mode = "single"
or AI_MEMORY_AUTO_SCOPE__MODE=single. The effective mode is logged at
startup (active-project isolation mode mode=…), which is the quickest way
to confirm what a running server is using.