Skip to content

desktop(win): fresh service start registers user home and drive root as ghost projects (no sessions, deletion does not stick) #49428

Open

Description

@junior-xj97-chan

Summary

On Windows, every fresh start of the OpenCode Desktop background service (opencode-cli.exe serve --service) registers the user's home directory (C:\Users\<user>) and the drive root (C:\) as projects in the server database. These project rows contain no sessions. Deleting them from Desktop → Settings → Projects does not stick: the next fresh service start (app relaunch, reboot auto-start) re-registers them, so the Projects list keeps growing ghost entries.

Environment

  • opencode version: 2.0.5 (desktop-managed CLI, channel latest)
  • OS: Windows 11 Pro 24H2 (Windows_NT 10.0.26200, win32 x64)
  • Terminal: OpenCode Desktop (Electron) 2.0.5
  • Shell: PowerShell 7 (user default)
  • Install/channel: desktop app packaged build, latest
  • Active plugins: opencode-mem@latest (global config)

Reproduction

  1. In Desktop → Settings → Projects, delete the extra entries (keep only the real project).
  2. Fully quit the Desktop app and kill the background service (opencode-cli.exe), or reboot (Desktop auto-starts on login).
  3. Open the Desktop app and let the background service start fresh.
  4. Settings → Projects shows the deleted entries again: C:\Users\<user> and C:\, both with zero sessions.

Reproducible consistently on every fresh service start.

Expected Behavior

The background service should not register directories as projects on its own. Startup should only register (at most) directories actually opened by the user — not the service's cwd (user home) and not the drive root.

Actual Behavior

Project rows for C:\Users\<user> and C:\ are inserted into the project table on service boot, matching the service startup timeline exactly (same second):

project table (opencode.db, redacted):

worktree time_created note
C:/Users/<user>/Documents/Default Project earlier real project
C:/Users/<user> 01:27:14 local ghost, 0 sessions
C:/ 01:27:20 local ghost, 0 sessions, 6 s after boot
/ built-in global project

Corresponding server log (~/.local/share/opencode/log/opencode.log, timestamps UTC):

2026-09-16T17:27:11Z run=4acc5529 message="cli starting" version=2.0.5 args=["--version"] role=cli
2026-09-16T17:27:12Z run=939abf0f message="cli starting" version=2.0.5 args=["serve","--service"] role=cli
2026-09-16T17:27:14Z run=939abf0f message="location services booted" directory="C:\Users\<user>" role=server
2026-09-16T17:27:20Z run=939abf0f message="watcher subscribe" path="C:\" type=entries role=server

Desktop main.log (01:27 boot) shows the app auto-starting and spawning the v2 background service; no explicit project directory is passed.

session / session_v2 tables contain no rows for either ghost directory — the projects exist purely from startup registration.

Additional Context

