Repository navigation
Description
Summary
After upgrading from V1.18.31 to V2.0.8, sessions that V1 stored in the global project (sessions created in non-git directories) become invisible in the TUI session picker, because V2 creates a per-directory project and the picker filters to the current project. The history is still in the database, so users perceive all recent conversations as lost.
Environment
- opencode version: 2.0.8 (upgraded from 1.18.31)
- OS: Linux 6.18.33.2-microsoft-standard-WSL2 (WSL2), x86_64
- Terminal: xterm-256color
- Shell: /bin/bash
- Install/channel: latest (curl installer replaced the V1 binary)
- Active plugins:
@wallbreakerno4/opencode-commandcode
Reproduction
- On V1.18.31, run
opencodein a non-git directory (e.g./home/wjf) and create several sessions. They are stored withproject_id = 'global'. - Upgrade to V2.0.8 so the installer replaces the V1 binary.
- Start V2 in the same directory (
/home/wjf). - V2 creates a new project keyed to
/home/wjf. The session picker defaults to filtering by the current project and directory, so only sessions created after the upgrade are listed; all prior sessions are hidden.
Expected Behavior
Sessions created for the same working directory should remain visible after upgrading. V1's global sessions in a non-git directory should be migrated to that directory's V2 project (or otherwise kept visible), per the migration guide's statement that supported non-API V1 behavior stays compatible.
Actual Behavior
The sessions remain in ~/.local/share/opencode/opencode.db but are not shown in the default session list. GET /api/session returns them (they carry projectID: "global"), while the TUI picker filters client-side to projectID === current project.id && location.directory === current directory. The Ctrl+O dialog reports No matches · search all sessions with <leader>l.
Additional Context
- Database:
~/.local/share/opencode/opencode.db, tablesession_v2. Before repair: 104 sessions total, 93 withproject_id = 'global'(81 in/home/wjf, 8 in/tmp/opencode, 3 in/home/wjf/novel, 1 in/home/wjf/videos). - Sessions in a git repository were unaffected:
/home/wjf/fadebreakkept a stable project id across the upgrade and stayed visible. - Relevant log timing: migrations ran at
2026-09-18T17:52:20Z; the new/home/wjfproject row was created at2026-09-18T17:52:21Z. - Workaround:
Ctrl+XthenL("List all sessions") shows everything, including theglobalproject. InsideCtrl+O, pressing<leader>lswitches to searching all sessions. - Repair performed locally:
POST /api/session/{sessionID}/movewith{"directory": "<the session's directory>"}re-bound each of the 93globalsessions to its directory project, after which they appear normally. This worked, but required knowing the sessions were hidden. - Likely cause: V1 used a single
globalproject for non-git directories, while V2 derives a per-directory project id (SHA-1 of the path). No data migration re-keys V1globalsessions.
Activity
veekayinhk commentedon Sep 20, 2026
Same issue on my end and almost exactly the same setup.
emsearcy commentedon Sep 22, 2026
Confirming the same symptom on the CLI side, not just the TUI picker/
Ctrl+Odialog.Environment: opencode 2.0.12, macOS (Darwin 27.0.0, arm64), zsh, ghostty terminal, no plugins configured.
Reproduction: Same setup — a workspace parent directory that is not itself a git repo but contains several git-repo subfolders (e.g.
~/code/myorg-workspacewith~/code/myorg-workspace/service-a,~/code/myorg-workspace/service-b, etc. as separate repos). Historical V1 sessions were created withcwdset to the non-git parent directory itself.After upgrading to V2 and starting a new session in that same parent directory,
opencode session listshows only the single new session — none of the ~228 historical sessions for that directory. Direct inspection ofsession_v2confirms the same root cause already described here:GET /api/session?directory=<parent-dir>does return the historical sessions (confirming the data itself is intact), but the plainsession listCLI subcommand — like the TUI picker — filters strictly to the project resolved for the current directory, so all theglobal-bucketed history is hidden by default.This suggests the fix (or workaround) described here should also cover the CLI
session listsubcommand, not only the TUI/Ctrl+Osurfaces. ThePOST /api/session/{sessionID}/moverepair mentioned above worked as a description of the underlying mechanism, but a built-in "show global-bucketed sessions for this directory" option (CLI flag and/or automatic re-key on first V2 use of a directory) would avoid needing to know about the API to recover history.wescran commentedon Oct 1, 2026
Still reproduces on v2.0.21 (latest), macOS arm64 (Darwin 25.6.0), upgraded from V1.18.32.
project_id = 'global'(non-git dirs: 43 in~/git, 9 in~, 1 in another non-git subdirectory). Messages migrated fine;GET /api/sessionreturns all of them.~/giton first launch, so/sessionsthere shows only post-upgrade sessions. Git-repo projects are unaffected (same project IDs in V1 and V2).ctrl+ain/sessionsand browse the/(global) project.Hpkings777 commentedon Oct 11, 2026
Confirming this still reproduces on v2.0.26 (latest) — adding fresh, fully-verified evidence.
Environment
@tarquinen/opencode-dcp,@cortexkit/opencode-magic-context,opencode-models-discovery(not required to reproduce)Symptom
From
/root(non-git dir), the directory-scoped TUI session picker (Ctrl+XthenL, or/sessions) shows 1 session. Switching to "all projects" (ctrl+a) shows all of them. 44 legacy V1 sessions are silently hidden.Database evidence (
~/.local/share/opencode/opencode.db)session_v2: 197 rows, of which 175 keepproject_id='global'./root: 45 rows — 44 withproject_id='global'+ 1 new V2 session with the freshly-created projectcd31f2124f23b5c0556ebda23ab6bff6bdc91712.sessiontable has the same distribution → importer copiedproject_idverbatim.globalsessions stranded in/root(44),phorixgpa(42+11),samtron(33),exp(8), … — i.e. every non-git directory.Hash formula verified locally
sha1("directory:" + dir)reproduces the V2 project id byte-for-byte:This matches the
Project.resolveanalysis in #50551.API evidence (
opencode api session.list)directory=/rootglobal+ 1 new)project=cd31f212…project=cd31f212…&subpath=rootWhy the TUI misses them
The client resolves
/rootto its own V2 project (cd31f212…) instead of the legacyglobalproject, so it scopes the query to{ project: "cd31f212…", subpath: "" }→ 1 hit, while the 44 legacy rows live under{ project: "global", subpath: "root" }. The V1→V2 importer created the per-directory project rows but never re-homed theglobalsessions.Workaround
POST /api/session/{sessionID}/movewith the session's owndirectoryre-resolvesproject_idand restores visibility (consistent with #51176 / #50800).Cross-refs: #50551 (code-level cause), #51176, #50800, #54521. Please keep this open until the importer re-keys
globalsessions bydirectory(or the list falls back to directory matching).psno commentedon Oct 12, 2026
Confirming this on 2.0.27 with independent diagnostics, plus one edge case and a caveat about the
session moveworkaround that may be useful for PR #51935.Environment
latest), upgraded from V1 with the installer that replaces the V1 binary~/.local/share/opencode/opencode.db, 620 MB, WALsession_v2: 308 rows total, 149 withproject_id = 'global', all withworkspace_id IS NULL, from 4 non-git directories:/mnt/e/DownLoad/snodxz/Test/Hermes/mnt/e/DownLoad/snodxz/Test/Hermes/ai-grading/mnt/e/DownLoad/snodxz/Test/Hermes/pure-content/mnt/e/DownLoad/snodxz/Test/CYT/opencodeSessions from git worktrees were unaffected: two projects kept stable ids across the upgrade (85 and 73 sessions).
Isolation
session moveworkaround (verified, with a caveat)POST /api/session/{sessionID}/movewith{"directory": "<the session's own directory>"}re-attributes the row and it reappears in the project-scoped list:Caveat worth knowing before recommending it broadly:
movebumpstime_updatedto now, so every repaired session sorts as "today" and the original activity ordering is lost. Processing oldest-first keeps the relative order roughly intact but still places all repaired sessions after all unrepaired ones.time_createdis preserved. The migration approach in #51935 does not have this side effect, which is an extra reason to prefer it.Subagent child sessions carry the same
project_id = 'global'and were re-attributed together with their parents; parent/child project consistency held afterwards (0 mismatches).Edge case for
attributeDirectories9 of the 149 rows point at a directory that no longer exists (
/mnt/e/DownLoad/snodxz/Test/CYT/opencodewas deleted). The move API rejects them:{"_tag":"InvalidRequestError","message":"Directory does not exist: /mnt/e/DownLoad/snodxz/Test/CYT/opencode"}Those rows can only stay under
global, which matches the fallback described in #51935 — worth keeping that fallback (and its warning) rather than failing the migration for rows whose directory is gone.rodbarnard commentedon Oct 12, 2026
Confirming the same problem on 2.0.25, with a correction to the workaround and a verification trap that cost me a wrong attempt.
The repair needs
path = '', notNULL. Re-owning the rows to the directory's project is only half of it. The listing filters onprojectandsubpath, andsubpath=''matchespath = ''but does not matchpath IS NULL:The single row in the second case is one that already had
path = ''. So clearingpathtoNULLproduces a confusing half-state: the session is owned by the right project but is still filtered out of the listing.Verification trap:
opencode session listomitssubpath, so it reports the sessions as restored while the TUI and sidebar still show none. Verify with the filter the UI actually uses instead:Repair (SQLite on macOS/Linux; back the database up first, and stop the service if you hit
SQLITE_BUSY):This restored 112 root sessions in one directory and 39 in another, from 1 and 0 respectively. Subagent rows share the parent's directory, so the same statement moves them, which matches what #51935 does.
Stopgap without touching the database:
Ctrl+XthenL(session.list, "List all sessions") is unfiltered and shows everything, including rows still underglobal. That is a different command fromCtrl+O(open.menu), which is project-scoped and reports no matches.One gap, also noted on #51935:
attributeDirectoriesselectsWHERE project_id = 'global', so rows someone has already moved offglobalby hand are outside the repair and remain hidden unlesspathis corrected too.techwhispers commentedon Oct 13, 2026
Confirming on 2.0.28 (channel
latest, Linux x86_64) — still present; the fix in #51935 is not released yet.Environment
v2.0.28, channellatest, upgraded from V1 on2026-10-137.2.5(x86_64),TERM_PROGRAM=tern,TERM=xterm-256color,/bin/bashEvidence (before repair)
39 of 40 sessions kept
project_id = 'global'; the only non-globalrow was created after the upgrade.GET /api/session(no filter)?project=global?project=<directory's project>&parentID=null?directory=<directory>So the CLI/TUI-scoped view returns nothing while the rows are present and intact, matching the report and the
loose_psylockeV1→V2 migration leavingglobalattribution in place.Workaround note:
session moveis TUI-safe in 2.0.28POST /api/session/{id}/movewith the session's own directory re-attributes the row, and in 2.0.28 it setspath = ''(project root). That means the repaired rows also satisfy the TUI'sproject=<p>&subpath=filter — not justopencode session list(which omitssubpath). This avoids thepath IS NULLhalf-state described in #54815:Caveats
time_updatedbump as @psno: after moving, every repaired session sorts as "today". The originals are recoverable from the legacysessiontable (time_updated), so I restored them and removed the move-addedlocation-switched/idleevents. The migration approach in fix(core): attribute migrated V1 sessions to the project of their directory #51935 does not have this side effect, another reason to prefer it.globalwith the existing warning.kiwols59 commentedon Oct 14, 2026
Confirming on 2.0.28 (latest), Linux x86_64, no plugins, upgraded from 1.18.35. All affected directories are non-git. 117/117 migrated sessions sat under
project_id='global';opencode session listshowed 1 in the directory and 0 in another.Adding the
session moveworkaround with a TUI-safety correction and atime_updatedcaveat:After moving, 0 rows remained on
globaland the sessions reappeared under their directory's project.session movewritespath = ''for a session moved to its project root, so thesubpathfilter matches. Verified?project=<p>&parentID=nulland?project=<p>&parentID=null&subpath=both return the same count (52). Thepath/subpathhalf-state noted above applies to manualproject_idedits, not to this endpoint.session movebumpstime_updated, so sessions re-sort to "now". Snapshottime_updatedfirst and restore it afterward if the chronological order matters.Also: #51935 reparents such rows on next start for already-migrated installs, which will make this workaround unnecessary.
marcello-russo commentedon Oct 15, 2026
Same root cause confirmed on 2.0.29 (Linux), with one addition: resetting
project_idalone is not enough — the stalepathmust go too (same finding as in #50551).SELECT project_id, count(*) FROM session_v2 WHERE directory='<dir>' GROUP BY project_id;showed ~all history onglobalplus 1 session on the new per-directory project. After remappingproject_id, the TUI picker still showed 1 session, because it queriesproject=<id>&subpath=''(dialog-session-list.tsx) while migrated rows kept the oldpathrelative to/(e.g.home/user/project).Verified fix (all confirmed via the exact TUI query afterwards):
UPDATE session_v2 SET project_id = (SELECT p.id FROM project p WHERE p.worktree = session_v2.directory) WHERE project_id='global' AND ...— 760 + 1988 rows across two passes (project rows appear on first TUI open per directory; IDs are random, never invent them).UPDATE session_v2 SET path='' WHERE project_id!='global' AND path!='' AND directory=(SELECT worktree FROM project WHERE id=session_v2.project_id);— 798 rows.Side note matching this thread: most migrated rows are subagent children (
parent_id IS NOT NULL) — only 154 of 3681 are top-level, so the visible list is much shorter than raw counts suggest.Full guide: https://gist.github.com/marcello-russo/0b3bd4872a9eac4b0c31ebf7dc332c01
kiwols59 commentedon Oct 17, 2026
Worth noting the
session moveAPI route does both steps in one call: it reparents to the directory's project and writespath = '', so the separatepathUPDATE is only needed for the raw-SQL route.Verified on 2.0.28 after repairing 118 sessions across 4 non-git directories: all rows ended with
path = '', 0 stale, 0 NULL, and?project=<id>&parentID=null&subpath=returned the same top-level count as the unscoped CLI list.jimicze commentedon Oct 18, 2026
Confirming this from #56760 — still reproduces on v2.0.32 (CLI), desktop app 1.19.3, macOS 15.2, on 2026-10-18, in a non-git directory:
session=session_v2= 5,241, so the rows are copied but the project link is not.projectrow for the directory is created on the first V2 run (c98ce852…, created 2026-10-18 12:38:22) and has 0 sessions:SELECT count(*) FROM session_v2 WHERE project_id LIKE 'c98ce852%'→ 0. The 30 sessions for that directory stay underproject_id = 'global'.project_id = 'global': 1,071 (all non-git directories).Screenshots (TUI vs desktop app): TUI · desktop
jimicze commentedon Oct 18, 2026
Proposed fix
Root cause is in the V1→V2 importer,
packages/core/src/database/v1-migration.bun.ts(run()):V1 stored non-git sessions with
project_id = 'global', and'global'exists as a project row, so those sessions are copied asproject_id = 'global'. Meanwhile V2'sProject.resolve(packages/core/src/project.ts) gives a non-git directory a fresh identity:The TUI resolves the cwd to that directory-hash project and lists sessions by
project_id, which has 0 rows → "No sessions available". The V1 desktop app still lists by directory/global, so it shows them.1. Fix the importer
When a legacy session's
project_idisglobal(or not present inproject), assign the same per-directory id thatresolvewould use, and create the project row:Two things must match, or the id drifts from what
resolvecomputes at runtime:directorythe same wayProject.resolvedoes (fs.resolve/ realpath), so case and symlinks match. The observedOpencode-testvsopencode-testsplit comes from exactly this.Project.directoryID(directory)and call it from bothresolveand the importer, so they cannot diverge.2. Backfill for already-migrated databases
The importer is one-shot:
MIGRATION_STATE_KEYshort-circuits tocompleted, and it will not re-run for anyone already on V2 (which is why this is still present on 2.0.32). Add a normal versioned migration (like..._normalize_storage_paths) that reassignssession_v2.project_idfor rows still on'global':Doing it per directory (not one blanket UPDATE) is what keeps multi-directory
'global'sessions correct.3. Defensive runtime fallback
In the session list query, when the resolved project has no sessions, also include sessions whose
directorymatches the project worktree. This unblocks users on an already-broken DB even before the backfill ships, and it is the approach the existing runtime PRs likely take.4. Edge cases
directoryno longer exists should still list under their project instead of being dropped.@opencode-agent — filing this here per your request on #56760.
jimicze commentedon Oct 18, 2026
Correction to my comment above: #51935 already does it, in this exact file, and it handles the
pathpart I missed — it setspathto the subpath (''at the project root) and re-runs attribution when the state is alreadycompleted. So "re-own global sessions to their directory's project and clearpath" is existing work, not a new migration. My comment was a duplicate of that PR.Where #51935 still looks incomplete (worth checking before merge):
project/worktreerow yet.attributeDirectoriesonly re-owns a session to an existing owner (SELECT ... FROM worktree UNION ... FROM project). For a non-git dir whose project row does not exist yet, there is no owner, so the session staysglobal— which is the exact~/Projects/opencode-testcase here. V2 creates that row lazily on firstProject.resolve, so the session only reappears after the directory is opened once and opencode restarts (the completed-branch re-runs attribution). Computing the id directly —Hash.fast(\directory:${realpath}`), the same expression as the non-git branch ofProject.resolve` — would fix it in one shot instead of depending on a second startup.WHERE project_id = 'global' AND workspace_id IS NULLskips sessions that have aworkspace_id. Intentional? If not, those stay hidden.normalizeDirectoryis a raw string compare (no realpath / no case fold). A session stored as…/Opencode-testwill not match a project worktree stored as…/opencode-test, even though on case-insensitive macOS that is the same directory. Resolving to the on-disk realpath on both sides before comparing would close that.So: options 1 and 2 in my earlier comment are #51935; options 3 (runtime fallback) and 4 (exist-check edge case) stand on their own. The
pathrequirement reporters found (path = '', #50551) is already in #51935 but not yet released, which is why 2.0.32 still shows the bug.fahrim commentedon Oct 19, 2026
Independent confirmation on 2.0.32 (upgraded from V1 1.18.4), Linux x86_64, no plugins, non-git home directory (
/root), 10 V1 sessions (8 top-level + 2 subagent children).Same picture as the other reports: all 10 rows sat under
project_id = 'global'withpath = 'root', while V2 created a new project row for the directory (cd31f212…, canonical/root) with 0 sessions.opencode session listshowed only the one session created in V2.Repair via the supported route, same as documented here:
rebound
project_idto the directory's project and wrotepath = ''in one call — thesubpathhalf-state was not hit. Verified with the TUI-style query, both return the same count:One small addition to the
time_updatedcaveat: restoring the original values works while the background service is running — no restart needed, the list shows the original dates immediately.(The legacy
sessiontable still holds the V1 originals; it is also the source for thetime_updatedvalues mentioned above.)#57145 is a duplicate of this issue and was closed in favour of it.
1 remaining item
kiwols59 commentedon Oct 21, 2026
One gap worth flagging for already-affected installs.
This thread is almost entirely people upgrading an existing V1 database. #56322's description says it only changes imports that have not completed: "Completed imports are not rerun or repaired." So it prevents the bug for future V1→V2 imports (including writing the relative path), but it leaves every database already in this state unrepaired.
#51935 was the change that healed completed imports on the next start, and it was closed on 2026-10-20 to stay within the open-PR limit (the author notes the branch stays on the fork and can be reopened).
So unless that existing-install repair is folded into #56322 or #51935 is reopened, the
session moveworkaround (reparent +path = ''in one call) remains the only recovery path for installs already migrating. Worth making explicit whatever resolves this issue, so affected users aren't left waiting for a fix that only applies to new imports.joelabreurojas commentedon Oct 22, 2026
After that, GET /api/session returns them and they show up in /sessions.
Side effect: move sets time_updated to "now", so the original ordering by date is lost.
rekram1-node commentedon Oct 23, 2026
Reopening this. #56322 fixes the import for anyone who hasn't upgraded yet, but if you already upgraded, your sessions are still stuck under
global. I'm going to add a way to repair already-migrated sessions so you don't need the move command or SQL from this thread.Until then the move workaround above works:
opencode api post /api/session/<id>/move --data '{"directory":"<dir>"}'. Heads up that it resets each session's updated time.Duplicates: #50521 #50551 #50800 #50999 #51176 #51283 #52844 #52919 #53450 #53709 #54521 #54640 #54679 #54774 #54815 #55069 #55081 #55142 #55253 #55275 #55381 #55596 #55633 #55663 #55671 #56030 #56312 #56760 #57068 #57145 #57629, and #57067 is the same root cause.
opencodejr commentedon Oct 28, 2026
Thanks for the detailed report. I could reproduce this.
On 2.0.8: I created three sessions with V1 1.18.31 in a non-git directory, and V1 stored them under the
globalproject. Then I started V2 2.0.8 in the same directory. The import kept the sessions underglobaland created a new project for the directory. As a result,opencode session listthere shows nothing, even though all three sessions are still insession_v2. The script below reproduces it.On the latest
dev(d6867f2):dev, the sessions were still underglobaland still hidden. So upgrading alone won't bring back history for people who already upgraded.Until that's handled, the workarounds you described still work:
<leader>linside Ctrl+O, lists everything.POST /api/session/{id}/movewith the session's directory moves a session into the directory's project for good.Reproduction script (repro.sh)
Run it:
bash repro.shjimicze commentedon Oct 29, 2026
@opencodejr
it this issue fixed by #56322 ?
jimicze commentedon Nov 2, 2026
#56322 is not the solution.
same for #58846
jonee-chu commentedon Nov 4, 2026
Confirming this on V1 1.19.0 → V2 2.0.28 (macOS arm64, fresh upgrade; V1 data backed up before upgrading).
It is not limited to a single directory. Every non-git directory I had used in V1 was affected:
Total 89 sessions across 9 non-git directories. Sessions in git repos were unaffected. In
session_v2every affected row still hadproject_id = 'global', while V2 had created a per-directory project;opencode session listfilters on the project, whileGET /api/sessionstill returned the rows.The
POST /api/session/{id}/moveworkaround from this thread repaired all 89 sessions with no message or fork loss. Two caveats that the planned repair path should handle:moverewritestime_updatedto the repair time for every session. After moving, the whole history shows the same timestamp and the picker ordering no longer reflects real last activity. I had to restore the originaltime_updatedvalues from a pre-repair DB snapshot. A built-in repair should preservetime_updated.moveis per session. The repair should enumerate every row withproject_id = 'global'and a non-rootdirectory(excluding/), not only top-level sessions, so child/subagent sessions are re-attributed as well.Finally, #56322 only fixes fresh imports. On 2.0.28, and for databases already imported by 2.0.x, the sessions are still stranded under
global, so the "repair already-migrated sessions" path mentioned above is still needed.jimicze commentedon Nov 4, 2026
They are trying to fix it, in #58846 , but not completely again. I did some testing, and its need to fixed again, even in that PR.
jimicze commentedon Nov 6, 2026
Mirror of the cross-reference I added to #50130, so the two migration tickets stay distinct:
project_id='global', so per-project session lists do not find itsession_v2,GET /api/session/{id}→ 404project_idas-is; V2 resolves a per-directory project insteadkv['migration.v1-v2']='completed'is a permanent latch; rows that appear afterwards are never drainedWhy #58846 still is not enough as the fix
Tested against a real already-migrated DB (v2.0.32 import, 17 GB): 1,071 sessions under
globalacross 42 directories (7 of them git repos on disk), all present in the legacysessiontable, 0 with aworkspace_id. The repair's shape is right (project_id+path=''), but these gaps remain:breakfalls through to theREPAIR_STATE_KEYinsert, so every folder after a hung one stays underglobalfor good. Already flagged by the bot and @matt8s — +1 forreturnwithout the marker and for removing the importer-side marker write.resolveDirectoryreturnsSome(undefined)forrealpath/isDirfailures (permission denied, transient unmount) andNoneonly for the 5s timeout. Those folders hitcontinue, and the loop still marks the repair completed — a transient filesystem error permanently skips the folder. The marker should be withheld whenever any folder failed to resolve, not only on timeout.path IS NULL. In this DB 79 rows havepath IS NULLand 992 have the old V1-style value (no leading slash). Add a row with the V1-style value and assert it is overwritten with''; that is the shape 992/1,071 rows depend on.path=''.workspace_id IS NOT NULLis silently excluded. This DB has 0 such rows, so that branch is unverified — is a V1 session that got aworkspace_idexpected to stay underglobal?sessiontable to exist and still contain the row (hasLegacySessions+EXISTS). If the V1 table was pruned after the import, V2 rows underglobalcan never be repaired. Might be intentional, but it is a hard boundary worth stating.Preserving
time_updated(raw SQL, no ORM bump) is the right call — that was the main casualty of thePOST /api/session/{id}/moveworkaround.Real-DB numbers and the test-gap details are also in #58846: #58846 (comment)