Skip to content

tui: saved tabs are shared between local and remote servers #47998

Open

Description

@abeisleem

Description

The V2 TUI shares saved session tabs between the managed local server and explicit remote servers. Opening another server can restore the previous server's tabs, and changing that layout can affect the tabs restored when returning.

Each server should restore its own tabs in both global and current-directory scopes. Selected terminal panes should also stay separate, even when servers have identical session or terminal IDs.

Steps to reproduce

  1. Open the managed local TUI and leave some session tabs open.
  2. Exit, then launch with --server <remote-url> using the same TUI storage channel. For current-directory scope, use the same launch directory.
  3. Observe that saved tabs are shared with the other server.
  4. Change the remote layout, exit, and return locally. The local layout should remain independent.

Related references

Activity

  1. reisi007 commented on Sep 29, 2026

    @reisi007

    Adding reproduction evidence measured on 2.0.19, since this report had no attached data. Same setup as described: managed local service plus an explicit --server remote, both clients sharing one cli.json with tabs.mode: "on" and tabs.scope: "global".

    The tab store is one file per machine, and --server is not part of its key

    ~/.local/state/opencode/latest/tui/tabs.json has two buckets, global and cwd. A client connected to the remote server writes its session IDs into that same local file:

    {
      "global": {
        "tabs": [
          { "sessionID": "ses_f1307fc2…", "title": "WebHarness" },
          { "sessionID": "ses_f13c841…", "title": "Open Accredidation" },
          { "sessionID": "ses_f13cb85…", "title": "LuminaRust" }
        ],
        "unread": {}
      },
      "cwd": {}
    }

    All three are remote sessions. The cwd bucket stayed empty throughout, so this is purely a consequence of scope: "global".

    API-level confirmation

    Each stored ID resolves on the remote server and is a 404 on the local one — deterministic, not intermittent:

    sessionID local service remote --server
    ses_f1307fc2… 404 SessionNotFoundError present (location.directory: /projects)
    ses_f13c841… 404 SessionNotFoundError present
    ses_f13cb85… 404 SessionNotFoundError present
    $ opencode api get /api/session/ses_f1307fc2…
    HTTP 404 Not Found
    {"_tag":"SessionNotFoundError","sessionID":"ses_f1307fc2…","message":"Session not found: ses_f1307fc2…"}
    
    $ opencode api get /api/session/ses_f1307fc2… --server http://<remote-url>
    {"data":{"id":"ses_f1307fc2…","title":"WebHarness","location":{"directory":"/projects"}, …}}

    Points that may matter for the fix

    • Not a version-skew artifact. Both sides report 2.0.19.
    • No role=cli log entry. Grepping ~/.local/share/opencode/log/opencode.log for the error returns nothing from the client; it surfaces only in the TUI. Any fix or regression test relying on client logs will not find it as-is.
    • Session IDs carry no server identity, so the store needs a namespace — the endpoint URL, or something stable derived from it. Nothing else in the entry distinguishes the origin.
    • The cwd workaround is incidental here, not a guarantee. It works in this setup only because the two path namespaces cannot collide (/Users/… locally vs. /projects/… on the remote). A client sharing a directory string with a foreign server hits the same failure, which matches the scope of this report.

    Workarounds tried

    Workaround Result
    tabs.scope: "cwd" Works here, incidental. Fails if directory strings ever collide across servers.
    OPENCODE_CLI_CONFIG_CONTENT='{"tabs":{"mode":"off"}}' per invocation Works, disables the feature for that client only.

    Environment: opencode 2.0.19 (client and server), macOS 27.0.1 arm64, Apple_Terminal, zsh, channel latest, no plugins.

    Full write-up including the redacted draft: #52080

  2. abeisleem commented on Oct 5, 2026

    @abeisleem
    Author

    @reisi007 Your reproduction matches the collision addressed by #47999: saved global and cwd layouts are now scoped by server, including when directory strings or session IDs coincide. Existing legacy storage is preserved for managed local use, so previously mixed remote tabs may still need closing once. The branch is updated against V2; 70 focused tests and the repository lint/typecheck check pass. Could you verify your local/remote workflow against it?

  3. reisi007 commented on Oct 5, 2026

    @reisi007

    Is there a build where it is included?

  4. fanrj3 commented on Oct 9, 2026

    @fanrj3

    Still reproduces on 2.0.26 (fresh client, single machine) — and the collision covers cwd buckets too; per-client launch directories work as a reliable workaround

    Since there's an open question above about which build includes the fix, here's fresh data: this is still present in the latest release we have — CLI 2.0.26 on macOS.

    Repro (one machine, three TUI clients, one per server):

    1. Launch each client from the same directory (e.g. a shared home dir):
      opencode --server http://hostA:49374 / http://hostB:… / http://hostC:…
    2. Open two sessions on client B (server B).
    3. The two tabs immediately show up in the tab bars of clients A and C. Clicking one of the foreign tabs shows Session not found: ses_… and interrupts the current view; after that failed click the foreign tabs get pruned and both clients return to normal on their own.

    State evidence from the client machine — ~/.local/state/opencode/latest/tui/tabs.json:

    {
      "global": { "tabs": [], "unread": {} },
      "cwd": {
        "<shared launch dir>": {
          "tabs": [
            { "sessionID": "ses_…", "title": "…" },
            { "sessionID": "ses_…", "title": "…" }
          ],
          "unread": {}
        }
      }
    }

    Notes:

    • Entries are {sessionID, title} with no server identity anywhere, and cwd buckets are keyed by launch directory only — so clients with different --server targets that share a launch dir share one bucket. (Same mechanism as tui: global tabs are shared across clients with different --server targets, causing SessionNotFound #52080; we hit it through the cwd bucket.)
    • A freshly launched 2.0.26 client still writes this unscoped shape, and pre-existing mixed buckets aren't migrated either — so it doesn't look like the fix(tui): keep saved tabs separate by server #47999 scoping has shipped in 2.0.26. Happy to re-test as soon as a build that includes it is available.
    • Workaround until then: give each client its own launch directory (we spawn every client with a per-server cwd). With distinct cwds the tab stores are fully isolated and the cross-server leak stops.
  5. sghng commented on Oct 16, 2026

    @sghng

    Heads-up for anyone watching this issue: the linked fix PR #47999 was closed on Oct 8 by the automated PR cleanup (age/volume criteria) — not because it was rejected, superseded, or merged. As of today there is no open PR carrying the server-scoping fix, and the unscoped tab store is still present on dev (packages/tui/src/context/session-tabs.tsx persists tabs with no server identity) and still reproduces on the latest release.

    @abeisleem would you consider reopening #47999 or pushing a fresh branch? Since the cleanup criteria weigh community engagement, a 👍 on this issue and on the PR should also help the fix survive the next sweep.

  6. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the report. I reproduced this on 2.2.0, and it still happens on the latest dev (d1bd20fa).

    I ran a separate opencode serve as the remote server and launched the TUI with the same storage and from the same directory:

    1. The local TUI opened a session LOCAL-ONLY-SESSION.
    2. opencode --server http://127.0.0.1:4199 restored LOCAL-ONLY-SESSION in its tab bar, although that server has no such session.
    3. After opening REMOTE-ONLY-SESSION on the remote, the next local launch showed both tabs.

    Both layouts are written to the same tui/tabs.json with no per-server key. The selected terminal pane is saved the same way (session-terminal-selection), but I only confirmed that part from the code. The script below reproduces it. It drives the real TUI in a pty, because our recording tool can't launch the TUI with --server, so there's no video.

    Related: #18302 (the same problem in the desktop app), #37667 and #45346.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces anomalyco/opencode#47998: TUI saved tabs are shared between the
    # managed local server and a remote --server.
    # Needs: opencode (v2) on PATH, python3, curl. Set OC="bun ... packages/cli/src/index.ts" to run from source.
    set -euo pipefail
    OC=${OC:-opencode}
    W=${W:-/tmp/opencode/repro-47998}
    rm -rf "$W"; mkdir -p "$W/local" "$W/remote" "$W/proj"
    cd "$W/proj"
    
    # Minimal pty runner: runs the TUI for N seconds, then Ctrl-C; saves raw output.
    cat > "$W/pty_run.py" <<'PY'
    import os, pty, sys, time, select, fcntl, termios, struct, signal
    secs=float(sys.argv[1]); out=sys.argv[2]; cmd=sys.argv[3:]
    pid, fd = pty.fork()
    if pid == 0:
        os.environ["TERM"]="xterm-256color"; os.execvp("bash", ["bash","-c"," ".join(cmd)])
    fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH",40,140,0,0))
    buf=b""
    def pump(t):
        global buf
        end=time.time()+t
        while time.time()<end:
            r,_,_=select.select([fd],[],[],0.1)
            if r:
                try: d=os.read(fd,65536)
                except OSError: return
                if not d: return
                buf+=d
    pump(secs)
    for _ in range(3):
        try: os.write(fd,b"\x03")
        except OSError: break
        pump(1)
    try: os.kill(pid, signal.SIGTERM)
    except ProcessLookupError: pass
    pump(1); open(out,"wb").write(buf)
    PY
    tabbar() { sed 's/\x1b\[[0-9;?<>=]*[a-zA-Z]//g; s/\x1b[][^\x07]*\x07//g' "$1" | tr -s ' ' | grep -aoE "(LOCAL|REMOTE)-ONLY-SESSION.{0,40}New session" | head -1 || true; }
    
    # "Remote" server: separate HOME, so its sessions are separate.
    HOME=$W/remote $OC serve --hostname 127.0.0.1 --port 4199 > "$W/remote.log" 2>&1 &
    SERVE=$!; trap 'kill $SERVE 2>/dev/null || true; HOME=$W/local $OC service stop >/dev/null 2>&1 || true' EXIT
    sleep 4
    PW=$(grep -o 'server password .*' "$W/remote.log" | awk '{print $3}')
    
    export HOME=$W/local   # same TUI storage for both launches
    $OC service start >/dev/null
    LID=$($OC api POST /api/session -d '{"title":"LOCAL-ONLY-SESSION"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["id"])')
    RID=$(curl -s -u opencode:$PW -XPOST -H 'content-type: application/json' 127.0.0.1:4199/api/session -d '{"title":"REMOTE-ONLY-SESSION"}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["id"])')
    
    echo "== 1. local TUI opens its session =="
    python3 "$W/pty_run.py" 8 "$W/1.raw" $OC -s $LID; tabbar "$W/1.raw"
    echo "== 2. remote TUI (--server), no session given: tab bar =="
    OPENCODE_SERVER_PASSWORD=$PW python3 "$W/pty_run.py" 8 "$W/2.raw" $OC --server http://127.0.0.1:4199; tabbar "$W/2.raw"
    echo "== 3. remote TUI opens its own session =="
    OPENCODE_SERVER_PASSWORD=$PW python3 "$W/pty_run.py" 8 "$W/3.raw" $OC --server http://127.0.0.1:4199 -s $RID; tabbar "$W/3.raw"
    echo "== 4. back to local TUI: tab bar =="
    python3 "$W/pty_run.py" 8 "$W/4.raw" $OC; tabbar "$W/4.raw"
    echo "== tabs.json (no per-server key) =="
    cat "$HOME"/.local/state/opencode/*/tui/tabs.json; echo

    Run it: bash repro.sh

  7. YidaWeng commented on Oct 31, 2026

    @YidaWeng

    Thanks for the detailed report — #60887 is the same bug and I'm marking it as a duplicate there.

    I re-read the current dev implementation and the diagnosis holds:

    • packages/tui/src/context/session-tabs.tsx persists { global, cwd } and selects store.cwd[paths.cwd] / store.global without any server identity, so two servers share the same tui/tabs.json entries.
    • packages/tui/src/context/storage.tsx watches the state directory and reloads every entry, so each process observes the other server's session IDs and then rewrites the shared entry from its own view. That explains both the cross-server tabs and the duplicate/flipping titles.

    There is also prior work here: #47999 ("fix(tui): keep saved tabs separate by server") implemented exactly this — a frozen serverIdentity in context/client.tsx, per-server keys for tabs, and per-server terminal state, with tests. It was closed by the automated age/no-linked-issue cleanup, not because the approach was rejected.

    The issue is currently assigned to @simonklee, so I don't want to open a competing PR. If you are not actively working on it, I'm happy to revive #47999 rebased onto current dev with Fixes #47998, keeping the original author's work, or to help review/test a fresh implementation. Just say the word.

  8. andycostintoma commented on Nov 5, 2026

    @andycostintoma

    Adding a data point from my report (#62063): the TUI already prunes tab entries whose sessions don't exist on the connected server — after both TUIs exited, tabs.json only contained sessions belonging to that TUI's server; the dead cross-server entries were gone. The dead tabs are still visible during a running session though, and clicking one shows "session not found". So validating tabs against the connected server at load time (dropping entries whose sessionID doesn't resolve) would match the cleanup that already happens at exit, and make the strips self-heal.

    Workaround meanwhile: launch each server connection from its own dedicated directory so the cwd-keyed state never collides.

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