Repository navigation
TUI sends absolute local paths causing ENOENT on server side #4686
Description
Description
HI
When using the OpenCode TUI on Windows connected to a remote opencode serve instance on Linux, file references using @file.py or @folder/file.py are incorrectly resolved on the Windows client instead of the server.
This happens even though the TUI is able to correctly list and browse files FROM the server using @.
So:
@shows files from server → ✔ works- Referencing the SAME file in a message → ❌ fails (path is expanded locally on Windows)
Error shown on the server
ENOENT: no such file or directory, statx '/C:/Users/myuser/myproject/module/file.py'
path: "/C:/Users/myuser/myproject/module/file.py",
syscall: "statx",
errno: -2,
code: "ENOENT"
Note: The referenced file does exist on the server at /root/myproject/module/file.py.
Expected behavior
- When connected to a remote server,
@path/to/file.pyshould be resolved server-side, using the server’s working directory and filesystem. - TUI should NOT expand paths locally on Windows (e.g. adding
C:\Users\...).
Actual behavior
The TUI:
- Lists files from the server correctly using
@(proves remote file access works). - But when referencing a file using
@file.pyin the prompt, it expands the path locally on Windows, converts it to an absolute Windows path (e.g.,C:/Users/...), and sends this invalid path to the Linux server.
Steps to reproduce
- On a Linux server:
opencode serve --hostname 0.0.0.0 --port 4700
- On a Windows machine:
opencode attach http://<vps-ip>:4700
- In TUI:
- Type
@→ browse files on server project directory → ✔ works - Select a file like
module/file.py - Submit the prompt:
@module/file.py
- Type
- Server logs:
ENOENT: no such file or directory, statx '/C:/Users/.../module/file.py'
Environment
- Server: Ubuntu 22.04, OpenCode CLI 1.0.106
- Client: Windows 11, OpenCode 1.0.97 & 1.0.106 (pre-release) via Chocolatey.
Proposed Solution / Workaround
(I developed this workaround with assistance from AI to debug the path resolution logic)
The issue lies in packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx. The submit function uses the local environment to resolve paths even when connected remotely.
I found a working fix that:
- Dynamic Root Detection: Extracts the remote server's working directory from
sync.data.session. - Path Normalization: Strips the local Windows CWD from the file path to force it to be relative.
- URL Construction: Rebuilds the
file://URL using the remote root so the Linux server can locate it.
Important Note on the Workaround:
For this dynamic fix to work effectively, the sync.data.session object must be populated with the correct remote directory. I found that currently, this requires a specific workaround: an AGENTS.md file must exist in the project directory and it must include the filePath parameter pointing to itself, for example:
<parameter name="filePath">/root/your_project/AGENTS.md</parameter>Without this specific parameter present in AGENTS.md, the remote path auto-detection often fails to retrieve the correct server-side root.
Code Changes
In packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx:
1. Add Import
import * as path from "path"2. Replace the submit function with:
async function submit() {
if (props.disabled) return
if (autocomplete.visible) return
if (!store.prompt.input) return
const sessionID = props.sessionID
? props.sessionID
: await (async () => {
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
return sessionID
})()
const messageID = Identifier.ascending("message")
let inputText = store.prompt.input
// -------------------------------------------------------------------------
// FIX: Dynamic Remote Root Detection
// Extract the server's working directory from the current or latest session.
// -------------------------------------------------------------------------
let REMOTE_ROOT = ""
if (sync.data.session && sync.data.session.length > 0) {
// Try to find the directory for the specific session we are in,
// otherwise use the most recent session's directory as the project root.
const activeSession = sync.data.session.find((s) => s.id === sessionID) || sync.data.session[0]
if (activeSession?.directory) {
REMOTE_ROOT = activeSession.directory
}
}
// -------------------------------------------------------------------------
// Expand pasted text inline before submitting
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
const sortedExtmarks = allExtmarks.sort((a: { start: number }, b: { start: number }) => b.start - a.start)
for (const extmark of sortedExtmarks) {
const partIndex = store.extmarkToPartIndex.get(extmark.id)
if (partIndex !== undefined) {
const part = store.prompt.parts[partIndex]
if (part?.type === "text" && part.text) {
const before = inputText.slice(0, extmark.start)
const after = inputText.slice(extmark.end)
inputText = before + part.text + after
}
}
}
// FIX: Normalize file paths (Windows Client -> Linux Server)
const cwd = process.cwd().replace(/\\/g, "/")
const cwdLower = cwd.toLowerCase()
const nonTextParts = store.prompt.parts
.filter((part) => part.type !== "text")
.map((part) => {
if (part.type === "file" && part.source?.path) {
let p = part.source.path.replace(/\\/g, "/")
const pLower = p.toLowerCase()
// 1. Strip local Windows CWD to get a clean relative path
if (pLower.startsWith(cwdLower) || (pLower.startsWith("/") && pLower.includes(cwdLower))) {
const idx = pLower.indexOf(cwdLower)
let rel = p.slice(idx + cwdLower.length)
rel = rel.replace(/^\/+/, "") // Remove leading slashes
// If we found a dirty absolute path, replace it in the message text
if (rel !== p) {
const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const originalPathRegex = new RegExp(escapeRegExp(part.source.path), 'gi')
const normalizedPathRegex = new RegExp(escapeRegExp(p), 'gi')
inputText = inputText.replace(originalPathRegex, rel)
inputText = inputText.replace(normalizedPathRegex, rel)
p = rel
}
} else if (path.isAbsolute(part.source.path)) {
// Fallback using path.relative logic
const rel = path.relative(process.cwd(), part.source.path).split(path.sep).join("/")
const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
inputText = inputText.replace(new RegExp(escapeRegExp(part.source.path), 'gi'), rel)
p = rel
}
// 2. Construct valid Linux Absolute URL using the Dynamic REMOTE_ROOT
const cleanRemoteRoot = REMOTE_ROOT.replace(/\/$/, "")
const cleanRelativePath = p.replace(/^\//, "")
// Combine root + relative path
let absoluteRemotePath = cleanRelativePath
if (cleanRemoteRoot) {
absoluteRemotePath = `${cleanRemoteRoot}/${cleanRelativePath}`
}
// Use 3 slashes for Linux file protocol if remote path implies root
const newUrl = `file://${absoluteRemotePath.startsWith('/') ? '' : '/'}${absoluteRemotePath}`
return {
...part,
url: newUrl,
source: {
...part.source,
path: p, // Keep path relative for display
},
}
}
return part
})
if (store.mode === "shell") {
sdk.client.session.shell({
path: {
id: sessionID,
},
body: {
agent: local.agent.current().name,
model: {
providerID: local.model.current().providerID,
modelID: local.model.current().modelID,
},
command: inputText,
},
})
setStore("mode", "normal")
} else if (
inputText.startsWith("/") &&
iife(() => {
const command = inputText.split(" ")[0].slice(1)
return sync.data.command.some((x) => x.name === command)
})
) {
let [command, ...args] = inputText.split(" ")
sdk.client.session.command({
path: {
id: sessionID,
},
body: {
command: command.slice(1),
arguments: args.join(" "),
agent: local.agent.current().name,
model: `${local.model.current().providerID}/${local.model.current().modelID}`,
messageID,
},
})
} else {
sdk.client.session.prompt({
path: {
id: sessionID,
},
body: {
...local.model.current(),
messageID,
agent: local.agent.current().name,
model: local.model.current(),
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: inputText,
},
...nonTextParts.map((x) => ({
id: Identifier.ascending("part"),
...x,
})),
],
},
})
}
history.append(store.prompt)
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
props.onSubmit?.()
// temporary hack to make sure the message is sent
if (!props.sessionID)
setTimeout(() => {
route.navigate({
type: "session",
sessionID,
})
}, 50)
input.clear()
}OpenCode version
1.0.106
Steps to reproduce
- sending @file command
Screenshot and/or share link
No response
Operating System
windows as TUI and Linux as server
Terminal
No response
Activity
github-actions commentedon Nov 24, 2025
This issue might be a duplicate of existing issues. Please check:
These issues collectively suggest there are known challenges with file path handling in cross-platform scenarios. Your issue specifically addresses the Windows-to-Linux remote scenario with @file references, which may share root causes with these related issues.
Feel free to ignore if none of these address your specific case.
rekram1-node commentedon Nov 24, 2025
yeah server should be source of truth this is a bug
oussamadouhou commentedon Dec 30, 2025
PR #6413 ensures the server uses the correct working directory via
x-opencode-directoryheader. This may help with@filereference resolution on remote servers.Worth testing with Windows client → Linux server setup once merged.
himalalps commentedon Jan 12, 2026
I am using Mac client -> Linux server, the same bug also occurs.
@shows files from server → ✔ worksERROR shown on the server:
And the referenced file does exist on the server at
/home/xxx/myproject/scripts/run.sh.joao-coli commentedon Jan 14, 2026
I'm having this issue even trying to use the linux server exclusively.
-> SSH into the sever
->
opencode server --hostname 0.0.0.0 --port XYZ-> In another tab or tmux session/window,
opencode attach localhost:XYZThen, all attempts to
@result in the same errors as others have described (and the message simply dissappears into thin air).If I run only
opencode --hostname 0.0.0.0 --port XYZand use it straight from there, no errors occur. But any attached session prevent me from linking filesthdxr commentedon Mar 25, 2026
To stay organized issues are automatically closed after 90 days of no activity. If the issue is still relevant please open a new one.