Repository navigation
feat(storage): import opencode sessions through ExternalSessionAdapter #3403
Description
Problem
ExternalSessionAdapter (packages/core/src/external-session.ts) is source-agnostic and the registry in packages/storage/src/external-session-adapters.ts currently holds exactly one adapter:
return new ExternalSessionAdapterRegistry([new CodexSessionAdapter(options.codex)]);A user with history in opencode has no way to bring it into Maka. Part of #2499; sibling of #3142, which covers Claude Code.
What I verified about opencode's storage
opencode 1.18.15, read out of the installed binary rather than from a running install — I have the CLI but no local sessions, so the shapes below are read from its own code paths and still need confirming against real transcripts before an adapter is written.
Three files per conversation, not one transcript. Unlike Codex rollouts and Claude Code transcripts, opencode splits a session across three record kinds:
storage/session/info/<sessionID>.jsonstorage/session/message/<sessionID>/<messageID>.jsonstorage/session/part/<sessionID>/<messageID>/<partID>.json
A message carries parts, and part.type is a discriminated union: text, reasoning, tool, file, agent, patch, snapshot, step-start, step-finish.
There are two layouts, and the binary carries a migration between them. The paths above are the pre-migration shape, held under a per-project directory with global as a reserved name. The migration re-roots them as session/<project>/<id>.json, message/<sessionID>/<id>.json, part/<messageID>/<id>.json. An adapter has to read whichever the user is on, the same way the Codex adapter falls back from the state DB to a filesystem walk, and the same way #3142 needs fixtures for both the Claude Code 2.0 and 2.1 shapes.
The question worth answering before writing code
#3142's load-bearing constraint is that a Claude Code transcript never records that a Turn ended — only that another began — which is why importing it forces a choice between forging a recorded terminal and having every Run repaired to failed.
opencode may not have that problem. It writes explicit step-start / step-finish parts, and text parts carry time.end. If those are durably persisted rather than stream-only bookkeeping, an opencode adapter can emit turn_state from facts the record actually carries, and would need none of the Ledger relaxation #3142 depends on.
That is a hypothesis, not a finding. It should be settled against real part/**.json files first, because it decides whether this issue is independent of #3142 or blocked behind it.
Suggested scope
packages/storage/src/opencode-session-adapter.ts—detect/listSessions/readSession, emittingStoredMessage[]directly. Same hardening the Codex adapter already establishes: realpath containment under the resolved data root, a byte cap on reads, a session-id charset assertion, and rejection of corrupt records rather than silent truncation.- One entry added to
createExternalSessionAdapterRegistry, plus anopencode?field onExternalSessionAdapterOptions. - Part-to-
StoredMessagemapping, including a decision on the parts that are not conversation (snapshot,step-start,step-finish) — dropped, or kept assystem_note. - Fixtures for both layouts.
Not in scope
- Any change to the importer, the Runtime Host operations, the storage schema, the IPC surface, or the Settings page. Those are already adapter-id-generic;
ImportTasksSettingsPagerenders a source picker as soon as a second adapter reports itself. - The digest-based handoff path in
foreign-session.ts/foreign-session-store.ts. That is a different feature with its own hardcoded two-source dispatch; adding opencode there is a separate question.
One shared piece with #3142
apps/desktop/src/renderer/locales/external-session-import-copy.ts is the only genuinely Codex-hardcoded product surface left: codex: string is a named field, and sourceLabel(id, copy.codex) is called with it. A second adapter — whichever of these two lands first — has to turn that into a map keyed by adapter id. Worth doing once rather than twice.
Activity
Joob1n commentedon Aug 21, 2026
Claiming this one.
First step is the part that decides the shape of the rest: confirming against real
part/**.jsonfiles whetherstep-finishandtime.endare durably persisted. Ifthey are, this adapter emits
turn_statefrom recorded facts and lands independently.If they are not, it inherits the same Ledger constraint as #3142 and should queue
behind it rather than duplicate the argument.
I will report that finding here before writing the adapter, so the sequencing is
visible rather than decided inside a PR.
I am also working #3142 (Claude Code). If a maintainer would rather these two go to
different hands, I would take the format work here and leave that one — the two
adapters share only the copy-string generalization, which is a few lines either way.
fix: address issue apache#3403
Joob1n commentedon Aug 24, 2026
Reporting the finding I promised before writing the adapter. Two things changed relative to what the issue body records.
Storage is SQLite now, not a JSON tree
The body documents
storage/session/info/<id>.json,.../message/<sessionID>/<messageID>.jsonand.../part/<sessionID>/<messageID>/<partID>.json, plus a migration between two directory layouts. On opencode1.18.21neither layout exists — there are no session JSON files at all. Everything is in one SQLite database:Relevant tables:
session,message,part,session_message,session_input,project,workspace,event,todo,permission.The CLI exposes it directly, which makes fixture capture straightforward:
opencode db path,opencode db "<SQL>",opencode session list,opencode export <sessionID>.This is good news for the adapter. It moves from walking a three-level directory tree while handling a migration, to reading one database — closer in shape to
CodexSessionAdapterthan the body assumed.Shapes:
message.dataandpart.dataare opaque JSON, so the schema alone does not answer the question this issue was blocked on. The rest of this comment is read from real sessions.step-finishis persisted, and turn state is derivable — but the field istime.completed, nottime.endCaptured 2 sessions / 18 messages / 46 parts, including one deliberately interrupted run.
part.typevalues actually observed:step-starttextstep-finishtoolreasoningSo
step-finishis durably persisted, carryingreason,tokensandcost:{ "type": "step-finish", "reason": "stop", "tokens": { "total": 15142, "input": 15137, "output": 5, "reasoning": 0, "cache": { "write": 0, "read": 0 } }, "cost": 0 }time.enddoes not exist. The assistant message carriestime.completed, and it is set on every assistant message, including the aborted one:time.completedfinisherror.namestoportool-callsMessageAbortedErrorThat is enough to emit
turn_statefrom recorded facts:finish: "stop"closes a turn;finish: "tool-calls"is an intermediate step;finishwitherror.nameis an aborted turn.So this adapter does not inherit the Ledger constraint from #3142 and does not need to queue behind it. It can land independently, which is what I will do.
Two gaps I could not close from captured data
toolpart observed hadstate.status: "completed"(6 of 6). The other statuses in the union are unexercised here, so the adapter will treat any non-completed status defensively rather than guess its shape.Both are handled by failing closed rather than inventing a mapping.
Note on the version
The body was written against
1.18.15, read out of the binary. This report is1.18.21, read out of real sessions. Anyone picking this up should re-checkopencode db pathfirst rather than trusting either version's layout.feat(storage): import opencode sessions through ExternalSessionAdapter (