Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/app/src/components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -513,8 +513,10 @@ export const Terminal = (props: TerminalProps) => {
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(seek))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? "opencode"
url.password = server.current?.http.password ?? ""
if (server.current?.http.password) {
url.username = server.current.http.username ?? "opencode"
url.password = server.current.http.password
}

const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
Expand Down
37 changes: 35 additions & 2 deletions packages/app/src/context/file/tree-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,37 @@ type TreeStoreOptions = {
onError: (message: string) => void
}

// ── Concurrency limiter ──────────────────────────────────────────────
// Prevents the file tree from exhausting the browser's connection pool
// when many directories are expanded simultaneously.
const MAX_CONCURRENT = 6

function createSemaphore(limit: number) {
let active = 0
const queue: Array<() => void> = []

function release() {
active--
const next = queue.shift()
if (next) {
active++
next()
}
}

function acquire(): Promise<void> {
if (active < limit) {
active++
return Promise.resolve()
}
return new Promise<void>((resolve) => queue.push(resolve))
}

return { acquire, release }
}

const semaphore = createSemaphore(MAX_CONCURRENT)

export function createFileTreeStore(options: TreeStoreOptions) {
const [tree, setTree] = createStore<{
node: Record<string, FileNode>
Expand Down Expand Up @@ -60,8 +91,9 @@ export function createFileTreeStore(options: TreeStoreOptions) {

const directory = options.scope()

const promise = options
.list(dir)
const promise = semaphore
.acquire()
.then(() => options.list(dir))
.then((nodes) => {
if (options.scope() !== directory) return
const prevChildren = tree.dir[dir]?.children ?? []
Expand Down Expand Up @@ -120,6 +152,7 @@ export function createFileTreeStore(options: TreeStoreOptions) {
options.onError(e.message)
})
.finally(() => {
semaphore.release()
inflight.delete(dir)
})

Expand Down
23 changes: 22 additions & 1 deletion packages/app/src/context/file/watcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { describe, expect, test } from "bun:test"
import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"
import { invalidateFromWatcher } from "./watcher"

beforeEach(() => {
jest.useFakeTimers()
})

afterEach(() => {
// Flush any pending timers so module-level state is clean between tests
jest.runAllTimers()
jest.useRealTimers()
})

describe("file watcher invalidation", () => {
test("reloads open files and refreshes loaded parent on add", () => {
const loads: string[] = []
Expand All @@ -23,6 +33,7 @@ describe("file watcher invalidation", () => {
},
)

jest.advanceTimersByTime(200)
expect(loads).toEqual(["src/new.ts"])
expect(refresh).toEqual(["src"])
})
Expand Down Expand Up @@ -55,6 +66,7 @@ describe("file watcher invalidation", () => {
},
)

jest.advanceTimersByTime(200)
expect(loads).toEqual(["src/open.ts"])
})

Expand All @@ -79,6 +91,9 @@ describe("file watcher invalidation", () => {
},
)

// Flush first event before the second, since the second uses different ops
jest.advanceTimersByTime(200)

invalidateFromWatcher(
{
type: "file.watcher.updated",
Expand All @@ -103,6 +118,11 @@ describe("file watcher invalidation", () => {
},
)

// The second event targets a file (not a directory), so "change" on a file
// means node.type !== "directory" → dir is undefined → early return.
// No refreshDir should be called for the second event.
jest.advanceTimersByTime(200)

expect(refresh).toEqual(["src"])
})

Expand Down Expand Up @@ -144,6 +164,7 @@ describe("file watcher invalidation", () => {
},
)

jest.advanceTimersByTime(200)
expect(refresh).toEqual([])
})
})
46 changes: 42 additions & 4 deletions packages/app/src/context/file/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,42 @@ type WatcherOps = {
refreshDir: (path: string) => void
}

// ── Debounced watcher ────────────────────────────────────────────────
// Collect invalidation targets over a short window and flush them in a
// single batch. This avoids firing N parallel HTTP requests when an
// agent writes N files in quick succession.

const DEBOUNCE_MS = 150

let pendingFiles = new Set<string>()
let pendingDirs = new Set<string>()
let timer: ReturnType<typeof setTimeout> | undefined
let lastOps: WatcherOps | undefined

function flush() {
timer = undefined
const ops = lastOps
if (!ops) return

const files = pendingFiles
const dirs = pendingDirs
pendingFiles = new Set()
pendingDirs = new Set()

for (const file of files) {
ops.loadFile(file)
}
for (const dir of dirs) {
ops.refreshDir(dir)
}
}

function schedule(ops: WatcherOps) {
lastOps = ops
if (timer !== undefined) return
timer = setTimeout(flush, DEBOUNCE_MS)
}

export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "file.watcher.updated") return
const props =
Expand All @@ -29,7 +65,8 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (path.startsWith(".git/")) return

if (ops.hasFile(path) || ops.isOpen?.(path)) {
ops.loadFile(path)
pendingFiles.add(path)
schedule(ops)
}

if (kind === "change") {
Expand All @@ -41,13 +78,14 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
})()
if (dir === undefined) return
if (!ops.isDirLoaded(dir)) return
ops.refreshDir(dir)
pendingDirs.add(dir)
schedule(ops)
return
}
if (kind !== "add" && kind !== "unlink") return

const parent = path.split("/").slice(0, -1).join("/")
if (!ops.isDirLoaded(parent)) return

ops.refreshDir(parent)
pendingDirs.add(parent)
schedule(ops)
}
13 changes: 13 additions & 0 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ export namespace ProviderTransform {
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
}

// Anthropic rejects conversations ending with an assistant message
// ("This model does not support assistant message prefill").
// Drop trailing assistant messages so the conversation ends with a user or tool turn.
if (
model.api.npm === "@ai-sdk/anthropic" ||
model.api.npm === "@ai-sdk/google-vertex/anthropic" ||
model.api.npm === "@ai-sdk/amazon-bedrock"
) {
while (msgs.length > 0 && msgs[msgs.length - 1].role === "assistant") {
msgs = msgs.slice(0, -1)
}
}

if (model.api.id.includes("claude")) {
return msgs.map((msg) => {
if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {
Expand Down
11 changes: 9 additions & 2 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,8 +566,15 @@ export namespace Server {
})
response.headers.set(
"Content-Security-Policy",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data: https://opencode.ai",
)
// Hashed asset paths are immutable; cache them aggressively.
// Everything else (index.html, manifests) gets a short revalidation window.
if (/^\/assets\//.test(path) && /\.[a-f0-9]{8,}\./.test(path)) {
response.headers.set("Cache-Control", "public, max-age=31536000, immutable")
} else if (!response.headers.has("Cache-Control")) {
response.headers.set("Cache-Control", "public, max-age=300")
}
return response
})
}
Expand Down Expand Up @@ -601,7 +608,7 @@ export namespace Server {
const app = createApp(opts)
const args = {
hostname: opts.hostname,
idleTimeout: 0,
idleTimeout: 120,
fetch: app.fetch,
websocket: websocket,
} as const
Expand Down
Loading