Problem
Moving a Session from one Maka installation to another — a release build to a dev build, one machine to another — has no path through the app.
maka session-export and maka session-import landed in #5113 and #5139, but they only work with Maka closed. A Runtime Host takes an exclusive lock on its Storage Root at startup and holds it for its lifetime, and both commands need that authority to read or write the context-offload store. Run either while the app is open and it refuses:
Context import requires an offline Storage Root; stop the Runtime Host first
So the one surface where a user would reach for this — a menu item in the app they already have open — is the one place it cannot run.
Desired outcome
Export a Session (and the subagent subtree under it) to a .maka-session file from the sidebar, and import one from Settings, without closing the app.
What a Session is on disk
Not a row. A Session spans four things in a workspace, and all four have to travel:
workspace/
runtime.sqlite Session rows, events, tool calls
context-offload.sqlite index of offloaded payloads
context-offload-values/ the payload bytes, at sha256/<prefix>/<hash>
artifacts/<sessionId>/ files the Session produced
exportSessionBundleState() / importSessionBundleState() already handle all four. What is missing is a way to call them from inside the process that holds the Storage Root.
Proposed flow
The Runtime Host is the single process holding the Storage Root, so it is the one that can do this. It does not need to take the lock again — it already has it, and can lend it.
Export
- Walk the subagent subtree under the named Session.
runSessionQuiescentMutation([root, ...subtree]) — refuses while any of them has an active execution claim, and serialises against other Session mutations, so no Turn starts mid-export.
runWithStorageRootLease(owner.lease, 'interactive', 'write', ...).
exportSessionBundleState() under that lease: back up the operational database, check quiescence on the copy, filter out everything the subtree does not own, snapshot context, copy artifacts.
- Pack to
.maka-session.
Steps 2 and 4 are not redundant: the kernel fence stops a new Turn from starting, the on-copy check catches state that was already mid-flight.
Import
inspect() the bundle's manifest, then hydrate() into a private staging directory.
runWithStorageRootLease(owner.lease, ...).
importSessionBundleState() under that lease: artifacts and context land first, Session rows last, so a failure before the final step publishes nothing and leaves the ids free for a retry.
No Session fence on the import side — the Sessions being imported do not exist locally yet, so there is nothing to fence by id.
What had to be verified first
Two questions decided whether the Host can do this at all.
Can the Host use the lock it already holds? Not through the current entry point. The Storage Root lock is an election (tryLock, fail fast), not a mutex, and it is not reentrant — a second exclusive lock on the same file from the same process is refused:
first exclusive: true
second exclusive (same process, different fd): false
So withOfflineContextSnapshot needs a lease-bound entry alongside the electing one. withLeaseBoundArtifactWriterLock is the same pattern, already in the same package.
Does an import racing the Host's own writes corrupt anything? For the databases, no:
- Only one process can hold the interactive write authority (verified across processes: the second is refused), and the production store can only be opened through that lease.
- Neither side holds a SQLite write transaction across an
await. put() publishes its managed file before entering a synchronous #writeTransaction, and the import's merge runs BEGIN IMMEDIATE → COMMIT with no await in between.
So the usage recompute always sees a settled table.
Correction (2026-09-11, from review on #5186): that is not the whole question, and the conclusion drawn from it was wrong. The Store's operations read database state, await, and only then act on files — collection decides a payload is unreferenced, awaits, and unlinks it. An import that commits a reference inside that await leaves the reference pointing at a file about to be removed, and collection's re-check cannot see it because the check and the unlink straddle the await. A root-scoped fence is needed, shared by the import, the Store's publication, and collection.
For the payload files, yes — and this is a present-day defect, not a future one. The store publishes a managed blob atomically (staging file → fsync → link), while the import uses copyFile, which is observable at its final path in partial states. Copying a 64 MB file and stat-ing the destination every millisecond:
copyFile partial sizes observed at the final path: 38 [2580480, 4177920, 5259264, ...]
link partial sizes observed at the final path: 0
Payloads are content-addressed, so a bundle and its target routinely hold the same hash at the same path. Today that is harmless because the import only runs offline. It is not harmless once the Host runs it — and it already bites offline in one case: copyContextValueTree swallows EEXIST, so an import that crashes mid-copy leaves a truncated payload that the retry silently accepts as already present.
Proposed split
PR 1 — storage
copyContextValueTree: publish through a staging file and link, matching SqliteContextOffloadStore#publishManagedFile; on EEXIST, verify the existing bytes instead of skipping. Fixes the truncated-payload retry above.
withOfflineContextSnapshot: accept a caller-supplied lease. Without one it elects an owner exactly as today, so the CLI path is unchanged.
PR 2 — Runtime Host and desktop
- Two Host operations, shaped after
protocol/external-session.ts, running under the Host's lease, with the Session fence on the export side.
- Desktop IPC and file dialogs, a sidebar entry for export, and a Settings entry for import.
Credentials stay out: a bundle names its connection by slug and model and carries no key, and the importing side resolves the slug against its own catalog.
I plan to implement this.
Problem
Moving a Session from one Maka installation to another — a release build to a dev build, one machine to another — has no path through the app.
maka session-exportandmaka session-importlanded in #5113 and #5139, but they only work with Maka closed. A Runtime Host takes an exclusive lock on its Storage Root at startup and holds it for its lifetime, and both commands need that authority to read or write the context-offload store. Run either while the app is open and it refuses:So the one surface where a user would reach for this — a menu item in the app they already have open — is the one place it cannot run.
Desired outcome
Export a Session (and the subagent subtree under it) to a
.maka-sessionfile from the sidebar, and import one from Settings, without closing the app.What a Session is on disk
Not a row. A Session spans four things in a workspace, and all four have to travel:
exportSessionBundleState()/importSessionBundleState()already handle all four. What is missing is a way to call them from inside the process that holds the Storage Root.Proposed flow
The Runtime Host is the single process holding the Storage Root, so it is the one that can do this. It does not need to take the lock again — it already has it, and can lend it.
Export
runSessionQuiescentMutation([root, ...subtree])— refuses while any of them has an active execution claim, and serialises against other Session mutations, so no Turn starts mid-export.runWithStorageRootLease(owner.lease, 'interactive', 'write', ...).exportSessionBundleState()under that lease: back up the operational database, check quiescence on the copy, filter out everything the subtree does not own, snapshot context, copy artifacts..maka-session.Steps 2 and 4 are not redundant: the kernel fence stops a new Turn from starting, the on-copy check catches state that was already mid-flight.
Import
inspect()the bundle's manifest, thenhydrate()into a private staging directory.runWithStorageRootLease(owner.lease, ...).importSessionBundleState()under that lease: artifacts and context land first, Session rows last, so a failure before the final step publishes nothing and leaves the ids free for a retry.No Session fence on the import side — the Sessions being imported do not exist locally yet, so there is nothing to fence by id.
What had to be verified first
Two questions decided whether the Host can do this at all.
Can the Host use the lock it already holds? Not through the current entry point. The Storage Root lock is an election (
tryLock, fail fast), not a mutex, and it is not reentrant — a second exclusive lock on the same file from the same process is refused:So
withOfflineContextSnapshotneeds a lease-bound entry alongside the electing one.withLeaseBoundArtifactWriterLockis the same pattern, already in the same package.Does an import racing the Host's own writes corrupt anything? For the databases, no:
await.put()publishes its managed file before entering a synchronous#writeTransaction, and the import's merge runsBEGIN IMMEDIATE→COMMITwith noawaitin between.So the usage recompute always sees a settled table.
Correction (2026-09-11, from review on #5186): that is not the whole question, and the conclusion drawn from it was wrong. The Store's operations read database state,
await, and only then act on files — collection decides a payload is unreferenced, awaits, and unlinks it. An import that commits a reference inside that await leaves the reference pointing at a file about to be removed, and collection's re-check cannot see it because the check and the unlink straddle the await. A root-scoped fence is needed, shared by the import, the Store's publication, and collection.For the payload files, yes — and this is a present-day defect, not a future one. The store publishes a managed blob atomically (staging file →
fsync→link), while the import usescopyFile, which is observable at its final path in partial states. Copying a 64 MB file and stat-ing the destination every millisecond:Payloads are content-addressed, so a bundle and its target routinely hold the same hash at the same path. Today that is harmless because the import only runs offline. It is not harmless once the Host runs it — and it already bites offline in one case:
copyContextValueTreeswallowsEEXIST, so an import that crashes mid-copy leaves a truncated payload that the retry silently accepts as already present.Proposed split
PR 1 — storage
copyContextValueTree: publish through a staging file andlink, matchingSqliteContextOffloadStore#publishManagedFile; onEEXIST, verify the existing bytes instead of skipping. Fixes the truncated-payload retry above.withOfflineContextSnapshot: accept a caller-supplied lease. Without one it elects an owner exactly as today, so the CLI path is unchanged.PR 2 — Runtime Host and desktop
protocol/external-session.ts, running under the Host's lease, with the Session fence on the export side.Credentials stay out: a bundle names its connection by slug and model and carries no key, and the importing side resolves the slug against its own catalog.
I plan to implement this.