Compare commits

..

1 Commits

Author SHA1 Message Date
Dax 96902d1a10 fix(core): disable WAL on network filesystems 2026-08-18 00:25:11 +00:00
33 changed files with 362 additions and 426 deletions
+52
View File
@@ -0,0 +1,52 @@
name: generate
on:
push:
branches:
- dev
- v2
jobs:
generate:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Generate
run: ./script/generate.ts
- name: Commit and push
run: |
if [ -z "$(git status --porcelain)" ]; then
echo "No changes to commit"
exit 0
fi
git add -A
git commit -m "chore: generate" --allow-empty
git push origin HEAD:${{ github.ref_name }} --no-verify
# if ! git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify; then
# echo ""
# echo "============================================"
# echo "Failed to push generated code."
# echo "Please run locally and push:"
# echo ""
# echo " ./script/generate.ts"
# echo " git add -A && git commit -m \"chore: generate\" && git push"
# echo ""
# echo "============================================"
# exit 1
# fi
+1 -2
View File
@@ -196,7 +196,7 @@ jobs:
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
if: github.repository == 'anomalyco/opencode'
strategy:
fail-fast: false
matrix:
@@ -594,7 +594,6 @@ jobs:
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.build-node-cli.result == 'success'
with:
pattern: opencode-node-cli-*
path: packages/cli/dist/node
-204
View File
@@ -1,204 +0,0 @@
---
name: opencode-dev
description: Use when interactively running, debugging, or verifying opencode's own V2 CLI/TUI or server during development in this repo — starting the dev TUI, driving it with termctrl, comparing V2 against the legacy TUI, hitting the V2 server/API directly, reading log files, or attaching Bun's inspector.
---
# Debugging opencode itself
Workflow for interactively exercising the V2 CLI/TUI and server while developing in this repo. All commands below run from `packages/cli` unless noted otherwise.
## Server/client model
opencode V2 is a client/server system, not a single monolithic process:
- **Server process** runs the Effect HTTP API (`packages/server`) and owns all domain state: sessions, database, plugins, permissions, Location services. It's started by the `serve` command (`packages/cli/src/commands/handlers/serve.ts`).
- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via the generated SDK (`createOpencodeClient` / `sdk.client.v2`).
- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid}`. A separate password file under `~/.config/opencode/service.json` provides HTTP Basic auth. Before reusing a registration, the client calls `GET /health` to confirm the server is alive, authenticated, and version-compatible.
- **Sharing**: because of this registration/health-check dance, many concurrent `opencode`/TUI invocations converge on one shared background daemon rather than each spawning their own. If no compatible healthy daemon is found, a new one is spawned detached (`serve --service`) and registers itself.
- **`bun dev service start|status|stop|restart`** manages this shared background daemon's lifecycle directly — useful when you need to force a fresh server, confirm one is running, or kill a stuck one.
- **Standalone mode** (`--standalone`) opts a single invocation out of the shared daemon: it spawns a private one-off `serve --stdio --port 0` child tied to that invocation's lifetime, with its own random password. Use this to isolate a debugging session from your other running opencode sessions.
- Every log line is tagged `role=server` or `role=cli` and a per-process `run=<id>`, so you can distinguish server-side and client-side activity in one shared log file (see "Logs" below) even when both roles are interleaved from concurrent processes.
## Starting the dev TUI
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server.
## Interactive debugging with termctrl
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
```bash
termctrl send opencode-v2-dev 'text:example prompt' enter
termctrl send opencode-v2-dev ctrl-c
```
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
```bash
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
```
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
```bash
termctrl resize opencode-v2-dev --cols 100 --rows 30
termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
termctrl stop opencode-v2-dev
```
## Comparing V2 against the legacy TUI
Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
```
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
## Server/API debugging
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
```bash
bun dev api get /health
bun dev api get /openapi.json
bun dev api <operationId> --param key=value
```
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Auditing installed `opencode2` sessions
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
For a supplied `ses_...` ID, compare three sources:
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
- The database's ordered `event` rows for durable history.
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
Locate an uncertain database without modifying it:
```bash
SESSION=ses_...
for db in ~/.local/share/opencode/*.db; do
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
done
```
## Logs
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
- Each line is structured `key=value` text: `timestamp`, `level`, `run=<id>` (per-process run ID), `message`, and a `role=cli` or `role=server` tag. Use `run=` to isolate one process's activity and `role=` to separate client-side from server-side log lines, since a shared daemon interleaves many processes' output in one file.
- Tail the live file while reproducing an issue instead of guessing from stale output:
```bash
tail -f ~/.local/share/opencode/log/opencode-local.log
```
- Filter to one run or role when the file is noisy:
```bash
grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode-local.log
grep 'role=server' ~/.local/share/opencode/log/opencode-local.log
```
- `OPENCODE_LOG_LEVEL` controls verbosity (default `INFO`); set it before starting `bun dev` or `serve` to get `DEBUG` output for a specific repro.
- `OPENCODE_PRINT_LOGS=1` additionally tees log output to stderr of the process that emitted it, which is useful when a process fails before you'd think to check the shared log file.
- `termctrl logs <session>` surfaces stdout/stderr for a Terminal Control session specifically (e.g. inspector output or startup failures before the TUI renderer starts) — use the log file above for anything emitted by a separate server/daemon process instead.
## Heap snapshots
The CLI installs a `SIGUSR1` listener on non-Windows processes in `packages/cli/src/heap.ts`. Use it to capture the installed `opencode2` server without restarting it or attaching an inspector.
1. Find the processes and inspect their roles and memory:
```bash
pgrep -a -f 'opencode2\.exe|opencode2'
ps -o pid,ppid,rss,vsz,lstart,etime,cmd -p <pid>,<pid>
```
2. Signal the process whose heap needs investigation. For shared-service memory, target the `opencode2.exe serve --service` child, not the short wrapper/TUI process:
```bash
kill -USR1 <server-pid>
```
3. Wait for `heap snapshot written` in the channel's log before opening the file. Snapshots are written to the same log directory as `heap-<pid>-<timestamp>.heapsnapshot`; writing a large heap can take several seconds and the file is incomplete until the completion message appears:
```bash
grep 'heap snapshot' ~/.local/share/opencode/log/opencode.log | tail
find ~/.local/share/opencode/log -maxdepth 1 -name 'heap-<server-pid>-*.heapsnapshot' -printf '%T@ %s %p\n' | sort -nr | head
```
Use `opencode-local.log` instead for a local/dev channel process. The log's `path=` field is authoritative.
4. Analyze the snapshot with Chrome DevTools, a V8 heap snapshot parser, or a temporary tool installed outside the repository. Start with the largest retained objects, dominators, object counts grouped by constructor/name, and retainer paths back to GC roots. Relate suspicious names and paths back to the source tree rather than treating large shallow allocations as leaks.
For command-line analysis, install tooling under `/tmp/opencode`, not in the repository. For example, MemLab can rank dominators and trace a reported heap object ID back to a GC root:
```bash
npm install --prefix /tmp/opencode/heap-analysis @memlab/cli
/tmp/opencode/heap-analysis/node_modules/.bin/memlab analyze object-size --snapshot <snapshot>
/tmp/opencode/heap-analysis/node_modules/.bin/memlab analyze shape --snapshot <snapshot>
/tmp/opencode/heap-analysis/node_modules/.bin/memlab trace --snapshot <snapshot> --node-id=<id>
```
A single snapshot explains what retains memory at one point in time, but does not by itself prove a leak. For leak confirmation, capture a baseline, perform a controlled repeated workload, allow idle cleanup/GC when possible, capture another snapshot, and compare growth and retainer paths. Also compare snapshot heap size with process RSS: a large difference can indicate native allocations, database mappings, allocator fragmentation, or other memory outside the JavaScript heap.
```bash
cat /proc/<pid>/smaps_rollup
pmap -x <pid> | sort -k3 -nr | head -25
```
Heap serialization itself can temporarily increase RSS and allocator high-water marks, so record `ps`/`smaps_rollup` both before and after capture. Large anonymous mappings with a comparatively small live heap require native-allocation or allocator investigation; they cannot be explained from JavaScript retainer paths alone.
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until a debugger attaches.
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
## Verification
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
@@ -277,7 +277,8 @@ function ProviderConnection(props: {
})
const provider = createMemo(() => ({
id: props.provider,
name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
name:
providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
}))
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
@@ -50,8 +50,7 @@ export const DialogSelectMcp: Component = () => {
>
{(i) => {
const mcpStatus = () =>
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)
?.status
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)?.status
const status = () => mcpStatus()?.status
const statusLabel = () => {
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
@@ -90,8 +90,8 @@ export const ProjectSettingsExtensions: Component = () => {
.sort()
})
const mcpEnabled = (name: string) =>
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)
?.status.status === "connected"
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)?.status
.status === "connected"
const [globalPluginList] = createResource(
() => serverSDK.connection.status() === "connected",
@@ -196,6 +196,7 @@ export const ProjectSettingsExtensions: Component = () => {
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
</div>
</TabsV2.Content>
</TabsV2>
</div>
)
@@ -63,10 +63,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (
cmd &&
input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)
) {
if (cmd && input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)) {
setBusy()
try {
const messageID = Identifier.ascending("message")
@@ -200,7 +197,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (!sessionID) return Promise.resolve()
input.onAbort?.()
return serverSDK.api.session.interrupt({ sessionID }).catch(() => {})
return serverSDK.api.session.interrupt({ sessionID })
.catch(() => {})
}
const restoreCommentItems = (
@@ -181,7 +181,8 @@ export function SessionContextTab() {
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.tokens.reasoning) },
{
label: "context.stats.cacheTokens",
value: () => `${formatter().number(ctx()?.tokens.cache.read)} / ${formatter().number(ctx()?.tokens.cache.write)}`,
value: () =>
`${formatter().number(ctx()?.tokens.cache.read)} / ${formatter().number(ctx()?.tokens.cache.write)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
@@ -304,7 +305,9 @@ export function SessionContextTab() {
</div>
<Accordion multiple>
<For each={messages()}>
{(message) => <RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />}
{(message) => (
<RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />
)}
</For>
</Accordion>
</div>
@@ -27,16 +27,15 @@ export function StatusPopoverBody(props: { shown: boolean }) {
const toggleMcp = useMcpToggle(() => sdk().directory)
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
const mcpNames = createMemo(() =>
mcp()
.map((server) => server.name)
.sort((a, b) => a.localeCompare(b)),
)
const mcpNames = createMemo(() => mcp().map((server) => server.name).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const [pluginList] = createResource(
() => (props.shown ? sdk().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
(directory) =>
serverSDK.api.plugin
.list({ location: { directory } })
.then((result) => result.data),
)
const plugins = createMemo(() => (pluginList.latest ?? []).map((item) => item.id))
const pluginCount = createMemo(() => plugins().length)
+10 -8
View File
@@ -78,14 +78,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
scope,
normalizeDir: path.normalizeDir,
list: (dir) =>
serverSDK.api.file.list({ path: dir, location: { directory: scope() } }).then((x) =>
x.data.map((entry) => ({
...entry,
name: entry.path.split("/").at(-1) ?? entry.path,
absolute: `${scope()}/${entry.path}`,
ignored: false,
})),
),
serverSDK.api.file
.list({ path: dir, location: { directory: scope() } })
.then((x) =>
x.data.map((entry) => ({
...entry,
name: entry.path.split("/").at(-1) ?? entry.path,
absolute: `${scope()}/${entry.path}`,
ignored: false,
})),
),
onError: (message) => {
showToast({
variant: "error",
@@ -86,4 +86,5 @@ describe("query keys", () => {
{ id: "b", sandboxes: [] },
])
})
})
@@ -233,10 +233,7 @@ export function createChildStoreManager(input: {
},
get mcp() {
return Object.fromEntries(
(input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [
server.name,
server.status,
]),
(input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [server.name, server.status]),
)
},
get mcp_resource() {
@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test"
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import type {
AgentListOutput,
ModelListOutput,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
@@ -1,4 +1,8 @@
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import type {
AgentListOutput,
ModelListOutput,
ProviderListOutput,
} from "@opencode-ai/client/promise"
import type { Agent, Project, Provider, ProviderListResponse } from "@/types"
import type { Project as CurrentProject } from "@opencode-ai/client/promise"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
+7 -2
View File
@@ -13,7 +13,10 @@ export type WorkspaceLocation = LocationContext & {
const context = createSimpleContext({
name: "Location",
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
init: (props: {
directory: string | Accessor<string>
workspaceID?: string | Accessor<string | undefined>
}) => {
const serverSDK = useServerSDK()
const data = useData()
const ref = createMemo(() => ({
@@ -41,7 +44,9 @@ const context = createSimpleContext({
})
})
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
const location = createMemo(() =>
serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory),
)
return createMemo<WorkspaceLocation>(() => ({
...location(),
ref: ref(),
+1 -2
View File
@@ -26,8 +26,7 @@ export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess
} else if (server.status.status === "needs_auth" && server.integrationID) {
const integration = await serverSDK.api.integration.get({ integrationID: server.integrationID, location: ref })
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
if (!method || method.type !== "oauth")
throw new Error(`MCP server ${name} requires an interactive authentication form`)
if (!method || method.type !== "oauth") throw new Error(`MCP server ${name} requires an interactive authentication form`)
const attempt = await serverSDK.api.integration.oauth.connect({
integrationID: server.integrationID,
methodID: method.id,
+11 -2
View File
@@ -5,7 +5,12 @@ import { getOwner, onCleanup, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { type ServerSDK } from "./server-sdk"
import { bootstrapDirectory, bootstrapGlobal, loadGlobalConfigQuery, loadPathQuery } from "./global-sync/bootstrap"
import {
bootstrapDirectory,
bootstrapGlobal,
loadGlobalConfigQuery,
loadPathQuery,
} from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import type { ProjectMeta } from "./global-sync/types"
@@ -242,7 +247,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
const existing = children.children[key]
if (!existing) return
children.mark(key)
if (eventType === "config.updated" || eventType === "agent.updated") queue.push(key)
if (
eventType === "config.updated" ||
eventType === "agent.updated"
)
queue.push(key)
const [store, setStore] = existing
if (eventType === "worktree.updated") void bootstrap.refetch()
if (eventType !== "vcs.branch.updated")
@@ -57,16 +57,17 @@ export function createHomeSessionsController(home: HomeController) {
? homeSessionIndexKey(ServerConnection.key(conn))
: (["home", "session-index", "unselected"] as const),
enabled: !!ctx && ctx.sdk.connection.status() === "connected",
queryFn: ctx
? async ({ signal }) => {
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.api.session.list(input, options),
signal,
)
index.forEach(ctx.data.session.remember)
return Date.now()
}
: skipToken,
queryFn:
ctx
? async ({ signal }) => {
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.api.session.list(input, options),
signal,
)
index.forEach(ctx.data.session.remember)
return Date.now()
}
: skipToken,
retry: false,
staleTime: 30_000,
refetchOnMount: true,
@@ -15,14 +15,9 @@ export function useSessionTabAvatarState(
const ctx = serverCtx()
if (!ctx) return false
const permission = ctx.permission
return !!sessionPermissionRequest(
ctx.data.session.list(),
ctx.data.session.permission.list,
sessionId(),
(item) => {
return !permission.autoResponds(item, directory())
},
)
return !!sessionPermissionRequest(ctx.data.session.list(), ctx.data.session.permission.list, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
})
const hasQuestions = createMemo(() => {
const data = serverCtx()?.data
@@ -88,9 +88,7 @@ export function createNewSessionWorkspaceController(input: {
)
const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory)
createEffect(() => {
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(
() => undefined,
)
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(() => undefined)
const project = currentProject()
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
@@ -40,9 +40,7 @@ export function createPromptInputController(input: {
model: {
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading:
(local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) ||
!providers.ready(),
loading: (local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) || !providers.ready(),
},
session: {
id: input.sessionID(),
@@ -72,7 +72,9 @@ export function SessionComposerRegion(props: {
</div>
)}
</Show>
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
<div
class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"
>
{controller.handoffPrompt() || language.t("prompt.loading")}
</div>
</>
@@ -53,9 +53,9 @@ export function createSessionComposerController() {
if (!primary()) return []
const id = params.id
if (!id) return []
const assistant = data.session.message
.list(id)
.findLast((message) => message.type === "assistant" && message.time.completed === undefined)
const assistant = data.session.message.list(id).findLast(
(message) => message.type === "assistant" && message.time.completed === undefined,
)
if (assistant?.type !== "assistant") return []
return assistant.content.flatMap((part) => {
if (part.type !== "tool" || part.state.status !== "running") return []
@@ -149,12 +149,14 @@ export function createSessionComposerController() {
if (!primary()) return
const sessionID = params.id
if (!sessionID) return
await serverSDK.api.session.background({ sessionID }).catch((error) => {
showToast({
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
await serverSDK.api.session
.background({ sessionID })
.catch((error) => {
showToast({
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
})
})
}
const [store, setStore] = createStore({
@@ -44,6 +44,7 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
userMessages: input.session.history.userMessages,
visibleUserMessages: input.session.history.visibleUserMessages,
}
}
export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
+1
View File
@@ -89,6 +89,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
: `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`),
wal: process.env.OPENCODE_DB_WAL === undefined ? undefined : truthy(process.env.OPENCODE_DB_WAL),
},
models: {
url: process.env.OPENCODE_MODELS_URL,
+3 -3
View File
@@ -18,6 +18,7 @@ export interface Interface {
export const Options = Schema.Struct({
path: Schema.optional(Schema.String),
wal: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
@@ -29,11 +30,9 @@ const databaseLayer = Layer.effect(
const db = yield* makeDatabase
if (supportsTuningPragmas) {
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
}
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
@@ -46,7 +45,8 @@ const databaseLayer = Layer.effect(
export function layer(options: Options = { path: ":memory:" }) {
return Layer.unwrap(
Effect.gen(function* () {
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
const provide = (filename: string) =>
layerFromClient.pipe(Layer.provide(sqliteLayer({ filename, wal: options.wal })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
@@ -0,0 +1,17 @@
import { statfsSync } from "node:fs"
const NETWORK_FILESYSTEM_TYPES = new Set([
0x0000517b, // SMB
0x01021997, // 9P
0x65735546, // FUSE (including VirtioFS and SSHFS)
0x00006969, // NFS
0xff534d42, // CIFS
])
export function isNetworkFilesystemType(type: number) {
return NETWORK_FILESYSTEM_TYPES.has(type >>> 0)
}
export function isNetworkFilesystem(filename: string) {
return isNetworkFilesystemType(statfsSync(filename).type)
}
+8 -2
View File
@@ -3,6 +3,7 @@ import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import { isNetworkFilesystem } from "./network-filesystem.js"
import { Sqlite } from "./sqlite.js"
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
@@ -17,7 +18,7 @@ interface Config extends Sqlite.ClientConfig {
readonly readonly?: boolean
readonly create?: boolean
readonly readwrite?: boolean
readonly disableWAL?: boolean
readonly wal?: boolean
}
const make = (options: Config) =>
@@ -90,7 +91,12 @@ const nativeLayer = (config: Config) =>
create: config.create ?? true,
})
yield* Effect.addFinalizer(() => Effect.sync(() => native.close()))
if (config.disableWAL !== true) native.run("PRAGMA journal_mode = WAL;")
const wal = config.filename !== ":memory:" && (config.wal ?? !isNetworkFilesystem(config.filename))
if (wal) {
native.run("PRAGMA journal_mode = WAL;")
native.run("PRAGMA wal_checkpoint(PASSIVE);")
}
if (!wal && config.filename !== ":memory:") native.run("PRAGMA journal_mode = DELETE;")
return native
}),
)
+9 -2
View File
@@ -3,6 +3,7 @@ import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import { isNetworkFilesystem } from "./network-filesystem.js"
import { Sqlite } from "./sqlite.js"
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
@@ -17,7 +18,7 @@ interface Config extends Sqlite.ClientConfig {
readonly readonly?: boolean
readonly create?: boolean
readonly readwrite?: boolean
readonly disableWAL?: boolean
readonly wal?: boolean
readonly timeout?: number
readonly allowExtension?: boolean
}
@@ -87,7 +88,13 @@ const nativeLayer = (config: Config) =>
open: true,
})
yield* Effect.addFinalizer(() => Effect.sync(() => native.close()))
if (config.disableWAL !== true && config.readonly !== true) native.exec("PRAGMA journal_mode = WAL;")
const wal = config.filename !== ":memory:" && (config.wal ?? !isNetworkFilesystem(config.filename))
if (wal && config.readonly !== true) {
native.exec("PRAGMA journal_mode = WAL;")
native.exec("PRAGMA wal_checkpoint(PASSIVE);")
}
if (!wal && config.filename !== ":memory:" && config.readonly !== true)
native.exec("PRAGMA journal_mode = DELETE;")
return native
}),
)
@@ -0,0 +1,42 @@
import { expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Database } from "@opencode-ai/core/database/database"
import { isNetworkFilesystemType } from "@opencode-ai/core/database/network-filesystem"
import { Global } from "@opencode-ai/util/global"
import { sql } from "drizzle-orm"
import { Effect } from "effect"
test.each([
["SMB", 0x0000517b],
["9P", 0x01021997],
["FUSE", 0x65735546],
["NFS", 0x00006969],
["CIFS", 0xff534d42],
])("disables WAL on %s", (_name, type) => {
expect(isNetworkFilesystemType(type)).toBe(true)
})
test("keeps WAL on local filesystems", () => {
expect(isNetworkFilesystemType(0xef53)).toBe(false)
})
test("allows WAL to be disabled explicitly", async () => {
const directory = await mkdtemp(join(tmpdir(), "opencode-database-"))
try {
const mode = await Effect.runPromise(
Effect.gen(function* () {
const database = yield* Database.Service
return yield* database.db.all<{ journal_mode: string }>(sql`PRAGMA journal_mode`)
}).pipe(
Effect.provide(Database.layer({ path: join(directory, "opencode.db"), wal: false })),
Effect.provideService(Global.Service, Global.make({ data: directory })),
Effect.scoped,
),
)
expect(mode).toEqual([{ journal_mode: "delete" }])
} finally {
await rm(directory, { recursive: true, force: true })
}
})
+115 -119
View File
@@ -220,133 +220,129 @@ const setup = Effect.gen(function* () {
return { db, bus, instructions: yield* instructionBuiltIns.load(sessionID) }
})
it.effect(
"generates from fresh settled Session context without durable mutation",
() =>
Effect.gen(function* () {
requests.length = 0
hasHttpMiddleware = false
instruction = "Initial context"
const { db, bus, instructions } = yield* setup
yield* InstructionState.prepare(db, bus, instructions, sessionID)
const existing = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.InboxEnqueued, {
sessionID,
inboxID: existing,
item: { type: "user", payload: { text: "Existing durable context" }, delivery: "steer" },
})
yield* bus.publish(SessionEvent.InboxDelivered, {
sessionID,
inboxID: existing,
})
const settledAssistant = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: settledAssistant,
agent: Agent.ID.make("build"),
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
})
yield* bus.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: settledAssistant,
ordinal: 0,
})
yield* bus.publish(SessionEvent.Text.Ended, {
sessionID,
assistantMessageID: settledAssistant,
ordinal: 0,
text: "Settled partial answer",
})
const activeAssistant = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: activeAssistant,
agent: Agent.ID.make("build"),
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
})
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID: activeAssistant,
id: "active-call",
name: "echo",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID: activeAssistant,
id: "active-call",
text: "{}",
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID: activeAssistant,
id: "active-call",
input: {},
executed: false,
})
yield* bus.publish(SessionEvent.InboxEnqueued, {
sessionID,
inboxID: SessionMessage.ID.create(),
item: { type: "user", payload: { text: "Queued input must remain invisible" }, delivery: "queue" },
})
instruction = "Changed context"
const before = yield* durableState(db, sessionID)
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system"), ...event.system]
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
}),
)
it.effect("generates from fresh settled Session context without durable mutation", () =>
Effect.gen(function* () {
requests.length = 0
hasHttpMiddleware = false
instruction = "Initial context"
const { db, bus, instructions } = yield* setup
yield* InstructionState.prepare(db, bus, instructions, sessionID)
const existing = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.InboxEnqueued, {
sessionID,
inboxID: existing,
item: { type: "user", payload: { text: "Existing durable context" }, delivery: "steer" },
})
yield* bus.publish(SessionEvent.InboxDelivered, {
sessionID,
inboxID: existing,
})
const settledAssistant = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: settledAssistant,
agent: Agent.ID.make("build"),
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
})
yield* bus.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: settledAssistant,
ordinal: 0,
})
yield* bus.publish(SessionEvent.Text.Ended, {
sessionID,
assistantMessageID: settledAssistant,
ordinal: 0,
text: "Settled partial answer",
})
const activeAssistant = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: activeAssistant,
agent: Agent.ID.make("build"),
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
})
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
assistantMessageID: activeAssistant,
id: "active-call",
name: "echo",
})
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID,
assistantMessageID: activeAssistant,
id: "active-call",
text: "{}",
})
yield* bus.publish(SessionEvent.Tool.Called, {
sessionID,
assistantMessageID: activeAssistant,
id: "active-call",
input: {},
executed: false,
})
yield* bus.publish(SessionEvent.InboxEnqueued, {
sessionID,
inboxID: SessionMessage.ID.create(),
item: { type: "user", payload: { text: "Queued input must remain invisible" }, delivery: "queue" },
})
instruction = "Changed context"
const before = yield* durableState(db, sessionID)
const hooks = yield* PluginHooks.Service
yield* hooks.register("session", "context", (event) =>
Effect.sync(() => {
event.system = [SystemPart.make("Hooked system"), ...event.system]
if (event.tools.lookup) event.tools.lookup.description = "Hooked lookup"
}),
)
const generate = yield* SessionGenerate.Service
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
const generate = yield* SessionGenerate.Service
const result = yield* generate.generate({ sessionID, prompt: "Summarize privately" })
expect(result).toBe("Transient answer")
expect(requests).toHaveLength(1)
expect(hasHttpMiddleware).toBe(true)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID })
expect(requests[0]?.promptCacheKey).toBe(sessionID)
const instructionUpdates = requests[0]?.messages.flatMap((message) =>
message.role === "system"
expect(result).toBe("Transient answer")
expect(requests).toHaveLength(1)
expect(hasHttpMiddleware).toBe(true)
expect(requests[0]?.model).toBe(model)
expect(requests[0]?.system[0]?.text).toBe("Hooked system")
expect(requests[0]?.system.map((part) => part.text)).toContain("Initial context")
expect(requests[0]?.http?.headers).toMatchObject({ "X-Session-Id": sessionID })
expect(requests[0]?.promptCacheKey).toBe(sessionID)
const instructionUpdates = requests[0]?.messages.flatMap((message) =>
message.role === "system"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
)
expect(instructionUpdates).toHaveLength(1)
expect(instructionUpdates?.[0]).toContain("Changed context")
expect(instructionUpdates?.[0]).toContain("tools.captured.lookup(input: {}): Promise<string>")
expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
expect(
requests[0]?.messages.flatMap((message) =>
message.role === "assistant"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
)
expect(instructionUpdates).toHaveLength(1)
expect(instructionUpdates?.[0]).toContain("Changed context")
expect(instructionUpdates?.[0]).toContain("tools.captured.lookup(input: {}): Promise<string>")
expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
expect(
requests[0]?.messages.flatMap((message) =>
message.role === "assistant"
? message.content.flatMap((content) => (content.type === "text" ? [content.text] : []))
: [],
),
).toEqual(["Settled partial answer"])
expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }])
expect(requests[0]?.toolChoice).toBeUndefined()
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
),
).toEqual(["Settled partial answer"])
expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }])
expect(requests[0]?.toolChoice).toBeUndefined()
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
{ timeout: 15_000 },
)
it.effect(
"blocks unavailable initial instructions before generation",
() =>
Effect.gen(function* () {
requests.length = 0
instruction = Instructions.unavailable
const { db } = yield* setup
const before = yield* durableState(db, sessionID)
const generate = yield* SessionGenerate.Service
it.effect("blocks unavailable initial instructions before generation", () =>
Effect.gen(function* () {
requests.length = 0
instruction = Instructions.unavailable
const { db } = yield* setup
const before = yield* durableState(db, sessionID)
const generate = yield* SessionGenerate.Service
const error = yield* generate.generate({ sessionID, prompt: "Summarize privately" }).pipe(Effect.flip)
const error = yield* generate.generate({ sessionID, prompt: "Summarize privately" }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
expect(requests).toEqual([])
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
expect(error).toBeInstanceOf(Instructions.InitializationBlocked)
expect(requests).toEqual([])
expect(yield* durableState(db, sessionID)).toEqual(before)
}),
{ timeout: 15_000 },
)
+21 -23
View File
@@ -180,30 +180,28 @@ describeHg("Vcs mercurial", () => {
),
)
it.live(
"diffs a named branch against the default branch",
() =>
withHg((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
expect(yield* vcs.diff("branch")).toEqual([])
it.live("diffs a named branch against the default branch", () =>
withHg((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
const vcs = yield* Vcs.Service
expect(yield* vcs.diff("branch")).toEqual([])
yield* Effect.promise(async () => {
await hg(directory, "branch", "-q", "feature")
await fs.writeFile(path.join(directory, "file.txt"), "one\ntwo\n")
await commitAll(directory, "feature change")
})
const diff = yield* vcs.diff("branch")
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
{ file: "file.txt", status: "modified" },
])
expect(diff[0].patch).toContain("+two")
}),
),
yield* Effect.promise(async () => {
await hg(directory, "branch", "-q", "feature")
await fs.writeFile(path.join(directory, "file.txt"), "one\ntwo\n")
await commitAll(directory, "feature change")
})
const diff = yield* vcs.diff("branch")
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
{ file: "file.txt", status: "modified" },
])
expect(diff[0].patch).toContain("+two")
}),
),
15_000,
)
})
@@ -99,6 +99,9 @@ The database normally lives at:
`OPENCODE_DB` can override the database location.
OpenCode disables SQLite WAL mode when it detects NFS or another shared network filesystem. Set
`OPENCODE_DB_WAL=true` to force WAL mode or `OPENCODE_DB_WAL=false` to disable it explicitly.
<Callout type="warning">
Do not delete or edit service files or the database while troubleshooting. Use the service commands to manage the
daemon, and make a backup before inspecting persistent data with external tools.