Activity

  1. github-actions commented on Sep 17, 2026

    @github-actions
    Contributor

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

    If your issue adds meaningfully new information (drive root registration, deletion not sticking), it may be worth keeping open, but please cross-reference #49209 to avoid duplicate fix efforts.

  2. junior-xj97-chan commented on Sep 29, 2026

    @junior-xj97-chan
    Author

    Still reproducible on 2.0.19, and I think I now have the exact mechanism plus a captured occurrence. Updating with the decompiled evidence.

    1. The fallback

    From the shipped bundle (opencode-cli.exe 2.0.19, Windows):

    function sd(e) {
      let i = new URL(e.url, "http://localhost").searchParams.get("location[directory]")
        || (e.headers["x-opencode-directory"] ? Xz(e.headers["x-opencode-directory"])
                                              : process.cwd());
      return Ci.make({ directory: le.make(i) });
    }

    Any request that carries neither location[directory] nor x-opencode-directory is silently attributed to the server process's own cwd. The desktop app spawns opencode-cli.exe serve --service with cwd = C:\Users\<user> (I verified this by reading ProcessParameters->CurrentDirectory out of the live service process PEB, not by inference), so a single directoryless client request resolves to the user's home directory.

    The same fallback appears in the session handlers:

    .handle("session.create", ... location: l.payload.location ?? { directory: le.make(process.cwd()) })
    .handle("session.import", ... location: l.payload.location ?? { directory: le.make(process.cwd()) })

    2. Booting a location writes the project row

    The row is a side effect of booting a location, not of a user adding a project. Captured from a fresh occurrence (2026-09-30 01:57 local, service not restarted — same run id throughout):

    01:57:03.528  location services evicted  C:\Users\<user>
    01:57:03.605  location services evicted  C:\Users\<user>\Documents\<project>
    01:57:03.641  location services evicted  C:\
    01:57:06.698  location services booted   C:\Users\<user>\Documents\<project>
    01:57:07.517  project row inserted for C:/Users/<user>   (0 sessions)
    

    project is doing double duty: user-visible projects and internal runtime locations. Once those share a table, "the service happened to touch a directory" becomes "a new item in your project list".

    3. Why it looks intermittent

    • A directory's row is only written on the first boot of that location. If it already exists, only time_updated/time_active change — which is why a manual delete appears to hold for a while.
    • Locations are evicted on an idle TTL and re-booted later, so a delete only "sticks" until the next boot.
    • Any client action that issues a directoryless request (app reconnect, window focus, project switch) re-arms it. Between 2026-09-16 and 2026-09-29 the home-directory location booted 189 times (~20/day) against 143 boots of the real project.

    4. A third ghost: opencode's own directories

    Twice, one of opencode's own directories got registered:

    • C:\Users\<user>\.config\opencode (2026-09-28 02:15)
    • C:\Users\<user>\.local\share\opencode (2026-09-29 23:45)

    For the second one, the Playwright MCP server process spawned for that location still had its cwd set to that directory, which is how I identified it. Same class of problem: any directory that becomes a location becomes a project.

    5. Conjecture: why this looks Windows-only

    On POSIX the service cwd is typically /, which is already the built-in global project row, so the fallback is invisible. On Windows the cwd is C:\Users\<user> while global is /, so you get two extra rows instead of none. I have not verified this on macOS/Linux — flagging it only as a possible reason this report looks low-impact.

    6. Ruled out: plugins

    I suspected the global plugin opencode-mem at first. It is not the cause: in v2 mode its client is a local shim (dist/v2/legacy-client.js → createLegacyClient(ctx)) whose methods either return constants or call in-process ctx.session.* / ctx.generate.* APIs — it never issues HTTP, so it cannot trigger the fallback. services/user-profile/ai-cleanup.js shares the same shim via getV2Client(). I mention it only so nobody repeats that dead end.

    Impact

    Cosmetic plus watcher overhead — no data or session loss. The service also installs recursive type=entries watchers on C:\, C:\Users and C:\Users\<user> for these rows.

    Suggested fix

    1. Don't create/update a project row as a side effect of booting a location; only register when a user explicitly adds a project (or when a session is actually created there).
    2. Reject a missing directory (400) instead of falling back to process.cwd().
    3. Clients should always send location[directory].

    Environment

    opencode 2.0.19 (desktop ai.opencode.desktop 2.0.19, channel latest), Windows 11 Pro 24H2 x64, PowerShell 7.

  3. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the detailed report. I can reproduce the home-directory part on 2.0.5, and it still happens on the latest dev (950111f).

    What I saw (Linux, server API only, no Desktop):

    • Starting opencode serve --service with the home directory as its working directory registers nothing by itself; the project table stays empty.
    • The first request to the service that doesn't name a directory (for example GET /api/agent) makes the server fall back to its own working directory. It boots location services there (location services booted directory=<home>) and stores a project row for it, with no sessions.
    • If that row is deleted and the service restarted, the next request of that kind adds it again, which matches the entries coming back in Settings → Projects.

    The startup scan also watches / and every parent directory, which fits your watcher subscribe path="C:\" log line. I could not get a project row for the drive root itself here, and I couldn't check which request the Desktop app sends at startup, since that needs Windows and Desktop.

    The script below reproduces it against the service API.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #49428: a request without a location makes `serve --service`
    # register its own cwd (the user's home) as a project, and it comes back after deletion.
    # Usage: ./repro.sh            (uses `opencode` from PATH)
    #        OC_DEV=/workspace/repo ./repro.sh   (runs the CLI from a source checkout)
    set -u
    H=$(mktemp -d /tmp/oc-home-XXXX)
    PORT=${PORT:-$((49000 + RANDOM % 1000))}
    export HOME=$H XDG_DATA_HOME=$H/.local/share XDG_CONFIG_HOME=$H/.config XDG_STATE_HOME=$H/.local/state XDG_CACHE_HOME=$H/.cache
    cd "$H"
    SUF=""; [ -n "${OC_DEV:-}" ] && SUF="-local"  # source checkouts use the "local" channel file names
    run() {
      if [ -n "${OC_DEV:-}" ]; then
        exec bun --conditions=browser --preload="$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js" "$OC_DEV/packages/cli/src/index.ts" "$@"
      else
        exec opencode "$@"
      fi
    }
    projects() { python3 -c "import sqlite3;print([r[0] for r in sqlite3.connect('$H/.local/share/opencode/opencode$SUF.db').execute('select worktree from project')])"; }
    start() { run serve --service --port $PORT > "$H/serve.log" 2>&1 & PID=$!; for i in $(seq 1 120); do [ -f "$H/.config/opencode/service$SUF.json" ] && curl -s -o /dev/null http://127.0.0.1:$PORT/ && break; sleep 0.5; done; sleep 1; }
    (run --version)
    start
    echo "projects after fresh service start (no requests): $(projects)"
    PW=$(python3 -c "import json;print(json.load(open('$H/.config/opencode/service$SUF.json'))['password'])")
    echo "GET /api/agent (no location) -> $(curl -s -o /dev/null -w '%{http_code}' -u "opencode:$PW" http://127.0.0.1:$PORT/api/agent)"
    sleep 1
    echo "projects after one directory-less request: $(projects)"
    kill $PID; wait $PID 2>/dev/null
    python3 -c "import sqlite3;c=sqlite3.connect('$H/.local/share/opencode/opencode$SUF.db');c.execute('delete from project');c.commit()"
    echo "deleted all project rows: $(projects)"
    start
    curl -s -o /dev/null -u "opencode:$PW" http://127.0.0.1:$PORT/api/agent; sleep 1
    echo "after restart + directory-less request: $(projects)"
    grep -o 'message="location services booted" directory=[^ ]*' "$H/.local/share/opencode/log/opencode$SUF.log" | tail -1
    kill $PID; wait $PID 2>/dev/null

    Run it: bash repro.sh

  4. junior-xj97-chan commented on Nov 1, 2026

    @junior-xj97-chan
    Author

    Still reproducible on 2.2.5 (Desktop 2.2.5, Windows 11, opencode-cli.exe serve --service), and I can now narrow the "which request" part down to the server side and add the exact endpoints that persist the ghost row.

    What I can prove

    1. The desktop app puts the service's cwd on the home directory.

    packages/desktop/src/main/lifecycle/configure.ts:18-21

    export function configureApplication() {
      try {
        process.chdir(homedir())
      } catch {}

    The serve --service process is spawned from the main process and inherits that cwd, so the server's process.cwd() is C:\Users\<user>.

    2. Any location-scoped request without a directory lands on that cwd.

    packages/server/src/location.ts:71-79

    const directory =
      query.get("location[directory]") ||
      (request.headers["x-opencode-directory"] ? decode(request.headers["x-opencode-directory"]) : process.cwd())

    3. Booting that location persists a project row.

    packages/core/src/location.ts:28-29

    const persist = ref.workspaceID !== undefined || (yield* fs.isDir(ref.directory))
    const resolved = yield* project.resolve(ref.directory, { persist })

    persist is true whenever the directory exists — so booting a location for the fallback cwd writes a project row for the home directory with zero sessions.

    Reproduced in isolation (no Desktop, no plugins)

    Fresh XDG_DATA_HOME, serve started with cwd = a temp "fake home", then exactly one request per fresh database. Endpoints that wrote project = <cwd> with no location[directory] and no x-opencode-directory header:

    endpoint creates ghost project
    GET /api/config yes
    GET /api/agent yes
    GET /api/command yes
    GET /api/provider yes
    GET /api/model yes
    GET /api/skill yes
    GET /api/location yes
    GET /api/info no
    GET /api/project no
    GET /api/session no
    GET /api/event (SSE) no

    So it is not the service booting itself — serve with an empty database writes nothing. It is the first directoryless client request, and seven different endpoint families are enough to trigger it.

    How often it hits this machine

    ~/.local/share/opencode/log/opencode.log (45 MB):

    • 1465 location services booted lines in total.
    • 140 distinct server runs; in 119 of them the first location booted is C:\Users\<user>, 21 started with the real project.
    • Per-directory: home 766, real project 574, Temp\opencode 19, Temp\ghref 19, five short-lived opencode-browser-* temp dirs 5-7 each, C:\ 14, \ 9.

    So the home row is re-created on ~85% of launches, which matches "deletion does not stick". The row only comes back when the home location is booted again; it survives in the DB untouched while that location stays warm, and reappears on the next boot after idle eviction / relaunch.

    What I could not pin down

    Which renderer call omits the directory. The only no-argument location.get() in the app is the directory-picker fallback (packages/app/src/workspaces/selection/dialog.tsx:116), but that dialog only mounts when the picker opens, so it is probably not the startup call. Every other call site I found passes { location: { directory } }.

    Suggested fix

    Two independent guards, either one stops the ghost row:

    1. Do not persist a project for a directory that came from the process.cwd() fallback — requestRef() could return a flag and Location.layer could call project.resolve(dir, { persist: false }) for it (the option already exists). A directoryless request should not create durable state; it may even deserve a 400.
    2. Do not process.chdir(homedir()) in the desktop main process, or spawn the service in a neutral cwd (app userData). That alone changes the blast radius from "the user's home shows up as a project" to "a scratch dir nobody notices", but (1) is the real fix for the other directoryless callers you mentioned (plugins/SDK).

    If you want the exact client request, a debug log line in requestRef() when the fallback is taken (with the request URL) would surface it in one launch.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions