You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
V2: V1 session history is never imported into session_v2 — old sessions invisible in OpenCode 2 #41217
OpenCode 2 (beta) and OpenCode 1 share the same SQLite data directory
(~/.local/share/opencode/opencode.db), but V2 reads/writes a separate session_v2 / session_message schema family and never imports legacy V1 session / message / part rows. As a result, a user who upgrades to V2
loses visibility of their entire V1 session history — it remains in the DB but
is unreachable from OpenCode 2, with no supported migration path.
Install/channel: V2 installed via npm @opencode-ai/cli@next, running as opencode2
Active plugins: fusion-audit.js, @slkiser/opencode-quota@latest,
@tarquinen/opencode-dcp, opencode-caveman, opencode-memfs, opencode-rtk
Reproduction
Have a V1 install with session history (legacy session / message / part rows).
Install the V2 beta (npm install -g @opencode-ai/cli@next) and run opencode2 in the same project.
Open the session list in V2.
Expected Behavior
V2 either (a) surfaces existing V1 session history, or (b) offers a documented
migration that imports legacy session/message/part data into session_v2
— or the migration guide clearly states that V1 session history is not carried
over and how to access it.
Actual Behavior
V1 sessions never appear in V2. They remain in the legacy tables and are not
copied or converted. The DB migration table (41 applied rows) contains no
import/backfill of legacy sessions — the only import migration is 20260805200742_import_legacy_credentials (credentials, not sessions). session_v2/session_message rows only begin from the first V2 use.
Additional Context
DB schema evidence (read-only, from ~/.local/share/opencode/opencode.db):
Legacy family: session (674 rows), message (12259), part (53858)
If your issue is distinct (specifically about V1 SQLite session/message/part rows not being imported into the V2 session_v2 schema, rather than JSON→SQLite migration), feel free to clarify the difference and this note can be disregarded.
V1 session import is now implemented by #40723 and runs as an application migration tracked under migration.v1-v2 in KV, rather than as a schema migration row. Closing this broad feature report as completed. Current concrete migration failures are tracked separately in #41341 and #41739.
This is a critical blocker for V1→V2 migration. Many of us have long-running sessions with important context, code decisions, and architectural discussions in V1. Without session history, we cannot continue development seamlessly after upgrading.
Could you please:
Provide a migration tool/script to import V1 sessions into V2's session_v2 table
Or at minimum, document a manual workaround (e.g., sqlite3 commands to convert the data)
The current situation forces users to either stay on V1 or lose all historical context, which significantly impacts productivity. Thank you!
very sad. Have some really import sessions from V1 that I really wanted to continue on V2. Please provide a workaround to the earliest. At this point, I am not sure if we can retrieve the sessions after downgrading to V1.
Here is a workaround script that packages the manual steps I used to recover these sessions on my own database (219 of 238 sessions were stuck on global after the V1→V2 migration reported completed).
Timestamps suddenly all say "today" — the move event rewrites time_updated/time_idle/time_viewed on every moved row, so afterwards the entire session list shows the move time instead of the original activity dates. The script restores those fields from a pre-move backup (skipping sessions whose cost/tokens changed, i.e. ones with real activity since the backup).
Usage
python3 fix_v1_global_sessions.py --dry-run # report only, no writes
python3 fix_v1_global_sessions.py # backup → move → restore times → verify
python3 fix_v1_global_sessions.py --restore-times-from <backup-dir># time-only restore,# if you already moved# without a backup
Single file, Python 3 stdlib only, POSIX paths.
Requires the OpenCode V2 server running (it checks GET /api/experimental/migration/v1 and refuses to run unless migration status is completed).
Takes a backup of opencode.db (+ -wal/-shm) before writing anything.
Script
#!/usr/bin/env python3"""Recover V1 sessions stuck on the pseudo-project 'global' after a V1→V2 migration.Context: after upgrading, sessions V1 stored under project_id='global' (directoriesthat are not git repos) stay attributed to 'global' in session_v2. V2 scopes thesession list per project, so those sessions never show up. Re-attributing them viaPOST /api/session/{id}/move (the workaround from #51176) works, but the move eventstamps time_updated/time_idle/time_viewed on every row, making the whole list show"today". This tool does both steps safely: 1. backs up opencode.db (db + -wal + -shm) 2. re-attributes every global session through the official move API 3. restores the time fields from the pre-move backup for rows the move touched (skips rows whose cost/tokens changed = sessions with real activity) 4. verifies and prints a summaryModes: python3 fix_v1_global_sessions.py full fix (backup first) python3 fix_v1_global_sessions.py --dry-run report only, no writes python3 fix_v1_global_sessions.py --restore-times-from <backup-dir> time-only restore for moves that already happenedRequires: the OpenCode V2 server running (for status check + move API).Standard library only. POSIX paths (adjust for Windows)."""importargparseimportbase64importjsonimportosimportshutilimportsqlite3importsysimporttimeimporturllib.errorimporturllib.requestfromconcurrent.futuresimportThreadPoolExecutorfrompathlibimportPathTIME_FIELDS= ("time_updated", "time_idle", "time_viewed", "idle_outcome")
ACTIVE_FIELDS= ("cost", "tokens_input", "tokens_output")
deffind_db() ->Path:
cands= []
ifos.environ.get("XDG_DATA_HOME"):
cands.append(Path(os.environ["XDG_DATA_HOME"]) /"opencode"/"opencode.db")
cands+= [
Path("~/.local/share/opencode/opencode.db"),
Path("~/Library/Application Support/opencode/opencode.db"),
]
forcincands:
ifc.expanduser().exists():
returnc.expanduser()
sys.exit("opencode.db not found; pass it explicitly with --db")
deffind_service():
forcin (Path("~/.local/state/opencode/service.json"), Path("~/.config/opencode/service.json")):
p=c.expanduser()
ifp.exists():
try:
data=json.loads(p.read_text())
ifdata.get("url") anddata.get("password"):
returndata["url"], data["password"]
except (json.JSONDecodeError, OSError):
passreturnNone, Nonedefapi(url, password, path, payload=None):
req=urllib.request.Request(
url+path,
data=json.dumps(payload).encode() ifpayloadisnotNoneelseNone,
headers={
"Content-Type": "application/json",
"Authorization": "Basic "+base64.b64encode(f"opencode:{password}".encode()).decode(),
},
method="POST"ifpayloadisnotNoneelse"GET",
)
try:
withurllib.request.urlopen(req, timeout=30) asr:
returnr.status, r.read().decode()[:300]
excepturllib.error.HTTPErrorase:
returne.code, e.read().decode()[:300]
exceptOSErrorase:
return0, str(e)
deftake_backup(db: Path) ->Path:
stamp=time.strftime("%Y%m%d-%H%M%S")
out=db.parent/f"opencode.db.backup-{stamp}"out.mkdir(parents=True, exist_ok=True)
forsufin ("", "-wal", "-shm"):
src=Path(str(db) +suf)
ifsrc.exists():
shutil.copy2(src, out/src.name)
print(f"backup: {out}")
returnoutdefconnect(db: Path):
conn=sqlite3.connect(f"file:{db}?mode=ro", uri=True)
conn.row_factory=sqlite3.Rowreturnconndefrestore_times(db: Path, backup_dir: Path) ->int:
bak=backup_dir/"opencode.db"ifnotbak.exists():
sys.exit(f"no opencode.db in {backup_dir}")
conn=sqlite3.connect(db)
conn.execute(f"ATTACH DATABASE 'file:{bak}?mode=ro' AS bak")
where= (
"a.project_id IS NOT b.project_id"# this row was moved" AND (a.time_updated IS NOT b.time_updated"# ...and its time was stamped" OR a.time_idle IS NOT b.time_idle"" OR a.time_viewed IS NOT b.time_viewed)"" AND a.cost = b.cost AND a.tokens_input = b.tokens_input"# no real activity" AND a.tokens_output = b.tokens_output"
)
set_sql=", ".join(
f"{f} = (SELECT b.{f} FROM bak.session_v2 b WHERE b.id = session_v2.id)"forfinTIME_FIELDS
)
ids= [r[0] forrinconn.execute(
f"SELECT a.id FROM session_v2 a JOIN bak.session_v2 b ON a.id = b.id WHERE {where}")]
ifids:
conn.executemany(f"UPDATE session_v2 SET {set_sql} WHERE id = ?", [(i,) foriinids])
conn.commit()
conn.close()
print(f"time fields restored on {len(ids)} session(s)")
returnlen(ids)
defmain() ->int:
ap=argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--db", type=Path, help="path to opencode.db")
ap.add_argument("--dry-run", action="store_true", help="report only, no writes")
ap.add_argument("--restore-times-from", type=Path, metavar="BACKUP_DIR",
help="only restore time fields from a pre-move backup dir")
args=ap.parse_args()
db= (args.dborfind_db()).expanduser()
print(f"db: {db}")
ifargs.restore_times_from:
restore_times(db, args.restore_times_from.expanduser())
return0conn=connect(db)
rows=conn.execute(
"SELECT id, directory FROM session_v2 WHERE project_id='global' ORDER BY id").fetchall()
total=conn.execute("SELECT COUNT(*) FROM session_v2").fetchone()[0]
conn.close()
print(f"sessions: {total} total, {len(rows)} on 'global'")
ifnotrows:
print("nothing to fix")
return0ifargs.dry_run:
forrinrows[:20]:
print(f" would move {r['id']} -> {r['directory']}")
print(f" ... {len(rows)} total"iflen(rows) >20else"")
return0url, password=find_service()
ifnoturl:
sys.exit("server not running (service.json with url+password not found); start OpenCode first")
status, body=api(url, password, "/api/experimental/migration/v1")
ifstatus!=200or'"completed"'notinbody:
sys.exit(f"V1 migration status is not completed ({status}: {body}); refusing to run")
backup=take_backup(db)
started=int(time.time() *1000)
ok, fails=0, []
defmove(r):
st, msg=api(url, password, f"/api/session/{r['id']}/move", {"directory": r["directory"]})
returnst, r, msgwithThreadPoolExecutor(max_workers=4) asex:
forst, r, msginex.map(move, rows):
ifst==204:
ok+=1else:
fails.append((r["id"], st, msg))
print(f"move: {ok}/{len(rows)} accepted (HTTP 204)")
forfinfails[:10]:
print(f" FAILED {f}")
# moves apply asynchronously through the session inbox; wait for it to settleconn=connect(db)
remaining=len(rows)
deadline=time.time() +120whiletime.time() <deadline:
time.sleep(3)
remaining=conn.execute(
"SELECT COUNT(*) FROM session_v2 WHERE project_id='global'").fetchone()[0]
ifremaining==0:
breakdone=int(time.time() *1000)
print(f"attribution settled: {remaining} still on 'global' (waited {done-started}ms)")
restore_times(db, backup)
# verifyconn.close()
conn=connect(db)
still_global=conn.execute(
"SELECT COUNT(*) FROM session_v2 WHERE project_id='global'").fetchone()[0]
print("\nverify:")
print(f" still on 'global': {still_global}")
print(" sessions per project:")
forrinconn.execute(
"SELECT p.worktree, COUNT(*) c FROM session_v2 s JOIN project p ON p.id=s.project_id"" GROUP BY p.worktree ORDER BY c DESC LIMIT 8"):
print(f" {r['c']:>4}{r['worktree']}")
print(" time_updated distribution (top):")
forrinconn.execute(
"SELECT date(time_updated/1000,'unixepoch','localtime') d, COUNT(*) c"" FROM session_v2 GROUP BY d ORDER BY c DESC LIMIT 5"):
print(f" {r['c']:>4}{r['d']}")
conn.close()
print(f"\ndone. backup kept at {backup} — delete it once V2 looks right.")
return1iffailsorremainingelse0if__name__=="__main__":
sys.exit(main())
Results from my run
sessions: 238 total, 219 on 'global'
V1 migration status: completed
move: 219/219 accepted (HTTP 204)
attribution settled: 0 still on 'global'
time fields restored on 219 session(s)
verify:
still on 'global': 0
sessions per project:
227 /Users/name
7 /Users/name/projects/my-project
2 /private/var/folders/.../T/opencode (project row auto-created)
2 /private/tmp
time_updated distribution: back to the original dates (08-14, 10-02, 10-09, ...)
Afterwards, in a freshly started TUI, migrated sessions open normally through both /sessions and the ctrl+o menu. A TUI that has been running since before the fix may still show the stamped "today" times and refuse to switch until it is restarted.
This is only a user-side workaround — happy to see it replaced by the attribution fix in flight (#51935, #54048).
Activity
github-actions commentedon Aug 8, 2026
This issue might be a duplicate of an existing issue. Please check:
~/.local/share/opencodeand did not migrate legacy sessions — similar theme of V1 session history not being imported/migrated when upgrading to a newer storage format.If your issue is distinct (specifically about V1 SQLite
session/message/partrows not being imported into the V2session_v2schema, rather than JSON→SQLite migration), feel free to clarify the difference and this note can be disregarded.kitlangton commentedon Aug 11, 2026
V1 session import is now implemented by #40723 and runs as an application migration tracked under
migration.v1-v2in KV, rather than as a schema migration row. Closing this broad feature report as completed. Current concrete migration failures are tracked separately in #41341 and #41739.627-hub commentedon Oct 14, 2026
This is a critical blocker for V1→V2 migration. Many of us have long-running sessions with important context, code decisions, and architectural discussions in V1. Without session history, we cannot continue development seamlessly after upgrading.
Could you please:
The current situation forces users to either stay on V1 or lose all historical context, which significantly impacts productivity. Thank you!
Afzal50 commentedon Oct 15, 2026
very sad. Have some really import sessions from V1 that I really wanted to continue on V2. Please provide a workaround to the earliest. At this point, I am not sure if we can retrieve the sessions after downgrading to V1.
627-hub commentedon Oct 16, 2026
Here is a workaround script that packages the manual steps I used to recover these sessions on my own database (219 of 238 sessions were stuck on
globalafter the V1→V2 migration reportedcompleted).What it fixes
project_id='global'(directories that are not git repos) stay onglobalinsession_v2. Since/sessionsandsession listare project-scoped, they never show up. Re-attributing them throughPOST /api/session/{sessionID}/move(the workaround documented in session: v1 "global" sessions stay unattributed after migration and are hidden from v2's session list #51176) works.time_updated/time_idle/time_viewedon every moved row, so afterwards the entire session list shows the move time instead of the original activity dates. The script restores those fields from a pre-move backup (skipping sessions whosecost/tokenschanged, i.e. ones with real activity since the backup).Usage
GET /api/experimental/migration/v1and refuses to run unless migration status iscompleted).opencode.db(+-wal/-shm) before writing anything.Script
Results from my run
Afterwards, in a freshly started TUI, migrated sessions open normally through both
/sessionsand thectrl+omenu. A TUI that has been running since before the fix may still show the stamped "today" times and refuse to switch until it is restarted.This is only a user-side workaround — happy to see it replaced by the attribution fix in flight (#51935, #54048).