diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a97fd8165..e4686e2b1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -39,8 +39,19 @@ jobs:
with:
targets: ${{ matrix.target }}
- - name: Install protoc
- run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update && sudo apt-get install -y protobuf-compiler
+ curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -
+ sudo apt-get install -y nodejs
+
+ - name: Install bun
+ uses: oven-sh/setup-bun@v2
+
+ - name: Build OpenCode embed
+ run: |
+ cd interface && bun install --frozen-lockfile && cd ..
+ ./scripts/build-opencode-embed.sh
- name: Determine version tag
id: version
diff --git a/.gitignore b/.gitignore
index 03ebf30f5..d76b4bc46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,8 @@
# Interface
interface/node_modules/
interface/dist/
+interface/public/opencode-embed/
+.opencode-build-cache/
.idea
list/
diff --git a/Dockerfile b/Dockerfile
index c90848f92..079a8a01e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,6 +16,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
+# Node 22+ is required for the OpenCode embed Vite build.
+RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
+ && apt-get install -y --no-install-recommends nodejs \
+ && rm -rf /var/lib/apt/lists/*
+
WORKDIR /build
# 1. Fetch and cache Rust dependencies.
@@ -26,14 +31,24 @@ RUN mkdir src && echo "fn main() {}" > src/main.rs && touch src/lib.rs \
&& cargo build --release \
&& rm -rf src
-# 2. Build the frontend.
+# 2. Install frontend dependencies.
COPY interface/package.json interface/
RUN cd interface && bun install
+
+# 3. Build the OpenCode embed bundle (live coding UI in Workers tab).
+# Must run before the frontend build so the embed assets in
+# interface/public/opencode-embed/ are included in the Vite output.
+COPY scripts/build-opencode-embed.sh scripts/
+COPY interface/opencode-embed-src/ interface/opencode-embed-src/
+RUN ./scripts/build-opencode-embed.sh
+
+# 4. Build the frontend (includes OpenCode embed assets from step 3).
COPY interface/ interface/
RUN cd interface && bun run build
-# 3. Copy source and compile the real binary.
-# build.rs runs the frontend build (already done above, node_modules present).
+# 5. Copy source and compile the real binary.
+# build.rs is skipped (SPACEBOT_SKIP_FRONTEND_BUILD=1) since the
+# frontend is already built above with the OpenCode embed included.
# prompts/ is needed for include_str! in src/prompts/text.rs.
# migrations/ is needed for sqlx::migrate! in src/db.rs.
# docs/ is needed for rust-embed in src/self_awareness.rs.
diff --git a/README.md b/README.md
index eb7087898..0c17bb3ac 100644
--- a/README.md
+++ b/README.md
@@ -424,6 +424,11 @@ Read the full vision in the [roadmap](docs/content/docs/(deployment)/roadmap.mdx
```bash
git clone https://github.com/spacedriveapp/spacebot
cd spacebot
+
+# Optional: build the OpenCode embedded UI (requires Node 22+ and bun)
+# Without this, OpenCode workers still work — the Workers tab shows a transcript view instead.
+# ./scripts/build-opencode-embed.sh
+
cargo build --release
```
diff --git a/docs/content/docs/(features)/opencode.mdx b/docs/content/docs/(features)/opencode.mdx
index 00f9403e9..1e9d042b3 100644
--- a/docs/content/docs/(features)/opencode.mdx
+++ b/docs/content/docs/(features)/opencode.mdx
@@ -176,3 +176,39 @@ webfetch = "allow"
```
The OpenCode server is a child process managed by Spacebot. It persists across worker invocations for the same directory. Multiple workers targeting the same directory share the same server (different sessions).
+
+## Embedded Web UI
+
+When you view an OpenCode worker in the Workers tab, Spacebot can show the full interactive OpenCode interface inline — the same editor, terminal, and conversation view you'd get from OpenCode's standalone app.
+
+The embedded UI uses a Shadow DOM for CSS isolation and a memory router to avoid conflicts with Spacebot's own routing. All API and SSE traffic is proxied through Spacebot's reverse proxy at `/api/opencode/{port}/`, keeping everything same-origin.
+
+### Building the embed
+
+The embed bundle is not included in the repository. Build it with:
+
+```bash
+# Requires Node 22+ and bun
+./scripts/build-opencode-embed.sh
+# or: just build-opencode-embed
+```
+
+This clones OpenCode at a pinned upstream commit, copies the embed entry points from `interface/opencode-embed-src/`, builds with Vite, and outputs to `interface/public/opencode-embed/`. The output is ~46MB (mostly shiki grammar chunks for syntax highlighting) and is gitignored.
+
+If you use [fnm](https://github.com/Schniz/fnm) for Node version management:
+
+```bash
+eval "$(fnm env)" && fnm use v24.14.0 && ./scripts/build-opencode-embed.sh
+```
+
+### Without the embed
+
+Everything works without building the embed. OpenCode workers still run normally — the Workers tab falls back to the transcript view, which shows tool calls, status updates, and results. The only difference is you don't get the live interactive OpenCode UI.
+
+### Updating the pinned commit
+
+The OpenCode commit is pinned in `scripts/build-opencode-embed.sh` as `OPENCODE_COMMIT`. To update:
+
+1. Change the commit hash in the script
+2. Re-run `./scripts/build-opencode-embed.sh`
+3. Test the embedded UI in the Workers tab
diff --git a/docs/content/docs/(getting-started)/quickstart.mdx b/docs/content/docs/(getting-started)/quickstart.mdx
index 3c7c55319..daa4718a2 100644
--- a/docs/content/docs/(getting-started)/quickstart.mdx
+++ b/docs/content/docs/(getting-started)/quickstart.mdx
@@ -58,12 +58,18 @@ cd spacebot
# Optional: build the web UI (React + Vite, embedded into the binary)
cd interface && bun install && cd ..
+# Optional: build the OpenCode embed (live coding UI in the Workers tab)
+# Requires Node 22+ (use fnm: fnm install v24.14.0 && fnm use v24.14.0)
+./scripts/build-opencode-embed.sh
+
# Install the binary
cargo install --path .
```
The `build.rs` script automatically runs `bun run build` during compilation if `interface/node_modules` exists. Without it, the binary still works — you just get an empty UI on the web dashboard.
+The OpenCode embed step (`build-opencode-embed.sh`) clones OpenCode at a pinned commit, builds the embeddable SPA, and places it in `interface/public/opencode-embed/`. This is optional — without it, OpenCode workers still function normally, but the Workers tab will show a transcript view instead of the live interactive OpenCode UI.
+
## Configure
Spacebot needs at least one LLM provider key. You can either set an environment variable or create a config file.
diff --git a/interface/opencode-embed-src/embed-entry.tsx b/interface/opencode-embed-src/embed-entry.tsx
new file mode 100644
index 000000000..39eb80755
--- /dev/null
+++ b/interface/opencode-embed-src/embed-entry.tsx
@@ -0,0 +1,16 @@
+/**
+ * SPA entry point for the embeddable OpenCode build.
+ *
+ * Unlike entry.tsx, this does NOT auto-render. Instead it attaches
+ * `mountOpenCode` to the window object so the host app can call it
+ * after loading this script.
+ *
+ * This file is used as the entry in index-embed.html for a normal
+ * Vite SPA build (not library mode), so we get full code splitting.
+ */
+
+import { mountOpenCode } from "./embed"
+export type { MountOpenCodeConfig, MountOpenCodeHandle } from "./embed"
+
+// Attach to window so the host app can call it after script load.
+;(window as any).__opencode_embed__ = { mountOpenCode }
diff --git a/interface/opencode-embed-src/embed.tsx b/interface/opencode-embed-src/embed.tsx
new file mode 100644
index 000000000..73413ccfe
--- /dev/null
+++ b/interface/opencode-embed-src/embed.tsx
@@ -0,0 +1,282 @@
+/**
+ * Embeddable entry point for the OpenCode app.
+ *
+ * Exports a `mountOpenCode` function that renders the full OpenCode SPA
+ * into an arbitrary DOM element using a MemoryRouter (no window.history
+ * interference). Designed to be consumed by host apps (e.g. Spacebot)
+ * that already have their own router.
+ *
+ * Unlike entry.tsx, this module has NO top-level side effects — it only
+ * executes when `mountOpenCode` is called.
+ */
+
+import "@/index.css"
+import { File } from "@opencode-ai/ui/file"
+import { I18nProvider } from "@opencode-ai/ui/context"
+import { DialogProvider } from "@opencode-ai/ui/context/dialog"
+import { FileComponentProvider } from "@opencode-ai/ui/context/file"
+import { MarkedProvider } from "@opencode-ai/ui/context/marked"
+import { Font } from "@opencode-ai/ui/font"
+import { ThemeProvider, useTheme, type DesktopTheme, type ColorScheme } from "@opencode-ai/ui/theme"
+import { MetaProvider } from "@solidjs/meta"
+import { MemoryRouter, Route, createMemoryHistory } from "@solidjs/router"
+import { ErrorBoundary, lazy, onMount, type ParentProps, Show, Suspense } from "solid-js"
+import { render } from "solid-js/web"
+import spacebotTheme from "./spacebot-theme.json"
+import { CommandProvider } from "@/context/command"
+import { CommentsProvider } from "@/context/comments"
+import { FileProvider } from "@/context/file"
+import { GlobalSDKProvider } from "@/context/global-sdk"
+import { GlobalSyncProvider } from "@/context/global-sync"
+import { HighlightsProvider } from "@/context/highlights"
+import { LanguageProvider, useLanguage } from "@/context/language"
+import { LayoutProvider } from "@/context/layout"
+import { ModelsProvider } from "@/context/models"
+import { NotificationProvider } from "@/context/notification"
+import { PermissionProvider } from "@/context/permission"
+import { type Platform, PlatformProvider, usePlatform } from "@/context/platform"
+import { PromptProvider } from "@/context/prompt"
+import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
+import { SettingsProvider } from "@/context/settings"
+import { TerminalProvider } from "@/context/terminal"
+import DirectoryLayout from "@/pages/directory-layout"
+import Layout from "@/pages/layout"
+import { ErrorPage } from "./pages/error"
+
+const Home = lazy(() => import("@/pages/home"))
+const Session = lazy(() => import("@/pages/session"))
+const Loading = () =>
+
+const HomeRoute = () => (
+ }>
+
+
+)
+
+const SessionRoute = () => (
+
+ }>
+
+
+
+)
+
+function UiI18nBridge(props: ParentProps) {
+ const language = useLanguage()
+ return {props.children}
+}
+
+function MarkedProviderWithNativeParser(props: ParentProps) {
+ const platform = usePlatform()
+ return {props.children}
+}
+
+function AppShellProviders(props: ParentProps) {
+ return (
+
+
+
+
+
+
+
+ {props.children}
+
+
+
+
+
+
+
+ )
+}
+
+function SessionProviders(props: ParentProps) {
+ return (
+
+
+
+ {props.children}
+
+
+
+ )
+}
+
+function RouterRoot(props: ParentProps) {
+ return {props.children}
+}
+
+function ServerKey(props: ParentProps) {
+ const server = useServer()
+ return (
+
+ {props.children}
+
+ )
+}
+
+/**
+ * Registers and activates a custom theme + color scheme inside the
+ * ThemeProvider. Runs once on mount — theme changes propagate
+ * reactively through OpenCode's own effect in the ThemeProvider.
+ */
+function ThemeInjector(props: ParentProps & { theme?: DesktopTheme; colorScheme?: ColorScheme }) {
+ const ctx = useTheme()
+ onMount(() => {
+ const theme = props.theme
+ if (theme) {
+ ctx.registerTheme(theme)
+ ctx.setTheme(theme.id)
+ }
+ if (props.colorScheme) {
+ ctx.setColorScheme(props.colorScheme)
+ }
+ })
+ return <>{props.children}>
+}
+
+// ---------------------------------------------------------------------------
+// Public API
+// ---------------------------------------------------------------------------
+
+export type MountOpenCodeConfig = {
+ /** URL of the OpenCode server, e.g. "http://127.0.0.1:12345" */
+ serverUrl: string
+
+ /**
+ * Initial route to navigate to inside the embedded app.
+ * e.g. "//session/"
+ * Defaults to "/" (home / project picker).
+ */
+ initialRoute?: string
+
+ /**
+ * Custom theme to register and activate. If omitted, the built-in
+ * Spacebot theme is used. Pass `null` to skip theme injection
+ * entirely and use OpenCode's default theme.
+ */
+ theme?: DesktopTheme | null
+
+ /**
+ * Force a color scheme. Defaults to "dark" to match Spacebot's UI.
+ * Pass "system" to respect the user's OS preference.
+ */
+ colorScheme?: ColorScheme
+}
+
+export type MountOpenCodeHandle = {
+ /** Tear down the SolidJS app and remove all DOM nodes. */
+ dispose: () => void
+
+ /**
+ * Navigate the embedded app to a new route.
+ * e.g. handle.navigate("//session/")
+ */
+ navigate: (route: string) => void
+}
+
+/**
+ * Mount the OpenCode SPA into a DOM element.
+ *
+ * - Uses MemoryRouter so it never touches window.history / window.location.
+ * - The caller is responsible for providing a container element (can be inside
+ * a Shadow DOM for CSS isolation).
+ * - Returns a handle with `dispose()` for cleanup and `navigate()` for
+ * programmatic route changes.
+ */
+export function mountOpenCode(
+ container: HTMLElement,
+ config: MountOpenCodeConfig,
+): MountOpenCodeHandle {
+ const { serverUrl, initialRoute = "/", colorScheme = "dark" } = config
+ // Resolve theme: undefined → default Spacebot theme, null → no injection
+ const theme = config.theme === undefined
+ ? (spacebotTheme as DesktopTheme)
+ : config.theme ?? undefined
+
+ // Create an in-memory history that never touches the real URL bar.
+ const memory = createMemoryHistory()
+ // Set the initial route before render so the router starts there.
+ memory.set({ value: initialRoute })
+
+ const platform: Platform = {
+ platform: "web",
+ version: "embed",
+ openLink: (url) => window.open(url, "_blank", "noopener,noreferrer"),
+ back: () => memory.go(-1),
+ forward: () => memory.go(1),
+ restart: async () => {
+ // No-op in embedded mode — the host app controls lifecycle.
+ },
+ notify: async () => {
+ // Notifications don't make sense in embedded mode.
+ },
+ // Don't let the embedded app read/write the host's localStorage
+ // for defaultServerUrl — we control the server URL via config.
+ getDefaultServerUrl: async () => serverUrl,
+ setDefaultServerUrl: () => {},
+ }
+
+ const server: ServerConnection.Http = {
+ type: "http",
+ http: { url: serverUrl },
+ }
+ // Inline ServerConnection.key() to avoid namespace bundling issues.
+ // ServerConnection.Key.make is just a branded string cast.
+ const serverKey = serverUrl as ServerConnection.Key
+
+ const dispose = render(
+ () => (
+
+
+
+
+
+
+
+ }>
+
+
+
+
+
+
+
+ (
+ {routerProps.children}
+ )}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ container,
+ )
+
+ return {
+ dispose,
+ navigate: (route: string) => {
+ memory.set({ value: route })
+ },
+ }
+}
diff --git a/interface/opencode-embed-src/index-embed.html b/interface/opencode-embed-src/index-embed.html
new file mode 100644
index 000000000..2519bb32d
--- /dev/null
+++ b/interface/opencode-embed-src/index-embed.html
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/interface/opencode-embed-src/spacebot-theme.json b/interface/opencode-embed-src/spacebot-theme.json
new file mode 100644
index 000000000..6dc5be2d2
--- /dev/null
+++ b/interface/opencode-embed-src/spacebot-theme.json
@@ -0,0 +1,147 @@
+{
+ "$schema": "https://opencode.ai/desktop-theme.json",
+ "name": "Spacebot",
+ "id": "spacebot",
+ "light": {
+ "seeds": {
+ "neutral": "#f5f3f7",
+ "primary": "#a54de8",
+ "success": "#3dba72",
+ "warning": "#d4993f",
+ "error": "#d44848",
+ "info": "#5096d4",
+ "interactive": "#a54de8",
+ "diffAdd": "#a8e0c0",
+ "diffDelete": "#f0b0b0"
+ },
+ "overrides": {
+ "background-base": "#f5f3f7",
+ "background-weak": "#eeedf2",
+ "background-strong": "#faf9fc",
+ "background-stronger": "#fdfcfe",
+ "text-base": "#1a1a20",
+ "text-weak": "#4a4a56",
+ "text-strong": "#0c0c0e",
+ "surface-base": "#faf9fc",
+ "surface-raised-base": "#ffffff",
+ "surface-weak": "#eeedf2",
+ "input-base": "#ffffff",
+ "border-weak-base": "#dddbe6",
+ "border-base": "#b5b3c2",
+ "border-strong-base": "#7a788a",
+ "syntax-string": "#3dba72",
+ "syntax-primitive": "#d44848",
+ "syntax-property": "#a54de8",
+ "syntax-type": "#d4993f",
+ "syntax-constant": "#5096d4",
+ "markdown-heading": "#a54de8",
+ "markdown-text": "#1a1a20",
+ "markdown-code": "#3dba72",
+ "markdown-strong": "#a54de8",
+ "markdown-link": "#a54de8",
+ "markdown-link-text": "#a54de8"
+ }
+ },
+ "dark": {
+ "seeds": {
+ "neutral": "#0c0c0e",
+ "primary": "#a54de8",
+ "success": "#5cf0b0",
+ "warning": "#f0c070",
+ "error": "#f06060",
+ "info": "#70d0f0",
+ "interactive": "#a54de8",
+ "diffAdd": "#5cf0b0",
+ "diffDelete": "#f06060"
+ },
+ "overrides": {
+ "background-base": "#0c0c0e",
+ "background-weak": "#0e0e11",
+ "background-strong": "#0a0a0c",
+ "background-stronger": "#080809",
+ "surface-base": "#131317",
+ "base": "#131317",
+ "surface-base-hover": "#18181d",
+ "surface-raised-base": "#0e0e11",
+ "surface-raised-base-hover": "#131317",
+ "surface-raised-strong": "#1a1a1f",
+ "surface-raised-stronger": "#222228",
+ "surface-weak": "#111114",
+ "surface-weaker": "#0e0e11",
+ "surface-float-base": "#0a0a0c",
+ "surface-inset-base": "#0a0a0c",
+ "input-base": "#111114",
+ "input-hover": "#131317",
+ "input-active": "#0e0e11",
+ "input-selected": "#2a1a4a",
+ "text-base": "#eeeff3",
+ "text-weak": "#6d6e74",
+ "text-weaker": "#55565c",
+ "text-strong": "#ffffff",
+ "text-interactive-base": "#c080f0",
+ "icon-base": "#6d6e74",
+ "icon-weak-base": "#3a3a42",
+ "icon-strong-base": "#eeeff3",
+ "icon-interactive-base": "#a54de8",
+ "border-weak-base": "#222228",
+ "border-weak-hover": "#2a2a32",
+ "border-weak-active": "#32323c",
+ "border-weak-selected": "#3a3a46",
+ "border-weak-disabled": "#141418",
+ "border-weak-focus": "#2e2e38",
+ "border-base": "#3a3a46",
+ "border-hover": "#444450",
+ "border-active": "#4e4e5a",
+ "border-selected": "#585864",
+ "border-disabled": "#1a1a1f",
+ "border-focus": "#484854",
+ "border-strong-base": "#585864",
+ "border-strong-hover": "#62626e",
+ "border-strong-active": "#6c6c78",
+ "border-strong-selected": "#767682",
+ "border-strong-disabled": "#28282e",
+ "border-strong-focus": "#666672",
+ "border-weaker-base": "#141418",
+ "border-weaker-hover": "#1a1a1f",
+ "border-weaker-active": "#222228",
+ "surface-interactive-base": "#1e0e3a",
+ "surface-interactive-hover": "#2a1a4a",
+ "surface-interactive-weak": "#140a28",
+ "surface-brand-base": "#a54de8",
+ "surface-brand-hover": "#9340d4",
+ "surface-diff-add-base": "#0e2018",
+ "surface-diff-delete-base": "#200e10",
+ "surface-diff-hidden-base": "#18181f",
+ "button-secondary-base": "#1a1a1f",
+ "button-secondary-hover": "#222228",
+ "button-ghost-hover": "#131317",
+ "button-ghost-hover2": "#18181d",
+ "syntax-comment": "var(--text-weak)",
+ "syntax-string": "#5cf0b0",
+ "syntax-primitive": "#f06060",
+ "syntax-property": "#c080f0",
+ "syntax-type": "#f0c070",
+ "syntax-constant": "#70d0f0",
+ "syntax-keyword": "var(--text-weak)",
+ "syntax-operator": "var(--text-weak)",
+ "syntax-variable": "var(--text-strong)",
+ "syntax-object": "var(--text-strong)",
+ "syntax-punctuation": "var(--text-weak)",
+ "syntax-info": "#70d0f0",
+ "markdown-heading": "#c080f0",
+ "markdown-text": "#eeeff3",
+ "markdown-link": "#c080f0",
+ "markdown-link-text": "#c080f0",
+ "markdown-code": "#5cf0b0",
+ "markdown-block-quote": "#6d6e74",
+ "markdown-emph": "#f0c070",
+ "markdown-strong": "#c080f0",
+ "markdown-horizontal-rule": "#222228",
+ "markdown-list-item": "#c080f0",
+ "markdown-list-enumeration": "#c080f0",
+ "markdown-image": "#c080f0",
+ "markdown-image-text": "#c080f0",
+ "markdown-code-block": "#eeeff3"
+ }
+ }
+}
diff --git a/interface/opencode-embed-src/vite.config.embed.ts b/interface/opencode-embed-src/vite.config.embed.ts
new file mode 100644
index 000000000..aefe7e1bb
--- /dev/null
+++ b/interface/opencode-embed-src/vite.config.embed.ts
@@ -0,0 +1,59 @@
+/**
+ * Vite config for building the OpenCode embed bundle.
+ *
+ * Builds as a normal SPA (not library mode) so we get Vite's full
+ * automatic code splitting for shiki grammars, lazy routes, etc.
+ * The entry script attaches `mountOpenCode` to `window.__opencode_embed__`
+ * instead of auto-rendering.
+ *
+ * Output:
+ * - dist-embed/index.html (minimal HTML, loads entry JS)
+ * - dist-embed/assets/index-*.js (main entry chunk, ~2-3MB)
+ * - dist-embed/assets/index-*.css (all CSS)
+ * - dist-embed/assets/*.js (lazy chunks: shiki grammars, etc.)
+ *
+ * Build with:
+ * ./node_modules/.bin/vite build --config vite.config.embed.ts
+ *
+ * Usage in host app:
+ * 1. Load the entry JS as a "
- );
-
- let rewritten = if let Some(head_pos) = html.find("") {
- let insert_at = head_pos + "".len();
- format!("{}{injection}{}", &html[..insert_at], &html[insert_at..])
- } else if let Some(head_pos) = html.find("") {
- let insert_at = head_pos + "".len();
- format!("{}{injection}{}", &html[..insert_at], &html[insert_at..])
- } else {
- // No tag found — prepend the injection
- format!("{injection}{html}")
- };
-
- match response_builder.body(Body::from(rewritten)) {
- Ok(response) => response,
- Err(error) => {
- tracing::warn!(%error, "failed to build rewritten HTML response");
- (StatusCode::INTERNAL_SERVER_ERROR, "proxy response error").into_response()
- }
- }
- } else {
- // Non-HTML: stream the response body as-is (supports SSE)
- let body_stream = upstream_response
- .bytes_stream()
- .map_err(std::io::Error::other);
-
- match response_builder.body(Body::from_stream(body_stream)) {
- Ok(response) => response,
- Err(error) => {
- tracing::warn!(%error, "failed to build proxy response");
- (StatusCode::INTERNAL_SERVER_ERROR, "proxy response error").into_response()
- }
+ let body_stream = upstream_response
+ .bytes_stream()
+ .map_err(std::io::Error::other);
+
+ match response_builder.body(Body::from_stream(body_stream)) {
+ Ok(response) => response,
+ Err(error) => {
+ tracing::warn!(%error, "failed to build proxy response");
+ (StatusCode::INTERNAL_SERVER_ERROR, "proxy response error").into_response()
}
}
}
diff --git a/src/api/server.rs b/src/api/server.rs
index 1fe29dc87..2826f5fb3 100644
--- a/src/api/server.rs
+++ b/src/api/server.rs
@@ -96,6 +96,8 @@ pub async fn start_http_server(
"/opencode/{port}/{*path}",
any(opencode_proxy::opencode_proxy),
)
+ .route("/opencode/{port}", any(opencode_proxy::opencode_proxy))
+ .route("/opencode/{port}/", any(opencode_proxy::opencode_proxy))
.route("/agents/memories", get(memories::list_memories))
.route("/agents/memories/search", get(memories::search_memories))
.route("/agents/memories/graph", get(memories::memory_graph))
diff --git a/src/api/workers.rs b/src/api/workers.rs
index 85a01539f..dff7a4f09 100644
--- a/src/api/workers.rs
+++ b/src/api/workers.rs
@@ -77,6 +77,8 @@ pub(super) struct WorkerDetailResponse {
opencode_port: Option,
/// Whether this worker accepts follow-up input via route.
interactive: bool,
+ /// Working directory for OpenCode workers.
+ directory: Option,
}
/// List worker runs for an agent, with live status merged from StatusBlocks.
@@ -191,5 +193,6 @@ pub(super) async fn worker_detail(
opencode_session_id: detail.opencode_session_id,
opencode_port: detail.opencode_port,
interactive: detail.interactive,
+ directory: detail.directory,
}))
}
diff --git a/src/conversation/history.rs b/src/conversation/history.rs
index f29728a39..41d8ad5aa 100644
--- a/src/conversation/history.rs
+++ b/src/conversation/history.rs
@@ -372,26 +372,6 @@ impl ProcessRunLogger {
});
}
- /// Persist the working directory for a worker. Fire-and-forget.
- ///
- /// Called from `spawn_opencode_worker_from_state` after the worker row is
- /// created, so the directory survives for idle-worker resume.
- pub fn log_worker_directory(&self, worker_id: WorkerId, directory: &std::path::Path) {
- let pool = self.pool.clone();
- let id = worker_id.to_string();
- let dir = directory.to_string_lossy().to_string();
- tokio::spawn(async move {
- if let Err(error) = sqlx::query("UPDATE worker_runs SET directory = ? WHERE id = ?")
- .bind(&dir)
- .bind(&id)
- .execute(&pool)
- .await
- {
- tracing::warn!(%error, worker_id = %id, "failed to persist worker directory");
- }
- });
- }
-
/// Update a worker's status. Fire-and-forget.
/// Most status text updates are transient — they're available via the
/// in-memory StatusBlock for live workers and don't need to be persisted.
@@ -835,7 +815,7 @@ impl ProcessRunLogger {
let row = sqlx::query(
"SELECT w.id, w.task, w.result, w.status, w.worker_type, w.channel_id, \
w.started_at, w.completed_at, w.transcript, w.tool_calls, \
- w.opencode_session_id, w.opencode_port, w.interactive, \
+ w.opencode_session_id, w.opencode_port, w.interactive, w.directory, \
c.display_name as channel_name \
FROM worker_runs w \
LEFT JOIN channels c ON w.channel_id = c.id \
@@ -870,6 +850,9 @@ impl ProcessRunLogger {
opencode_session_id: row.try_get("opencode_session_id").ok(),
opencode_port: row.try_get::("opencode_port").ok(),
interactive: row.try_get::("interactive").unwrap_or(false),
+ directory: row
+ .try_get::