anomalyco / anomalyco/opencode
Sessions in a non-git parent directory are unattributable: `resolve` returns `global` before `project_directory` is consulted
@jlongster is already working on this.
Since Sep 13, 2026.
- Dominant language
- TypeScript
- Stars
- 209k
- Forks
- 27.5k
- PR merge metrics
- PR metrics pending
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/ws
GET /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 // saveProjectDirectory
Why 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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.