Skip to content

Sessions in a non-git parent directory are unattributable: resolve returns global before project_directory is consulted #48870

Closed as duplicate

Description

@internetisalie

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

  • sandboxes auto-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/init clears 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 outside location.directory. In
    packages/core/src/location-mutation.ts, lexicallyInternal is computed against
    location.directory, so siblings then require external_directory grants — and a relative
    path that escapes is rejected outright with relative_escape, which no permission can allow.
    Shared material next to the checkout becomes unreachable.
  • ProjectDirectories.create would express it exactly, but is not routed over HTTP
    (GET /project/:projectID/directories is the only directory endpoint) and, more importantly,
    resolve would 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

  1. github-actions commented on Sep 13, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. internetisalie commented on Sep 13, 2026

    @internetisalie
    Author

    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 directory to
    path.parse(input).root as well as the id, so two unrelated non-git directories are
    indistinguishable and worktree === "/" breaks path-relative permission scoping. That is a more
    serious 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:

    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
    resolve either way.

  3. neriousy commented on Oct 1, 2026

    @neriousy
    Member

    Closing as a duplicate of #47885. Same root cause: sessions in non-git directories resolve to the global project. As discussed here, #47885 is the canonical report.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions