From 929c6f2a1795d178ea92a28fb13f4935940805d6 Mon Sep 17 00:00:00 2001 From: James Pine Date: Tue, 3 Mar 2026 23:20:59 -0800 Subject: [PATCH 01/15] fix: expand tilde in OpenCode worker directory paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLMs consistently produce tilde-prefixed paths (e.g. ~/Projects/foo) because that's what appears in conversation context. Path::canonicalize() doesn't expand tildes — that's a shell feature — so these paths fail with 'directory does not exist'. Expand ~ to the home directory before passing to the server pool. --- src/agent/channel_dispatch.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/agent/channel_dispatch.rs b/src/agent/channel_dispatch.rs index 0c39b349e..17c32934f 100644 --- a/src/agent/channel_dispatch.rs +++ b/src/agent/channel_dispatch.rs @@ -379,7 +379,7 @@ pub async fn spawn_opencode_worker_from_state( check_worker_limit(state).await?; ensure_dispatch_readiness(state, "opencode_worker"); let task = task.into(); - let directory = std::path::PathBuf::from(directory); + let directory = expand_tilde(directory); let rc = &state.deps.runtime_config; let prompt_engine = rc.prompts.load(); @@ -569,3 +569,21 @@ where }); }) } + +/// Expand a leading `~` or `~/` in a path to the user's home directory. +/// +/// LLMs consistently produce tilde-prefixed paths because that's what appears +/// in conversation context. `std::path::Path::canonicalize()` doesn't expand +/// tildes (that's a shell feature), so paths like `~/Projects/foo` fail with +/// "directory does not exist". This handles the common cases. +fn expand_tilde(path: &str) -> std::path::PathBuf { + if path == "~" { + dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/")) + } else if let Some(rest) = path.strip_prefix("~/") { + dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from("/")) + .join(rest) + } else { + std::path::PathBuf::from(path) + } +} From 4746d4fd5ed11a9969f082241b42296d52dd5ff4 Mon Sep 17 00:00:00 2001 From: James Pine Date: Tue, 3 Mar 2026 23:40:54 -0800 Subject: [PATCH 02/15] feat: embed OpenCode web UI in worker detail view Add a reverse proxy and iframe-based embed so users can see the full OpenCode coding interface (diffs, file tree, timeline, terminal) when viewing OpenCode worker runs in the workers tab. Backend: - New migration adding opencode_session_id and opencode_port to worker_runs - New ProcessEvent::OpenCodeSessionCreated emitted after session creation - Reverse proxy at /api/opencode/{port}/{*path} forwarding to localhost OpenCode servers with port range validation and SSE streaming support - Worker list/detail API responses now include OpenCode metadata Frontend: - Tabbed detail view (OpenCode / Transcript) for OpenCode workers - OpenCodeEmbed component with health check and iframe loading states - Falls back to transcript tab when server is unreachable --- interface/src/api/client.ts | 3 + interface/src/routes/AgentWorkers.tsx | 214 ++++++++++++++---- ...0260303000001_opencode_worker_metadata.sql | 5 + src/agent/channel.rs | 8 + src/api.rs | 1 + src/api/opencode_proxy.rs | 131 +++++++++++ src/api/server.rs | 9 +- src/api/workers.rs | 9 + src/conversation/history.rs | 33 ++- src/lib.rs | 7 + src/opencode/worker.rs | 15 ++ 11 files changed, 384 insertions(+), 51 deletions(-) create mode 100644 migrations/20260303000001_opencode_worker_metadata.sql create mode 100644 src/api/opencode_proxy.rs diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 482d056d1..a23413351 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -219,6 +219,7 @@ export interface WorkerRunInfo { has_transcript: boolean; live_status: string | null; tool_calls: number; + opencode_port: number | null; } export interface WorkerDetailResponse { @@ -233,6 +234,8 @@ export interface WorkerDetailResponse { completed_at: string | null; transcript: TranscriptStep[] | null; tool_calls: number; + opencode_session_id: string | null; + opencode_port: number | null; } export interface WorkerListResponse { diff --git a/interface/src/routes/AgentWorkers.tsx b/interface/src/routes/AgentWorkers.tsx index f52718f90..7f2ab7779 100644 --- a/interface/src/routes/AgentWorkers.tsx +++ b/interface/src/routes/AgentWorkers.tsx @@ -16,6 +16,13 @@ import {LiveDuration} from "@/components/LiveDuration"; import {useLiveContext} from "@/hooks/useLiveContext"; import {cx} from "@/ui/utils"; +/** RFC 4648 base64url encoding (no padding), matching OpenCode's directory encoding. */ +function base64UrlEncode(value: string): string { + const bytes = new TextEncoder().encode(value); + const binary = Array.from(bytes, (b) => String.fromCharCode(b)).join(""); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + const STATUS_FILTERS = ["all", "running", "done", "failed"] as const; type StatusFilter = (typeof STATUS_FILTERS)[number]; @@ -139,6 +146,7 @@ export function AgentWorkers({agentId}: {agentId: string}) { has_transcript: false, live_status: live.status, tool_calls: live.toolCalls, + opencode_port: null, })); return [...synthetic, ...merged]; @@ -177,6 +185,8 @@ export function AgentWorkers({agentId}: {agentId: string}) { completed_at: null, transcript: null, tool_calls: live.toolCalls, + opencode_session_id: null, + opencode_port: null, }; }, [detailData, scopedActiveWorkers, selectedWorkerId]); @@ -339,6 +349,8 @@ function WorkerCard({ ); } +type DetailTab = "opencode" | "transcript"; + function WorkerDetail({ detail, liveWorker, @@ -353,6 +365,21 @@ function WorkerDetail({ const displayStatus = liveWorker?.status; const currentTool = liveWorker?.currentTool; const toolCalls = liveWorker?.toolCalls ?? detail.tool_calls ?? 0; + + const hasOpenCodeEmbed = + detail.worker_type === "opencode" && + detail.opencode_port != null && + detail.opencode_session_id != null; + + const [activeTab, setActiveTab] = useState( + hasOpenCodeEmbed ? "opencode" : "transcript", + ); + + // Reset tab when switching workers + useEffect(() => { + setActiveTab(hasOpenCodeEmbed ? "opencode" : "transcript"); + }, [detail.id, hasOpenCodeEmbed]); + // Use persisted transcript if available, otherwise fall back to live SSE transcript. // Strip the final action step if it duplicates the result text shown above. const rawTranscript = detail.transcript ?? (isRunning ? liveTranscript : null); @@ -373,10 +400,10 @@ function WorkerDetail({ // Auto-scroll to latest transcript step for running workers useEffect(() => { - if (isRunning && transcriptRef.current) { + if (isRunning && activeTab === "transcript" && transcriptRef.current) { transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight; } - }, [isRunning, transcript?.length]); + }, [isRunning, activeTab, transcript?.length]); return (
@@ -444,60 +471,151 @@ function WorkerDetail({ )}
+ {/* Tab bar (only for OpenCode workers with embed data) */} + {hasOpenCodeEmbed && ( +
+ + +
+ )} + {/* Content */} -
- {/* Result section */} - {detail.result && ( -
-

- Result -

-
- {detail.result} + {activeTab === "opencode" && hasOpenCodeEmbed ? ( + + ) : ( +
+ {/* Result section */} + {detail.result && ( +
+

+ Result +

+
+ {detail.result} +
-
- )} + )} - {/* Transcript section */} - {transcript && transcript.length > 0 ? ( -
-

- {isRunning ? "Live Transcript" : "Transcript"} -

-
- {transcript.map((step, index) => ( - - - - ))} - {isRunning && currentTool && ( -
- - Running {currentTool}... -
- )} + {/* Transcript section */} + {transcript && transcript.length > 0 ? ( +
+

+ {isRunning ? "Live Transcript" : "Transcript"} +

+
+ {transcript.map((step, index) => ( + + + + ))} + {isRunning && currentTool && ( +
+ + Running {currentTool}... +
+ )} +
-
- ) : isRunning ? ( -
-
-

Waiting for first tool call...

-
- ) : ( -
- Full transcript not available for this worker -
- )} -
+ ) : isRunning ? ( +
+
+

Waiting for first tool call...

+
+ ) : ( +
+ Full transcript not available for this worker +
+ )} +
+ )}
); } +function OpenCodeEmbed({port, sessionId}: {port: number; sessionId: string}) { + const [state, setState] = useState<"loading" | "ready" | "error">("loading"); + + useEffect(() => { + setState("loading"); + const controller = new AbortController(); + + fetch(`/api/opencode/${port}/global/health`, {signal: controller.signal}) + .then((response) => { + setState(response.ok ? "ready" : "error"); + }) + .catch(() => { + setState("error"); + }); + + return () => controller.abort(); + }, [port, sessionId]); + + if (state === "loading") { + return ( +
+
+ + Connecting to OpenCode... +
+
+ ); + } + + if (state === "error") { + return ( +
+

OpenCode server is not reachable

+

+ The server may have been stopped. Try the Transcript tab for available data. +

+
+ ); + } + + // Build the iframe URL. OpenCode uses base64url-encoded directory paths + // in its SPA routing. We load the root and let the app navigate — the + // server knows its directory, and the session list will show this session. + // Direct deep-linking: /api/opencode/{port}/{base64dir}/session/{sessionId} + const iframeSrc = `/api/opencode/${port}/`; + + return ( +