Repository navigation
Description
Summary
When a session's directory is not inside a git repository, Project.resolve returns ID.global
immediately. project_directory — the table that already models "directories belonging to a
project", including a type of root — is never read on that path, and nothing else can
associate the directory with a project.
The result is that any automated setup which runs agents from a parent directory containing
several checkouts puts every one of those sessions into global, where no project-scoped view
can show them. There is currently no supported way to say "this directory belongs to project X"
for a directory that is not itself a git worktree.
Measurement
One host, one server, automated dispatch to agent seats over POST /session/{id}/prompt_async.
Live session store, read read-only:
sessions total 693
project_id = "global" 411 (59%) time_updated: now
next-largest project 130
The global sessions are not scattered — they are concentrated in a handful of directories, each
one a workspace root holding two or more repository checkouts plus shared material:
/scratch/ws-a 69 sessions
/scratch/ws-b 23
/scratch/ws-c 23
/scratch/ws-d 22
/scratch/ws-e 15
…
None of those roots has a .git. Each does contain checkouts that resolve perfectly well on
their own — one of them appears in its project's sandboxes list with zero sessions attributed to
it, while 12 sessions sit in global at the directory one level up.
The project those checkouts belong to shows sandboxes=7 and its most recent session is 8 days
old, because all recent work was dispatched at the parent.
Reproduction
mkdir -p /tmp/ws && cd /tmp/ws
git clone <some-repo> repo-a
git clone <other-repo> repo-b
mkdir -p .opencode/agents # cards shared by both checkouts
# dispatch with directory=/tmp/ws
curl -s -X POST "http://127.0.0.1:4096/session?directory=/tmp/ws" …Then:
select project_id, directory from session order by time_created desc limit 1;
-- global | /tmp/wsGET /project never lists /tmp/ws, and the session is invisible in any project-scoped UI.
Mechanism
packages/core/src/project.ts, Project.resolve:
const repo = yield* git.repo.discover(input)
if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))The early return happens before any lookup against project_directory.
packages/core/src/project/sql.ts already carries the association the case needs:
export const ProjectDirectoryTable = sqliteTable("project_directory", {
project_id: text().references(() => ProjectTable.id, { onDelete: "cascade" }),
directory: DatabasePath.absoluteColumn().notNull(),
type: text().$type<"main" | "root" | "git_worktree">(),
strategy: text(),
…
}, (t) => [primaryKey({ columns: [t.project_id, t.directory] })])and packages/core/src/project/directories.ts exposes create / remove / contains / list
over it. type: "root" reads as though a non-worktree directory was anticipated.
packages/opencode/src/project/project.ts also skips persistence for global, so nothing is
recorded even as a side effect:
if (input.projectID === ProjectV2.ID.global) return // saveProjectDirectoryWhy the existing escapes don't cover it
sandboxesauto-registration works and is self-healing, but only for a directory that
resolves to a non-global project — i.e. one that is already inside a git repo.POST /project/git/initclears the gate by making the parent a git root, but the parent then
becomes its own project. For N workspaces that is N single-use projects rather than N
directories under the project they belong to, which is the opposite of the grouping wanted.- Pointing the session at the checkout instead of the parent attributes correctly, but moves
the sibling directories outsidelocation.directory. In
packages/core/src/location-mutation.ts,lexicallyInternalis computed against
location.directory, so siblings then requireexternal_directorygrants — and a relative
path that escapes is rejected outright withrelative_escape, which no permission can allow.
Shared material next to the checkout becomes unreachable. ProjectDirectories.createwould express it exactly, but is not routed over HTTP
(GET /project/:projectID/directoriesis the only directory endpoint) and, more importantly,
resolvewould not read the row.
Proposal
Consult project_directory in Project.resolve before falling back to global:
const repo = yield* git.repo.discover(input)
if (!repo) {
const owner = yield* directories.ownerOf(input) // (project_id, directory) lookup
if (owner) return { id: owner, directory: input, vcs: undefined }
return { id: ID.global, … }
}and expose create / remove on the existing directories route so a caller can register a
directory without a git repository. Both pieces exist; the change is the lookup and the two
routes.
Happy to send a PR if the shape is agreeable — in particular whether an explicit association
should also win over git discovery, or only serve as the fallback as written above.
Environment
opencode 0.0.0-fix/phantom-unknown-tool-parts-202608232224
Read against origin/dev @ df23b7f948; every file cited above is identical there.
Aside, possibly a separate issue
Unmatched paths on the server return the SPA shell with HTTP 200 rather than 404 — e.g.
POST /nonsense-route-xyz → 200 with <!doctype html>. That makes probing the HTTP surface
misleading, since a 200 is not evidence a route exists. Happy to split this out.
Activity
github-actions commentedon Sep 13, 2026
This issue might be a duplicate of existing issues. Please check:
Project.resolveusing git discovery as the sole identity gate, causing non-git directories to collapse intoglobal. That issue proposes a broader fix (directory path as primary identity key); this issue proposes a narrower fix (consultproject_directorybefore falling back). Worth coordinating to avoid overlapping PRs.internetisalie commentedon Sep 13, 2026
The bot is right that this overlaps #47885, and #47885 is the better report — it predates this by
five days and covers strictly more.
In particular it catches a half I missed: the same early return collapses
directorytopath.parse(input).rootas well as the id, so two unrelated non-git directories areindistinguishable and
worktree === "/"breaks path-relative permission scoping. That is a moreserious consequence than the attribution problem I described here, and it is not addressed by my
PR at all.
So treat this issue as a duplicate of #47885 for the root cause. What I would keep from it:
global, concentrated in a handful of parent directories each holding several checkouts. That isProject identity should anchor on directory path, not git detection — fixes global-bucket fallback and session isolation #47885's "workspace root above multiple repos" case, at scale, under automated dispatch.
than competing. Project identity should anchor on directory path, not git detection — fixes global-bucket fallback and session isolation #47885 argues identity should key on the normalized directory path, with git as an
enrichment/merge signal. Under that scheme every directory becomes its own project, which fixes
the collision — but it still gives no way to say "this parent directory belongs to the project its
checkouts belong to". My PR adds exactly that, via the
project_directorytable that alreadyexists, as a fallback that never overrides git discovery.
Happy for a maintainer to close this in favour of #47885. If the directory-path-identity direction
lands first I would rebase #48871 onto it, since the association lookup sits in the same branch of
resolveeither way.neriousy commentedon Oct 1, 2026
Closing as a duplicate of #47885. Same root cause: sessions in non-git directories resolve to the
globalproject. As discussed here, #47885 is the canonical report.