Skip to content

feat(storage): import opencode sessions through ExternalSessionAdapter #3403

Closed

Description

@Joob1n

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>.json
  • storage/session/message/<sessionID>/<messageID>.json
  • storage/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, emitting StoredMessage[] 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 an opencode? field on ExternalSessionAdapterOptions.
  • Part-to-StoredMessage mapping, including a decision on the parts that are not conversation (snapshot, step-start, step-finish) — dropped, or kept as system_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; ImportTasksSettingsPage renders 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

  1. Joob1n commented on Aug 21, 2026

    @Joob1n
    MemberAuthor

    Claiming this one.

    First step is the part that decides the shape of the rest: confirming against real
    part/**.json files whether step-finish and time.end are durably persisted. If
    they are, this adapter emits turn_state from 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.

  2. added a commit that references this issue on Aug 23, 2026
  3. Joob1n commented on Aug 24, 2026

    @Joob1n
    MemberAuthor

    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>.json and .../part/<sessionID>/<messageID>/<partID>.json, plus a migration between two directory layouts. On opencode 1.18.21 neither layout exists — there are no session JSON files at all. Everything is in one SQLite database:

    $ opencode db path
    ~/.local/share/opencode/opencode.db
    $ find ~/.local/share/opencode -name '*.json' | wc -l
    0
    

    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 CodexSessionAdapter than the body assumed.

    Shapes:

    session(id, project_id, workspace_id, parent_id, slug, directory, path, title,
            version, share_url, summary_*, metadata, cost, tokens_*, permission,
            agent, model, time_created, time_updated, time_compacting, time_archived)
    message(id, session_id, time_created, time_updated, data)
    part   (id, message_id, session_id, time_created, time_updated, data)

    message.data and part.data are 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-finish is persisted, and turn state is derivable — but the field is time.completed, not time.end

    Captured 2 sessions / 18 messages / 46 parts, including one deliberately interrupted run.

    part.type values actually observed:

    type count
    step-start 12
    text 11
    step-finish 11
    tool 6
    reasoning 6

    So step-finish is durably persisted, carrying reason, tokens and cost:

    { "type": "step-finish", "reason": "stop",
      "tokens": { "total": 15142, "input": 15137, "output": 5, "reasoning": 0,
                  "cache": { "write": 0, "read": 0 } },
      "cost": 0 }

    time.end does not exist. The assistant message carries time.completed, and it is set on every assistant message, including the aborted one:

    message time.completed finish error.name
    11 normal assistant messages present stop or tool-calls —
    the interrupted one present absent MessageAbortedError

    That is enough to emit turn_state from recorded facts:

    • finish: "stop" closes a turn;
    • finish: "tool-calls" is an intermediate step;
    • absent finish with error.name is 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

    • Every tool part observed had state.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.
    • The aborted assistant message has no parts at all — it was cut before producing any. A run interrupted mid-tool would be a different shape, and I do not have one.

    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 is 1.18.21, read out of real sessions. Anyone picking this up should re-check opencode db path first rather than trusting either version's layout.

  4. added a commit that references this issue on Aug 24, 2026
    04836d3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requesthelp wantedExtra attention is needed

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions