diff --git a/.env.example b/.env.example
index 3a7e819c..e95939ae 100644
--- a/.env.example
+++ b/.env.example
@@ -17,14 +17,24 @@ PORT=3000
# Root under which project folders are offered in the new-session picker.
PROJECTS_DIR=~/Documents/Projects
+# OpenCode creates isolated worktrees below this root. The BFF accepts workspace
+# paths only beneath PROJECTS_DIR or this directory.
+OPENCODE_WORKTREE_ROOT=~/.local/share/opencode/worktree
+
# ── Mobile / Tailscale (optional) ────────────────────────────────────────────
# Vite blocks non-localhost Host headers by default (DNS-rebinding protection).
# Set to "all", or a comma-separated allowlist, to reach the dev UI from a phone.
# VITE_ALLOWED_HOSTS=all
+# Ports the read-only mobile preview proxy may reach on 127.0.0.1. Unset means
+# disabled. The BFF and OpenCode ports are always removed from this list.
+# PREVIEW_ALLOWED_PORTS=5173,4173
+
# ── Notifications (optional, Phase 5) ────────────────────────────────────────
# NTFY_SERVER=https://ntfy.sh
# NTFY_TOPIC=
+# NTFY_TOKEN=
+# NOTIFICATION_PREFS_FILE=.state/notification-prefs.json
# ── Forge integrations (optional, Phase 3) ───────────────────────────────────
# Used only by the merge-request panel. Agent git operations use your host
diff --git a/.gitignore b/.gitignore
index a23fe37f..7f45d4c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,5 +11,3 @@ playwright-report/
.DS_Store
mcp-servers.json
screenshots-out/
-playwright-report/
-test-results/
diff --git a/AGENTS.md b/AGENTS.md
index 789446b1..ab44492b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,11 +22,12 @@ several decisions below.
- **The API is much larger than the docs.** `GET /doc` on a live server returns
OpenAPI 3.1 with 162 paths / 188 operations; the published docs show ~60. When in
doubt, curl `/doc`, not the website.
-- **Use `dist/v2/gen/` SDK types.** `dist/gen/` and the GitHub `dev` branch are stale
- in ways that fail silently — they still say `permission.updated` (actually
- `permission.asked`) and still declare `Todo.id`, which was removed in 1.18.19.
+- **The live `GET /doc` is the contract.** The SDK's classic query types are narrower
+ than the 1.18.19 server and its event union is stale, so `server/opencode/client.ts`
+ owns a small typed fetch seam instead of casting around the SDK.
- Tests: `npm test` (vitest, `tests/*.test.ts`, node environment, import with `.js`
- suffixes). `npm run typecheck` runs both tsconfigs. Playwright e2e needs the stack up.
+ suffixes). `npm run typecheck` runs both tsconfigs. Playwright starts deterministic
+ mock OpenCode and preview servers, so `npm run test:e2e` needs no live stack or keys.
## Non-obvious API contracts (each one cost real debugging)
diff --git a/README.md b/README.md
index 192f9110..1badb4cc 100644
--- a/README.md
+++ b/README.md
@@ -21,17 +21,18 @@ the same server and can be attached at the same time, watching the same sessions
## Status
-Early. Built in phases — see [`docs/research/opencode-build-plan.html`](docs/research/).
+The planned migration waves are implemented. The deterministic verification suite runs
+against the production SPA and real BFF with only OpenCode and preview targets mocked.
| Phase | | |
|---|---|---|
| 0 | Foundation — scaffold, permission policy, launchd unit | ✅ |
-| 1 | The seam — SDK wrapper + event adapter | 🚧 |
-| 2 | Session lifecycle + interrupted-run detection | |
-| 3 | Panels — MR, commands, files, changes, preview proxy | |
-| 4 | Derived — task list, status bar, tools & health | |
-| 5 | Settings, resilience, worktrees | |
-| 6 | Mobile polish + cutover | |
+| 1 | The seam — typed fetch wrapper + event adapter | ✅ |
+| 2 | Session lifecycle + interrupted-run detection | ✅ |
+| 3 | Panels — MR links, commands, files, changes, preview proxy | ✅ |
+| 4 | Derived — task list, status bar, tools & health | ✅ |
+| 5 | Settings, notifications, resilience, worktrees | ✅ |
+| 6 | Mobile/PWA polish + deterministic E2E | ✅ |
## Requirements
@@ -47,6 +48,14 @@ cp .env.example .env # point OPENCODE_URL at your server
npm run dev
```
+Verification requires no live agent or model credentials:
+
+```bash
+npm run typecheck
+npm test
+npm run test:e2e
+```
+
## Architecture
```
@@ -71,6 +80,11 @@ container. The guardrail is the `permission` block in `opencode.json`: per-tool
per-command-pattern `allow` / `ask` / `deny`, with `~/.ssh`, `~/.aws` and `.env` files
denied outright.
+The BFF additionally canonicalizes every browser-provided workspace path beneath
+`PROJECTS_DIR` or `OPENCODE_WORKTREE_ROOT`. The preview tunnel is disabled unless
+`PREVIEW_ALLOWED_PORTS` explicitly allows a localhost port, and it never forwards
+cookies, authorization, host headers or OpenCode credentials.
+
Note that permission precedence is **last-match-wins**, the opposite of most ACL
systems. Broad rules first, specific overrides after.
diff --git a/client/components/app-shell.tsx b/client/components/app-shell.tsx
new file mode 100644
index 00000000..00c96e67
--- /dev/null
+++ b/client/components/app-shell.tsx
@@ -0,0 +1,35 @@
+import { NavLink, Outlet } from "react-router-dom";
+
+import { useNotifyWatcher } from "../lib/useNotifyWatcher.js";
+
+export function AppShell() {
+ useNotifyWatcher();
+ return (
+
+
+
+ OpenCode
+
+ {[
+ ["/tools", "Tools"],
+ ["/settings/notifications", "Notifications"],
+ ["/settings", "Settings"],
+ ].map(([to, label]) => (
+
+ `rounded px-2 py-1 text-xs ${isActive ? "bg-[var(--color-background-surface-neutral-muted)] font-semibold" : "text-[var(--color-text-muted)]"}`
+ }
+ data-testid={`opencode-nav-${label.toLowerCase()}`}
+ >
+ {label}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/client/components/session-inspector.tsx b/client/components/session-inspector.tsx
new file mode 100644
index 00000000..2ddde1e2
--- /dev/null
+++ b/client/components/session-inspector.tsx
@@ -0,0 +1,193 @@
+import { useEffect, useMemo, useState } from "react";
+
+import { Button } from "../ds/button.js";
+import {
+ extractCommands,
+ extractMrUrls,
+ formatClockTime,
+ type CommandEntry,
+} from "../lib/derive.js";
+import type { Todo } from "../lib/api.js";
+import type { TranscriptEvent } from "../lib/transcript.js";
+import { api, type ReviewStatus } from "../lib/api.js";
+
+type InspectorTab = "tasks" | "commands" | "links";
+
+interface SessionInspectorProps {
+ events: TranscriptEvent[];
+ todos: Todo[];
+}
+
+function exportCommands(commands: CommandEntry[]): void {
+ const lines = [
+ "#!/usr/bin/env bash",
+ "set -euo pipefail",
+ "",
+ ...commands
+ .filter((command) => command.category === "command")
+ .flatMap((command) => [`# ${command.status} at ${command.timestamp}`, command.text, ""]),
+ ];
+ const url = URL.createObjectURL(new Blob([lines.join("\n")], { type: "text/x-shellscript" }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = "session-commands.sh";
+ link.click();
+ URL.revokeObjectURL(url);
+}
+
+function jumpToEvent(id: string): void {
+ const row = document.querySelector(`[data-event-id="${CSS.escape(id)}"]`);
+ row?.scrollIntoView({ behavior: "smooth", block: "center" });
+ row?.focus({ preventScroll: true });
+}
+
+function ReviewLink({ url }: { url: string }) {
+ const [review, setReview] = useState(null);
+ const [error, setError] = useState("");
+ useEffect(() => {
+ void api.review(url).then((result) => setReview(result.review)).catch((reason: Error) => setError(reason.message));
+ }, [url]);
+ return (
+
+
+ {review?.title ?? url}
+
+ {review ? (
+
+ {review.forge} {review.state} {review.author}
+ {review.pipeline && pipeline {review.pipeline} }
+ {review.mergeable && review.state === "open" && (
+ { if (window.confirm(`Merge ${review.title} at ${review.headSha.slice(0, 8)}?`)) void api.mergeReview(url, review.headSha).then(() => setReview({ ...review, state: "merged", mergeable: false })).catch((reason: Error) => setError(reason.message)); }} data-testid="opencode-merge-review">Merge
+ )}
+
+ ) : error ?
Live status unavailable
:
Loading status...
}
+
+ );
+}
+
+export function SessionInspector({ events, todos }: SessionInspectorProps) {
+ const commands = useMemo(() => extractCommands(events), [events]);
+ const links = useMemo(() => extractMrUrls(events), [events]);
+ const [tab, setTab] = useState("tasks");
+
+ return (
+
+
+ {(["tasks", "commands", "links"] as const).map((name) => (
+ setTab(name)}
+ data-testid={`opencode-inspector-${name}`}
+ >
+ {name}
+ {name === "tasks" && todos.length ? ` ${todos.length}` : ""}
+ {name === "commands" && commands.length ? ` ${commands.length}` : ""}
+ {name === "links" && links.length ? ` ${links.length}` : ""}
+
+ ))}
+
+
+
+ {tab === "tasks" && (
+
+
+ Task list - {todos.filter((todo) => todo.status === "completed").length}/{todos.length} done
+
+ {todos.length === 0 ? (
+ No tasks reported.
+ ) : (
+
+ {todos.map((todo, index) => (
+
+
+ {todo.status === "completed" ? "[x]" : todo.status === "in_progress" ? "[~]" : "[ ]"}
+
+
+ {todo.content}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {tab === "commands" && (
+
+
+
+ Tool audit
+
+ command.category === "command")}
+ onClick={() => exportCommands(commands)}
+ data-testid="opencode-export-commands"
+ >
+ Export .sh
+
+
+ {commands.length === 0 ? (
+ No tool calls yet.
+ ) : (
+
+ {commands.map((command) => (
+
+ jumpToEvent(command.id)}
+ data-testid="opencode-command-row"
+ >
+
+ {command.category}
+ {command.status}
+ {formatClockTime(command.timestamp)}
+
+ {command.text}
+ {command.outputPreview && (
+
+ {command.outputPreview}
+
+ )}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {tab === "links" && (
+
+
+ Merge requests and pull requests
+
+ {links.length === 0 ? (
+ No review links mentioned.
+ ) : (
+
+ {links.map((url) => (
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/client/components/workspace-panels.tsx b/client/components/workspace-panels.tsx
new file mode 100644
index 00000000..c61bd4d2
--- /dev/null
+++ b/client/components/workspace-panels.tsx
@@ -0,0 +1,105 @@
+import { useEffect, useState } from "react";
+
+import { Alert } from "../ds/alert.js";
+import { Button } from "../ds/button.js";
+import {
+ api,
+ type GitCommit,
+ type VcsFileDiff,
+ type WorkspaceFile,
+ type WorkspaceNode,
+} from "../lib/api.js";
+
+type Tab = "files" | "changes" | "preview";
+
+export function WorkspacePanels({ directory, onClose }: { directory: string; onClose: () => void }) {
+ const [tab, setTab] = useState("files");
+ const [path, setPath] = useState("");
+ const [nodes, setNodes] = useState([]);
+ const [file, setFile] = useState(null);
+ const [changes, setChanges] = useState([]);
+ const [selectedChange, setSelectedChange] = useState(0);
+ const [mode, setMode] = useState<"git" | "branch">("git");
+ const [commits, setCommits] = useState([]);
+ const [port, setPort] = useState("5173");
+ const [previewKey, setPreviewKey] = useState(0);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ if (tab !== "files") return;
+ void api.workspaceTree(directory, path).then((tree) => {
+ setNodes([...tree.dirs, ...tree.files]);
+ setFile(null);
+ setError("");
+ }).catch((e: Error) => setError(e.message));
+ }, [directory, path, tab]);
+
+ useEffect(() => {
+ if (tab !== "changes") return;
+ void Promise.all([api.changes(directory, mode), api.commits(directory)]).then(([diffs, history]) => {
+ setChanges(diffs.changes);
+ setCommits(history.commits);
+ setSelectedChange(0);
+ setError("");
+ }).catch((e: Error) => setError(e.message));
+ }, [directory, mode, tab]);
+
+ const openNode = (node: WorkspaceNode) => {
+ if (node.type === "directory") setPath(node.path);
+ else void api.workspaceFile(directory, node.path).then(setFile).catch((e: Error) => setError(e.message));
+ };
+ const parent = path.split("/").slice(0, -1).join("/");
+
+ return (
+
+
+ {(["files", "changes", "preview"] as const).map((name) => (
+ setTab(name)} className={`rounded px-3 py-1.5 text-xs capitalize ${tab === name ? "bg-[var(--color-background-surface-neutral-muted)] font-semibold" : "text-[var(--color-text-muted)]"}`} data-testid={`opencode-workspace-${name}`}>{name}
+ ))}
+ Close
+
+ {error && }
+
+ {tab === "files" && (
+
+
+ setPath(parent)} className="mb-1 w-full rounded p-2 text-left text-xs text-[var(--color-text-muted)] disabled:opacity-40" data-testid="opencode-files-up">../ {path || "workspace"}
+ {nodes.filter((node) => !node.ignored).map((node) => (
+ openNode(node)} className="block w-full truncate rounded p-2 text-left text-sm hover:bg-[var(--hh-row-hover)]" data-testid="opencode-file-node">{node.type === "directory" ? "> " : ""}{node.name}
+ ))}
+
+
+ {!file ?
Select a file.
: file.type === "binary" ?
Binary file ({file.mimeType ?? "unknown type"})
: <>
{file.path} {file.content} >}
+
+
+ )}
+
+ {tab === "changes" && (
+
+
+ {(["git", "branch"] as const).map((value) => setMode(value)} data-testid={`opencode-changes-${value}`}>{value === "git" ? "Working tree" : "Branch"} )}
+
+
+
+ {changes.map((change, index) =>
setSelectedChange(index)} className="block w-full truncate rounded p-2 text-left text-xs hover:bg-[var(--hh-row-hover)]" data-testid="opencode-change-file">{change.file}+{change.additions} -{change.deletions} )}
+
Recent commits
+ {commits.map((commit) =>
{commit.shortSha} {commit.subject}
)}
+
+
{changes[selectedChange]?.patch || (changes.length ? "Patch unavailable or capped by OpenCode." : "No changes.")}
+
+
+ )}
+
+ {tab === "preview" && (
+
+
+ Port setPort(event.target.value.replace(/\D/g, ""))} className="w-24 rounded-md border border-[var(--color-border-default)] bg-transparent p-2" data-testid="opencode-preview-port" />
+ setPreviewKey((key) => key + 1)} data-testid="opencode-preview-reload">Load / Reload
+
+
+
Read-only proxy only. Start the app separately and configure its base path for this URL.
+
+ )}
+
+ );
+}
diff --git a/client/index.html b/client/index.html
index 1b4cc0ed..001135fc 100644
--- a/client/index.html
+++ b/client/index.html
@@ -6,6 +6,9 @@
+
+
+
custom-dca-opencode
diff --git a/client/lib/api.ts b/client/lib/api.ts
index 3bc46dbf..6e87bd34 100644
--- a/client/lib/api.ts
+++ b/client/lib/api.ts
@@ -45,6 +45,78 @@ export interface HealthResponse {
events?: { connected: boolean };
}
+export interface AppSettings {
+ model?: string;
+ small_model?: string;
+ default_agent?: string;
+ subagent_depth?: number;
+ compaction?: { auto?: boolean; prune?: boolean; reserved?: number };
+}
+
+export type McpStatus =
+ | { status: "connected" }
+ | { status: "disabled" }
+ | { status: "failed"; error: string }
+ | { status: "needs_auth" }
+ | { status: "needs_client_registration"; error: string };
+
+export type NotifyEvent = "idle" | "error" | "abort" | "permission" | "question" | "parked";
+export interface NotificationPreferences {
+ version: 1;
+ ntfy: { enabled: boolean; server: string; topic: string; events: Record };
+ browser: { desktop: boolean; sound: boolean; volume: number; events: Record };
+ parkedPermissionSeconds: number;
+}
+
+export interface WorkspaceNode {
+ name: string;
+ path: string;
+ type: "file" | "directory";
+ ignored: boolean;
+}
+
+export interface WorkspaceFile {
+ path: string;
+ type: "text" | "binary";
+ content: string;
+ encoding?: "base64";
+ mimeType?: string;
+}
+
+export interface VcsFileDiff {
+ file: string;
+ patch?: string;
+ additions: number;
+ deletions: number;
+ status?: "added" | "deleted" | "modified";
+}
+
+export interface GitCommit {
+ sha: string;
+ shortSha: string;
+ subject: string;
+ author: string;
+ authoredAt: string;
+}
+
+export interface Worktree { name: string; branch?: string; directory: string }
+export interface ReviewStatus {
+ url: string;
+ forge: "github" | "gitlab";
+ title: string;
+ state: string;
+ author: string;
+ pipeline: string | null;
+ mergeable: boolean | null;
+ headSha: string;
+}
+export interface PermissionRequest {
+ id: string;
+ sessionID: string;
+ permission: string;
+ patterns: string[];
+}
+
/**
* Unwrap a response, surfacing the BFF's `{ error }` body when present.
*
@@ -104,6 +176,10 @@ export const api = {
fetch(scoped(`/sessions/${encodeURIComponent(id)}/todos`, directory)).then((r) =>
json<{ todos: Todo[] }>(r),
),
+ modelLimit: (directory: string, id: string) =>
+ fetch(scoped(`/sessions/${encodeURIComponent(id)}/model-limit`, directory)).then((r) =>
+ json<{ context: number | null }>(r),
+ ),
createSession: (input: {
directory: string;
@@ -111,6 +187,8 @@ export const api = {
agent?: string;
model?: { providerID: string; modelID: string };
prompt?: string;
+ isolated?: boolean;
+ worktreeName?: string;
}) =>
fetch("/api/sessions", {
method: "POST",
@@ -118,11 +196,17 @@ export const api = {
body: JSON.stringify(input),
}).then((r) => json<{ session: SessionSummary }>(r)),
- prompt: (directory: string, id: string, text: string, model?: { providerID: string; modelID: string }) =>
+ prompt: (
+ directory: string,
+ id: string,
+ text: string,
+ model?: { providerID: string; modelID: string },
+ attachments?: Array<{ filename: string; mime: string; url: string }>,
+ ) =>
fetch(scoped(`/sessions/${encodeURIComponent(id)}/prompt`, directory), {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ text, ...(model ? { model } : {}) }),
+ body: JSON.stringify({ text, ...(model ? { model } : {}), ...(attachments?.length ? { attachments } : {}) }),
}).then((r) => json<{ accepted: boolean }>(r)),
abort: (directory: string, id: string) =>
@@ -135,6 +219,89 @@ export const api = {
json(r),
),
+ settings: () => fetch("/api/settings").then((r) => json<{ settings: AppSettings }>(r)),
+ saveSettings: (settings: AppSettings) =>
+ fetch("/api/settings", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(settings),
+ }).then((r) => json<{ settings: AppSettings }>(r)),
+
+ mcp: (directory: string) =>
+ fetch(scoped("/mcp", directory)).then((r) => json<{ servers: Record }>(r)),
+ setMcp: (directory: string, name: string, connected: boolean) =>
+ fetch(scoped(`/mcp/${encodeURIComponent(name)}/${connected ? "connect" : "disconnect"}`, directory), {
+ method: "POST",
+ }).then((r) => json<{ servers: Record }>(r)),
+ permissions: (directory: string) =>
+ fetch(scoped("/permissions", directory)).then((r) => json<{ permissions: unknown }>(r)),
+ lsp: (directory: string) =>
+ fetch(scoped("/lsp", directory)).then((r) => json<{ servers: unknown }>(r)),
+
+ notifications: () =>
+ fetch("/api/notifications").then((r) =>
+ json<{ preferences: NotificationPreferences; tokenConfigured: boolean }>(r),
+ ),
+ saveNotifications: (preferences: NotificationPreferences) =>
+ fetch("/api/notifications", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(preferences),
+ }).then((r) => json<{ preferences: NotificationPreferences; tokenConfigured: boolean }>(r)),
+ testNtfy: () =>
+ fetch("/api/notifications/test", { method: "POST" }).then((r) => json<{ sent: boolean }>(r)),
+
+ workspaceTree: (directory: string, path = "") =>
+ fetch(scoped("/workspace/tree", directory, { path })).then((r) =>
+ json<{ path: string; dirs: WorkspaceNode[]; files: WorkspaceNode[] }>(r),
+ ),
+ workspaceFile: (directory: string, path: string) =>
+ fetch(scoped("/workspace/file", directory, { path })).then((r) => json(r)),
+ changes: (directory: string, mode: "git" | "branch") =>
+ fetch(scoped("/workspace/changes", directory, { mode })).then((r) =>
+ json<{ changes: VcsFileDiff[] }>(r),
+ ),
+ commits: (directory: string) =>
+ fetch(scoped("/workspace/commits", directory)).then((r) => json<{ commits: GitCommit[] }>(r)),
+ worktrees: (directory: string) =>
+ fetch(scoped("/worktrees", directory)).then((r) => json<{ worktrees: Worktree[] }>(r)),
+ createWorktree: (directory: string, name?: string) =>
+ fetch(scoped("/worktrees", directory), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(name ? { name } : {}),
+ }).then((r) => json<{ worktree: Worktree }>(r)),
+ resetWorktree: (directory: string, worktreeDirectory: string) =>
+ fetch(scoped("/worktrees/reset", directory), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ worktreeDirectory }),
+ }).then((r) => json<{ reset: boolean }>(r)),
+ deleteWorktree: (directory: string, worktreeDirectory: string) =>
+ fetch(scoped("/worktrees", directory), {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ worktreeDirectory }),
+ }).then((r) => json(r)),
+ review: (url: string) =>
+ fetch(`/api/forge/review?${new URLSearchParams({ url })}`).then((r) => json<{ review: ReviewStatus }>(r)),
+ mergeReview: (url: string, expectedSha: string) =>
+ fetch("/api/forge/review/merge", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url, expectedSha }),
+ }).then((r) => json<{ merged: boolean }>(r)),
+ permissionRequests: (directory: string) =>
+ fetch(scoped("/permission-requests", directory)).then((r) =>
+ json<{ requests: PermissionRequest[] }>(r),
+ ),
+ replyPermission: (directory: string, requestId: string, reply: "once" | "always" | "reject") =>
+ fetch(scoped(`/permission-requests/${encodeURIComponent(requestId)}/reply`, directory), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ reply }),
+ }).then((r) => json<{ replied: boolean }>(r)),
+
/** SSE endpoint URL — consumed by EventSource, not fetch. */
eventsUrl: (directory?: string) =>
directory ? `/api/events?directory=${encodeURIComponent(directory)}` : "/api/events",
diff --git a/client/lib/useNotifyWatcher.ts b/client/lib/useNotifyWatcher.ts
new file mode 100644
index 00000000..0a91016d
--- /dev/null
+++ b/client/lib/useNotifyWatcher.ts
@@ -0,0 +1,87 @@
+import { useEffect } from "react";
+
+import { api, type NotificationPreferences, type NotifyEvent } from "./api.js";
+
+function classify(type: string, properties: Record): NotifyEvent | null {
+ if (type === "session.idle") return "idle";
+ if (type === "permission.asked") return "permission";
+ if (type === "question.asked") return "question";
+ if (type === "notification.parked") return "parked";
+ if (type === "session.error") {
+ const error = properties.error;
+ return error && typeof error === "object" && (error as Record).name === "MessageAbortedError"
+ ? "abort"
+ : "error";
+ }
+ return null;
+}
+
+function play(volume: number): void {
+ const AudioContextClass = window.AudioContext;
+ if (!AudioContextClass) return;
+ const context = new AudioContextClass();
+ const oscillator = context.createOscillator();
+ const gain = context.createGain();
+ oscillator.frequency.value = 660;
+ gain.gain.value = Math.max(0, Math.min(1, volume)) * 0.08;
+ oscillator.connect(gain).connect(context.destination);
+ oscillator.start();
+ oscillator.stop(context.currentTime + 0.12);
+ oscillator.addEventListener("ended", () => void context.close());
+}
+
+export function notifyBrowser(
+ preferences: NotificationPreferences,
+ event: NotifyEvent,
+ title = `OpenCode: ${event}`,
+): void {
+ if (!preferences.browser.events[event]) return;
+ if (preferences.browser.sound) play(preferences.browser.volume);
+ if (
+ preferences.browser.desktop &&
+ "Notification" in window &&
+ Notification.permission === "granted"
+ ) {
+ new Notification(title, { body: "Open the IDE to review the session." });
+ }
+}
+
+/** One app-level listener. SSE is a nudge; notification preferences stay server-backed. */
+export function useNotifyWatcher(): void {
+ useEffect(() => {
+ let preferences: NotificationPreferences | null = null;
+ const seen = new Map();
+ const refreshPreferences = () => void api.notifications().then((result) => {
+ preferences = result.preferences;
+ }).catch(() => undefined);
+ refreshPreferences();
+ window.addEventListener("opencode-notification-preferences", refreshPreferences);
+ const source = new EventSource(api.eventsUrl());
+ source.onmessage = (message) => {
+ let event: { type?: string; properties?: Record };
+ try {
+ event = JSON.parse(message.data) as typeof event;
+ } catch {
+ return;
+ }
+ if (!event.type || !preferences) return;
+ const kind = classify(event.type, event.properties ?? {});
+ if (!kind) return;
+ const properties = event.properties ?? {};
+ const key = `${event.type}:${String(properties.id ?? properties.requestID ?? properties.sessionID ?? message.lastEventId)}`;
+ const now = Date.now();
+ if (now - (seen.get(key) ?? 0) < 5_000) return;
+ seen.set(key, now);
+ if (seen.size > 500) {
+ for (const [seenKey, timestamp] of seen) {
+ if (now - timestamp > 60_000) seen.delete(seenKey);
+ }
+ }
+ notifyBrowser(preferences, kind);
+ };
+ return () => {
+ source.close();
+ window.removeEventListener("opencode-notification-preferences", refreshPreferences);
+ };
+ }, []);
+}
diff --git a/client/lib/useSessionStream.ts b/client/lib/useSessionStream.ts
index 7c86f904..e4dd2aa1 100644
--- a/client/lib/useSessionStream.ts
+++ b/client/lib/useSessionStream.ts
@@ -17,7 +17,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
-import { api, ApiError } from "./api.js";
+import { api, ApiError, type PermissionRequest } from "./api.js";
const POLL_MS = 3_000;
const RETRY_BASE_MS = 2_000;
@@ -32,6 +32,7 @@ export interface SessionStreamState {
messages: unknown[];
running: boolean;
todos: Array<{ content: string; status: string; priority: string }>;
+ permissions: PermissionRequest[];
error: string | null;
/** True once the first fetch has resolved, so the UI can skip a spinner. */
loaded: boolean;
@@ -42,6 +43,7 @@ export function useSessionStream(directory: string, sessionId: string): SessionS
const [messages, setMessages] = useState([]);
const [running, setRunning] = useState(false);
const [todos, setTodos] = useState>([]);
+ const [permissions, setPermissions] = useState([]);
const [error, setError] = useState(null);
const [loaded, setLoaded] = useState(false);
@@ -54,9 +56,10 @@ export function useSessionStream(directory: string, sessionId: string): SessionS
if (inFlight.current) return;
inFlight.current = true;
try {
- const [messageResult, todoResult] = await Promise.allSettled([
+ const [messageResult, todoResult, permissionResult] = await Promise.allSettled([
api.messages(directory, sessionId),
api.todos(directory, sessionId),
+ api.permissionRequests(directory),
]);
if (liveId.current !== sessionId) return;
@@ -70,6 +73,9 @@ export function useSessionStream(directory: string, sessionId: string): SessionS
}
// Todos are supplementary — a failure there must not blank the transcript.
if (todoResult.status === "fulfilled") setTodos(todoResult.value.todos);
+ if (permissionResult.status === "fulfilled") {
+ setPermissions(permissionResult.value.requests.filter((request) => request.sessionID === sessionId));
+ }
} finally {
inFlight.current = false;
setLoaded(true);
@@ -158,7 +164,7 @@ export function useSessionStream(directory: string, sessionId: string): SessionS
void poll();
}, [poll]);
- return { messages, running, todos, error, loaded, refresh };
+ return { messages, running, todos, permissions, error, loaded, refresh };
}
/** True when an error means "stop trying" rather than "retry later". */
diff --git a/client/main.tsx b/client/main.tsx
index 2b11d775..2f3a693e 100644
--- a/client/main.tsx
+++ b/client/main.tsx
@@ -5,6 +5,10 @@ import { ThemeProvider } from "next-themes";
import { HubPage } from "./pages/Hub.js";
import { ConversationPage } from "./pages/Conversation.js";
+import { SettingsPage } from "./pages/Settings.js";
+import { NotificationsPage } from "./pages/Notifications.js";
+import { ToolsPage } from "./pages/Tools.js";
+import { AppShell } from "./components/app-shell.js";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
@@ -12,8 +16,13 @@ createRoot(document.getElementById("root")!).render(
- } />
- } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
diff --git a/client/pages/Conversation.tsx b/client/pages/Conversation.tsx
index 18019f5b..04ef53b2 100644
--- a/client/pages/Conversation.tsx
+++ b/client/pages/Conversation.tsx
@@ -6,6 +6,8 @@ import { Badge } from "../ds/badge.js";
import { Button } from "../ds/button.js";
import { LoadingIndicator } from "../ds/loading-indicator.js";
import { RunningIndicator, Transcript } from "../components/transcript.js";
+import { SessionInspector } from "../components/session-inspector.js";
+import { WorkspacePanels } from "../components/workspace-panels.js";
import { api, formatCost, type SessionSummary } from "../lib/api.js";
import { collapseActionGroups, mergeEvents, runningActivity } from "../lib/derive.js";
import { normalizeTranscript, type RawMessage } from "../lib/events.js";
@@ -25,6 +27,9 @@ export function ConversationPage() {
const [collapsedGroups, setCollapsedGroups] = useState>({});
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
+ const [workspaceOpen, setWorkspaceOpen] = useState(false);
+ const [contextLimit, setContextLimit] = useState(null);
+ const [attachments, setAttachments] = useState>([]);
// Keep event identity stable across polls so memoised rows do not churn.
const [events, setEvents] = useState([]);
@@ -48,8 +53,17 @@ export function ConversationPage() {
};
}, [directory, id, stream.running]);
+ useEffect(() => {
+ if (!directory || !id) return;
+ void api.modelLimit(directory, id).then((result) => setContextLimit(result.context)).catch(() => setContextLimit(null));
+ }, [directory, id]);
+
const items = useMemo(() => collapseActionGroups(events), [events]);
const activity = useMemo(() => runningActivity(events), [events]);
+ const latestUsage = transcript.usage.at(-1);
+ const contextTokens = latestUsage
+ ? latestUsage.tokens.input + latestUsage.tokens.output + latestUsage.tokens.reasoning + latestUsage.tokens.cacheRead + latestUsage.tokens.cacheWrite
+ : 0;
const toggleGroup = useCallback((groupId: string) => {
setCollapsedGroups((state) => ({ ...state, [groupId]: !state[groupId] }));
@@ -73,8 +87,9 @@ export function ConversationPage() {
if (!text) return;
setSending(true);
try {
- await api.prompt(directory, id, text);
+ await api.prompt(directory, id, text, undefined, attachments);
setDraft("");
+ setAttachments([]);
stream.refresh();
} finally {
setSending(false);
@@ -104,9 +119,18 @@ export function ConversationPage() {
{formatCost(session.cost)}
)}
+ {contextTokens > 0 && (
+
+ context {Intl.NumberFormat(undefined, { notation: "compact" }).format(contextTokens)}
+ {contextLimit ? ` / ${Math.round((contextTokens / contextLimit) * 100)}%` : ""}
+
+ )}
{wrap ? "Wrap: on" : "Wrap: off"}
+ setWorkspaceOpen(true)} data-testid="opencode-workspace-open">
+ Workspace
+
{stream.running && (
)}
+ {stream.permissions.map((permission) => (
+
+
+
+ {permission.permission} needs approval{permission.patterns.length ? `: ${permission.patterns.join(", ")}` : ""}
+ void api.replyPermission(directory, permission.id, "once").then(stream.refresh)} data-testid="opencode-permission-once">Allow once
+ void api.replyPermission(directory, permission.id, "always").then(stream.refresh)} data-testid="opencode-permission-always">Always
+ void api.replyPermission(directory, permission.id, "reject").then(stream.refresh)} data-testid="opencode-permission-reject">Reject
+
+
+
+ ))}
+
- {stream.todos.length > 0 && (
-
-
- Task list · {stream.todos.filter((t) => t.status === "completed").length}/
- {stream.todos.length} done
-
-
- {/* Todo has no id in 1.18.19 — index is the only stable key. */}
- {stream.todos.map((todo, index) => (
-
-
- {todo.status === "completed" ? "✓" : todo.status === "in_progress" ? "◐" : "○"}
-
-
- {todo.content}
-
-
- ))}
-
-
- )}
+
-
+
+ {attachments.length > 0 &&
{attachments.map((attachment, index) => setAttachments((items) => items.filter((_, itemIndex) => itemIndex !== index))} className="rounded border border-[var(--color-border-default)] px-2 py-1 text-xs" data-testid="opencode-attachment-chip">{attachment.filename} x )}
}
+
+
+ Attach
+ {
+ const files = [...(event.target.files ?? [])].slice(0, Math.max(0, 4 - attachments.length)).filter((file) => file.size <= 3 * 1024 * 1024);
+ void Promise.all(files.map((file) => new Promise<{ filename: string; mime: string; url: string }>((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve({ filename: file.name, mime: file.type, url: String(reader.result) });
+ reader.onerror = () => reject(reader.error);
+ reader.readAsDataURL(file);
+ }))).then((next) => setAttachments((items) => [...items, ...next]));
+ event.target.value = "";
+ }} />
+
+ {workspaceOpen &&
setWorkspaceOpen(false)} />}
);
}
diff --git a/client/pages/Hub.tsx b/client/pages/Hub.tsx
index 52177547..0fe8f80a 100644
--- a/client/pages/Hub.tsx
+++ b/client/pages/Hub.tsx
@@ -5,7 +5,7 @@ import { Alert } from "../ds/alert.js";
import { Badge } from "../ds/badge.js";
import { Button } from "../ds/button.js";
import { LoadingIndicator } from "../ds/loading-indicator.js";
-import { api, formatCost, type HealthResponse, type SessionSummary } from "../lib/api.js";
+import { api, formatCost, type HealthResponse, type SessionSummary, type Worktree } from "../lib/api.js";
const DIRECTORY_KEY = "opencode.directory.v1";
const POLL_MS = 10_000;
@@ -39,6 +39,8 @@ export function HubPage() {
const [error, setError] = useState(null);
const [prompt, setPrompt] = useState("");
const [creating, setCreating] = useState(false);
+ const [isolated, setIsolated] = useState(false);
+ const [worktrees, setWorktrees] = useState([]);
useEffect(() => {
if (directory) localStorage.setItem(DIRECTORY_KEY, directory);
@@ -76,6 +78,11 @@ export function HubPage() {
return () => clearInterval(timer);
}, [directory, refresh]);
+ useEffect(() => {
+ if (!directory) return;
+ void api.worktrees(directory).then((result) => setWorktrees(result.worktrees)).catch(() => setWorktrees([]));
+ }, [directory]);
+
const applyDirectory = () => {
const next = directoryInput.trim();
if (!next) return;
@@ -88,8 +95,8 @@ export function HubPage() {
setCreating(true);
setError(null);
try {
- const { session } = await api.createSession({ directory, prompt });
- navigate(`/sessions/${session.id}?directory=${encodeURIComponent(directory)}`);
+ const { session } = await api.createSession({ directory, prompt, isolated });
+ navigate(`/sessions/${session.id}?directory=${encodeURIComponent(session.directory)}`);
} catch (e) {
setError((e as Error).message);
} finally {
@@ -177,8 +184,27 @@ export function HubPage() {
)}
+
+ setIsolated(event.target.checked)} data-testid="opencode-isolated-workspace" />
+ Isolated workspace (creates an OpenCode worktree before the agent starts)
+
+ {directory && worktrees.length > 0 && (
+
+ Isolated workspaces
+
+ {worktrees.map((worktree) => (
+
+ {worktree.name}
+ { if (window.confirm(`Hard-reset and clean ${worktree.name}?`)) void api.resetWorktree(directory, worktree.directory); }} data-testid="opencode-worktree-reset">Reset
+ { if (window.confirm(`Force-delete ${worktree.name} and its branch?`)) void api.deleteWorktree(directory, worktree.directory).then(() => setWorktrees((items) => items.filter((item) => item.directory !== worktree.directory))); }} data-testid="opencode-worktree-delete">Delete
+
+ ))}
+
+
+ )}
+
Sessions
diff --git a/client/pages/Notifications.tsx b/client/pages/Notifications.tsx
new file mode 100644
index 00000000..fede05e0
--- /dev/null
+++ b/client/pages/Notifications.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useState } from "react";
+
+import { Alert } from "../ds/alert.js";
+import { Button } from "../ds/button.js";
+import {
+ api,
+ type NotificationPreferences,
+ type NotifyEvent,
+} from "../lib/api.js";
+import { notifyBrowser } from "../lib/useNotifyWatcher.js";
+
+const EVENTS: NotifyEvent[] = ["idle", "error", "abort", "permission", "question", "parked"];
+
+export function NotificationsPage() {
+ const [preferences, setPreferences] = useState
(null);
+ const [tokenConfigured, setTokenConfigured] = useState(false);
+ const [message, setMessage] = useState("");
+ const [error, setError] = useState("");
+ useEffect(() => {
+ void api.notifications().then((result) => {
+ setPreferences(result.preferences);
+ setTokenConfigured(result.tokenConfigured);
+ }).catch((e: Error) => setError(e.message));
+ }, []);
+
+ const save = async () => {
+ if (!preferences) return;
+ try {
+ const result = await api.saveNotifications(preferences);
+ setPreferences(result.preferences);
+ window.dispatchEvent(new Event("opencode-notification-preferences"));
+ setMessage("Saved");
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ };
+
+ const testBrowser = async () => {
+ if (!preferences) return;
+ if ("Notification" in window && Notification.permission === "default") {
+ await Notification.requestPermission();
+ }
+ notifyBrowser(preferences, "idle", "OpenCode notification test");
+ setMessage("Browser test triggered");
+ };
+
+ return (
+
+
+ {error && {error} }
+ {preferences && (
+ <>
+
+ Delivery
+ setPreferences({ ...preferences, ntfy: { ...preferences.ntfy, enabled: e.target.checked } })} data-testid="opencode-ntfy-enabled" />ntfy enabled
+ Server (configured by NTFY_SERVER)
+ Topic setPreferences({ ...preferences, ntfy: { ...preferences.ntfy, topic: e.target.value } })} className="w-full rounded-md border border-[var(--color-border-default)] bg-transparent p-2" data-testid="opencode-ntfy-topic" />
+ Token: {tokenConfigured ? "configured in the environment" : "not configured"}
+ setPreferences({ ...preferences, browser: { ...preferences.browser, desktop: e.target.checked } })} data-testid="opencode-browser-desktop" />Desktop notifications
+ setPreferences({ ...preferences, browser: { ...preferences.browser, sound: e.target.checked } })} data-testid="opencode-browser-sound" />Sound
+ Volume setPreferences({ ...preferences, browser: { ...preferences.browser, volume: Number(e.target.value) } })} className="w-full" data-testid="opencode-browser-volume" />
+ Parked permission after seconds setPreferences({ ...preferences, parkedPermissionSeconds: Number(e.target.value) })} className="w-full rounded-md border border-[var(--color-border-default)] bg-transparent p-2" data-testid="opencode-parked-seconds" />
+
+
+
+
+ void save()} data-testid="opencode-notifications-save">Save
+ void testBrowser()} data-testid="opencode-notifications-test-browser">Test browser
+ void api.testNtfy().then(() => setMessage("ntfy test sent")).catch((e: Error) => setError(e.message))} data-testid="opencode-notifications-test-ntfy">Test ntfy
+ {message && {message} }
+
+ >
+ )}
+
+ );
+}
diff --git a/client/pages/Settings.tsx b/client/pages/Settings.tsx
new file mode 100644
index 00000000..b2d865fa
--- /dev/null
+++ b/client/pages/Settings.tsx
@@ -0,0 +1,89 @@
+import { useEffect, useState } from "react";
+
+import { Alert } from "../ds/alert.js";
+import { Button } from "../ds/button.js";
+import { api, type AppSettings } from "../lib/api.js";
+
+export function SettingsPage() {
+ const [settings, setSettings] = useState(null);
+ const [initial, setInitial] = useState(null);
+ const [error, setError] = useState("");
+ const [saved, setSaved] = useState(false);
+ useEffect(() => {
+ void api.settings().then((result) => {
+ setSettings(result.settings);
+ setInitial(result.settings);
+ }).catch((e: Error) => setError(e.message));
+ }, []);
+
+ const save = async () => {
+ if (!settings) return;
+ const cleared = (["model", "small_model", "default_agent"] as const).find(
+ (key) => initial?.[key] && !settings[key],
+ );
+ if (cleared) {
+ setError(`OpenCode's PATCH API cannot clear '${cleared}'. Remove it from the config file instead.`);
+ return;
+ }
+ setSaved(false);
+ try {
+ const savedSettings = (await api.saveSettings(settings)).settings;
+ setSettings(savedSettings);
+ setInitial(savedSettings);
+ setSaved(true);
+ } catch (e) {
+ setError((e as Error).message);
+ }
+ };
+
+ return (
+
+
+ Saving global settings restarts OpenCode project instances. Avoid saving during an active run.
+ {error && {error} }
+ {settings && (
+
+ )}
+
+ );
+}
diff --git a/client/pages/Tools.tsx b/client/pages/Tools.tsx
new file mode 100644
index 00000000..a99a8f6c
--- /dev/null
+++ b/client/pages/Tools.tsx
@@ -0,0 +1,60 @@
+import { useEffect, useState } from "react";
+import { useSearchParams } from "react-router-dom";
+
+import { Alert } from "../ds/alert.js";
+import { Button } from "../ds/button.js";
+import { api, type McpStatus } from "../lib/api.js";
+
+const DIRECTORY_KEY = "opencode.directory.v1";
+
+export function ToolsPage() {
+ const [params] = useSearchParams();
+ const directory = params.get("directory") ?? localStorage.getItem(DIRECTORY_KEY) ?? "";
+ const [servers, setServers] = useState>({});
+ const [error, setError] = useState("");
+ const [permissions, setPermissions] = useState(null);
+ const [lsp, setLsp] = useState(null);
+ const load = () => directory && Promise.all([api.mcp(directory), api.permissions(directory), api.lsp(directory)]).then(([mcp, rules, languageServers]) => {
+ setServers(mcp.servers);
+ setPermissions(rules.permissions);
+ setLsp(languageServers.servers);
+ }).catch((e: Error) => setError(e.message));
+ useEffect(() => { void load(); }, [directory]);
+
+ return (
+
+
+ {!directory && Open a project on the home page first. }
+ {error && {error} }
+
+ {Object.entries(servers).sort(([a], [b]) => a.localeCompare(b)).map(([name, status]) => {
+ const connected = status.status === "connected";
+ const detail = "error" in status ? status.error : status.status.replaceAll("_", " ");
+ return (
+
+
+ {name}
+ {detail}
+
+ void api.setMcp(directory, name, !connected).then((result) => setServers(result.servers)).catch((e: Error) => setError(e.message))} data-testid={`opencode-mcp-${connected ? "disconnect" : "connect"}`}>
+ {connected ? "Disconnect" : "Connect"}
+
+
+ );
+ })}
+
+
+ Language servers
+ {JSON.stringify(lsp, null, 2)}
+
+
+ Effective permissions
+ Read-only. Edit opencode.jsonc to change these last-match-wins rules.
+ {JSON.stringify(permissions, null, 2)}
+
+
+ );
+}
diff --git a/client/public/icon.svg b/client/public/icon.svg
new file mode 100644
index 00000000..78ac0b17
--- /dev/null
+++ b/client/public/icon.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/client/public/manifest.webmanifest b/client/public/manifest.webmanifest
new file mode 100644
index 00000000..89ccbab9
--- /dev/null
+++ b/client/public/manifest.webmanifest
@@ -0,0 +1,17 @@
+{
+ "name": "Custom DCA OpenCode",
+ "short_name": "OpenCode",
+ "description": "A local coding-agent IDE backed by OpenCode.",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#101418",
+ "theme_color": "#24b47e",
+ "icons": [
+ {
+ "src": "/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/docs/research/opencode-build-plan.html b/docs/research/opencode-build-plan.html
index 499d264c..768a4eb7 100644
--- a/docs/research/opencode-build-plan.html
+++ b/docs/research/opencode-build-plan.html
@@ -100,7 +100,7 @@ Build Plan
-v2 changes (Aug 21): dropped preview proxy, Terminal page, web PTY, manager runs (+ Postgres), latency probes, permissions
editor (now read-only display), providers page, disk bar — most existed only to work around the agent-canvas container boundary. R2 auto-resume is
accepted, with detection-only UX added to Phase 2.
All decisions resolved: git identity = local tools as-is (no bot plugin) · Resume = prefill composer ·
fresh new public repo . New:
one-shot execution strategy using background opencode subagents on
anthropic/claude-opus-5.
+Completed Aug 21: all implementation waves and the deterministic E2E wave are green. Preview lifecycle , Terminal, web PTY, manager runs (+ Postgres), latency probes, permissions editor (read-only display retained), providers page and disk bar were dropped. The allowlisted read-only preview reverse proxy remains for phones, where there is no cmux pane. R2 auto-resume is accepted, with detection-only UX. Git identity = local tools as-is · Resume = prefill composer · public repo.
— Overview & what was dropped
@@ -127,7 +127,7 @@ — Overview
Dropped, and why
Dropped Was Reason
- Live preview proxy 7 BFF routes + 524-line panel Existed because the dev server ran inside agent-canvas. It runs on your host now — open localhost:<port> in a cmux browser pane
+ Preview lifecycle start/stop/logs/status plumbing Dropped. A GET/HEAD-only, port-allowlisted localhost reverse proxy remains for mobile access; it has no lifecycle control, credentials, WebSockets or HTML rewriting.
Terminal page read-only bash-event history No bash_events store in OpenCode (rebuild, not port) — and the Commands panel already answers "what did the agent run", with .sh export
Web PTY Phase 8 "new capability" You live in cmux with Ghostty terminals. A terminal in a browser tab in a terminal multiplexer is not a feature
Manager runs ~2,200 server lines + UI + Postgres The manager-children cmux skill covers waves-of-workers-in-worktrees. Dropping this removes the last Docker container
@@ -366,7 +366,7 @@ 4 Workspace panels
P4 Files · Changes
~1.5 days
-
Goal: the two surviving workspace panels (preview, terminal, disk bar dropped).
+
Goal: files and changes, plus the mobile-only-safe preview reverse proxy (preview lifecycle, terminal and disk bar dropped).
Panel Backing Work
Files GET /file?path= → FileNode[]; GET /file/content?path= (text or base64+mimeType)Shim to the client's {path,dirs,files,nextPageId} shape; one level per call, recurse in the BFF
@@ -423,12 +423,12 @@ 6 Settings
Page Backing Note
Notifications BFF-local JSON (.state/ntfy-prefs.json) Per-event toggles × channels, volume, test buttons. misc_settings KV replaced by owning the store
Agent settings PATCH /global/config → compaction{auto,prune,reserved}, model, small_model, default_agent, subagent_depthCondenser page survives, re-pointed and broader
- MCP manager NEW POST /mcp (add) · connect/disconnect/auth · Config.mcp durable enable/disableOpenHands was read-only + a sync script; full CRUD now
+ MCP manager NEW GET /mcp status + runtime connect/disconnectDurable CRUD/auth deferred: effective MCP config currently comes from a higher-precedence source than empty global config, so claiming persistence would be dishonest
Permissions READ-ONLY GET /config effective rules + GET /api/permission/savedDisplay what's active; author in opencode.jsonc ($schema autocomplete beats a form)
Providers page · skills toggles — Dropped / no equivalent
Plan/Build: deferred, not designed. No per-action security_risk exists; if it returns it's agent-switch (POST /api/session/{id}/agent) + permission rulesets. Wait to see whether the Phase 0 policy alone suffices.
-
Done when: per-event notification toggles work, compaction threshold is editable, and gitlab-mcp can be re-authed — all without touching a config file.
+
Done: per-event browser/ntfy toggles, volume/test controls, compaction settings, MCP status/error display and runtime connect/disconnect are covered by mock-backed E2E.
diff --git a/playwright.config.ts b/playwright.config.ts
index e8c49e32..1bd60a62 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -9,6 +9,7 @@ import { defineConfig } from "@playwright/test";
// and the real BFF code path, with only the agent itself faked.
const PORT = Number(process.env.PORT || 3410);
const MOCK_PORT = Number(process.env.MOCK_OPENCODE_PORT || 4599);
+const PREVIEW_PORT = Number(process.env.MOCK_PREVIEW_PORT || 4600);
export default defineConfig({
testDir: "tests/e2e",
@@ -28,6 +29,12 @@ export default defineConfig({
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
+ {
+ command: `npx tsx tests/e2e/mock-preview.ts ${PREVIEW_PORT}`,
+ port: PREVIEW_PORT,
+ reuseExistingServer: !process.env.CI,
+ timeout: 30_000,
+ },
{
// Test the built bundle, not the dev server — the predecessor had bugs
// that only appeared after a production build.
@@ -38,6 +45,11 @@ export default defineConfig({
env: {
PORT: String(PORT),
OPENCODE_URL: `http://127.0.0.1:${MOCK_PORT}`,
+ PROJECTS_DIR: "/tmp",
+ OPENCODE_WORKTREE_ROOT: "/tmp",
+ NOTIFICATION_PREFS_FILE: "/tmp/custom-dca-opencode-e2e-notifications.json",
+ PREVIEW_ALLOWED_PORTS: String(PREVIEW_PORT),
+ GITHUB_API_URL: `http://127.0.0.1:${PREVIEW_PORT}`,
},
},
],
diff --git a/server/forge.ts b/server/forge.ts
new file mode 100644
index 00000000..a2668626
--- /dev/null
+++ b/server/forge.ts
@@ -0,0 +1,101 @@
+export type ReviewRef =
+ | { forge: "github"; url: string; owner: string; repo: string; number: number }
+ | { forge: "gitlab"; url: string; project: string; number: number };
+
+export interface ReviewStatus {
+ url: string;
+ forge: "github" | "gitlab";
+ title: string;
+ state: string;
+ author: string;
+ pipeline: string | null;
+ mergeable: boolean | null;
+ headSha: string;
+}
+
+function gitlabOrigin(): string {
+ return new URL(process.env.GITLAB_BASE_URL || "https://gitlab.com").origin;
+}
+
+export function parseReviewUrl(value: string): ReviewRef {
+ const url = new URL(value);
+ const github = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/.exec(url.pathname);
+ if (url.origin === "https://github.com" && github) {
+ return { forge: "github", url: url.toString(), owner: github[1], repo: github[2], number: Number(github[3]) };
+ }
+ const marker = "/-/merge_requests/";
+ if (url.origin === gitlabOrigin() && url.pathname.includes(marker)) {
+ const [project, suffix] = url.pathname.split(marker);
+ if (project && /^\d+\/?$/.test(suffix)) {
+ return { forge: "gitlab", url: url.toString(), project: project.replace(/^\//, ""), number: Number(suffix.replace("/", "")) };
+ }
+ }
+ throw new Error("only GitHub pull-request and configured GitLab merge-request URLs are supported");
+}
+
+async function forgeFetch(url: URL, token: string | undefined, method = "GET", body?: unknown): Promise {
+ const headers: Record = { Accept: "application/json" };
+ if (token) headers.Authorization = `Bearer ${token}`;
+ if (body !== undefined) headers["Content-Type"] = "application/json";
+ const response = await fetch(url, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), redirect: "error", signal: AbortSignal.timeout(15_000) });
+ if (!response.ok) throw new Error(`forge returned HTTP ${response.status}`);
+ const text = await response.text();
+ return (text ? JSON.parse(text) : {}) as T;
+}
+
+export async function getReviewStatus(ref: ReviewRef): Promise {
+ if (ref.forge === "github") {
+ const api = new URL(process.env.GITHUB_API_URL || "https://api.github.com");
+ const pr = await forgeFetch>(
+ new URL(`/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}/pulls/${ref.number}`, api),
+ process.env.GITHUB_TOKEN,
+ );
+ let pipeline: string | null = null;
+ if (typeof pr.head?.sha === "string") {
+ const checks = await forgeFetch<{ check_runs?: Array<{ conclusion?: string; status?: string }> }>(
+ new URL(`/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}/commits/${pr.head.sha}/check-runs`, api),
+ process.env.GITHUB_TOKEN,
+ ).catch(() => ({ check_runs: [] }));
+ const states = (checks.check_runs ?? []).map((check) => check.conclusion ?? check.status ?? "unknown");
+ const pending = states.some((state) => ["in_progress", "queued", "pending", "requested", "waiting"].includes(state));
+ const passed = states.length > 0 && states.every((state) => ["success", "neutral", "skipped"].includes(state));
+ pipeline = pending ? "running" : passed ? "passed" : states.length ? "failed" : null;
+ }
+ return {
+ url: ref.url,
+ forge: "github",
+ title: String(pr.title ?? "Pull request"),
+ state: String(pr.state ?? "unknown"),
+ author: String(pr.user?.login ?? "unknown"),
+ pipeline,
+ mergeable: typeof pr.mergeable === "boolean" ? pr.mergeable : null,
+ headSha: String(pr.head?.sha ?? ""),
+ };
+ }
+
+ const api = new URL(`/api/v4/projects/${encodeURIComponent(ref.project)}/`, process.env.GITLAB_BASE_URL || "https://gitlab.com");
+ const token = process.env.GITLAB_TOKEN;
+ const mr = await forgeFetch>(new URL(`merge_requests/${ref.number}`, api), token);
+ const pipelines = await forgeFetch>(new URL(`merge_requests/${ref.number}/pipelines`, api), token).catch(() => []);
+ return {
+ url: ref.url,
+ forge: "gitlab",
+ title: String(mr.title ?? "Merge request"),
+ state: String(mr.state ?? "unknown"),
+ author: String(mr.author?.username ?? "unknown"),
+ pipeline: pipelines[0]?.status ?? null,
+ mergeable: mr.merge_status === "can_be_merged" ? true : mr.merge_status ? false : null,
+ headSha: String(mr.sha ?? ""),
+ };
+}
+
+export async function mergeReview(ref: ReviewRef, expectedSha: string): Promise {
+ if (!/^[a-f0-9]{6,64}$/i.test(expectedSha)) throw new Error("a reviewed head SHA is required");
+ if (ref.forge === "github") {
+ const api = new URL(process.env.GITHUB_API_URL || "https://api.github.com");
+ await forgeFetch(new URL(`/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.repo)}/pulls/${ref.number}/merge`, api), process.env.GITHUB_TOKEN, "PUT", { sha: expectedSha });
+ return;
+ }
+ const api = new URL(`/api/v4/projects/${encodeURIComponent(ref.project)}/merge_requests/${ref.number}/merge`, process.env.GITLAB_BASE_URL || "https://gitlab.com");
+ await forgeFetch(api, process.env.GITLAB_TOKEN, "PUT", { sha: expectedSha });
+}
diff --git a/server/index.ts b/server/index.ts
index e8747c06..a8893f36 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -8,8 +8,8 @@
// - runs what the OpenCode API does not expose: git history, forge APIs,
// notification transport
//
-// Phase 0 ships the shell: config, health, static serving. Routes land in
-// later phases (see AGENTS.md).
+// All browser-facing API routes are registered here; feature modules own the
+// upstream and filesystem details so this entrypoint remains auditable.
import { fileURLToPath } from "node:url";
import path from "node:path";
@@ -19,6 +19,15 @@ import dotenv from "dotenv";
import { readOpencodeConfig, checkHealth, EXPECTED_SERVER_VERSION } from "./opencode/client.js";
import { EventBus } from "./opencode/events.js";
import { sessionRoutes } from "./routes/sessions.js";
+import { settingsRoutes } from "./routes/settings.js";
+import { mcpRoutes } from "./routes/mcp.js";
+import { workspaceRoutes } from "./routes/workspace.js";
+import { parseAllowedPorts, previewRoutes } from "./routes/preview.js";
+import { worktreeRoutes } from "./routes/worktrees.js";
+import { notificationRoutes } from "./routes/notifications.js";
+import { PreferenceStore } from "./notifications/preferences.js";
+import { NotificationService } from "./notifications/service.js";
+import { forgeRoutes } from "./routes/forge.js";
dotenv.config();
@@ -34,8 +43,19 @@ bus.on("error", (error: unknown) => {
console.warn("[bus]", error instanceof Error ? error.message : error);
});
bus.start();
+const notificationStore = new PreferenceStore();
+const notificationService = new NotificationService(opencode, bus, notificationStore);
+notificationService.start();
app.use("/api", sessionRoutes(opencode, bus));
+app.use("/api", settingsRoutes(opencode));
+app.use("/api", mcpRoutes(opencode));
+app.use("/api", workspaceRoutes(opencode));
+app.use("/api", worktreeRoutes(opencode, bus));
+app.use("/api", notificationRoutes(notificationStore));
+app.use("/api", forgeRoutes());
+const opencodePort = Number(new URL(opencode.baseUrl).port || 80);
+app.use("/api", previewRoutes(parseAllowedPorts(process.env.PREVIEW_ALLOWED_PORTS, [PORT, opencodePort])));
/**
* Liveness for this BFF plus reachability of the OpenCode server behind it.
diff --git a/server/notifications/ntfy.ts b/server/notifications/ntfy.ts
new file mode 100644
index 00000000..e6470106
--- /dev/null
+++ b/server/notifications/ntfy.ts
@@ -0,0 +1,36 @@
+import { trustedNtfyOrigin, type NotificationPreferences, type NotifyEvent } from "./preferences.js";
+
+export interface NotificationMessage {
+ event: NotifyEvent;
+ title: string;
+ body: string;
+ priority?: "default" | "high";
+ click?: string;
+}
+
+export async function sendNtfy(
+ preferences: NotificationPreferences,
+ message: NotificationMessage,
+ token = process.env.NTFY_TOKEN,
+): Promise {
+ const { ntfy } = preferences;
+ if (!ntfy.enabled || !ntfy.topic || !ntfy.events[message.event]) return;
+ const trusted = trustedNtfyOrigin();
+ if (ntfy.server !== trusted) throw new Error("refusing to send ntfy credentials to an untrusted origin");
+ const headers: Record = {
+ "Content-Type": "text/plain; charset=utf-8",
+ Title: message.title,
+ Priority: message.priority ?? "default",
+ Tags: message.event,
+ };
+ if (message.click) headers.Click = message.click;
+ if (token) headers.Authorization = `Bearer ${token}`;
+ const response = await fetch(`${trusted}/${encodeURIComponent(ntfy.topic)}`, {
+ method: "POST",
+ headers,
+ body: message.body,
+ redirect: "manual",
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!response.ok) throw new Error(`ntfy returned HTTP ${response.status}`);
+}
diff --git a/server/notifications/preferences.ts b/server/notifications/preferences.ts
new file mode 100644
index 00000000..514a44a4
--- /dev/null
+++ b/server/notifications/preferences.ts
@@ -0,0 +1,140 @@
+import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
+import path from "node:path";
+
+export const NOTIFY_EVENTS = ["idle", "error", "abort", "permission", "question", "parked"] as const;
+export type NotifyEvent = (typeof NOTIFY_EVENTS)[number];
+
+export interface NotificationPreferences {
+ version: 1;
+ ntfy: {
+ enabled: boolean;
+ server: string;
+ topic: string;
+ events: Record;
+ };
+ browser: {
+ desktop: boolean;
+ sound: boolean;
+ volume: number;
+ events: Record;
+ };
+ parkedPermissionSeconds: number;
+}
+
+const DEFAULT_EVENTS: Record = {
+ idle: true,
+ error: true,
+ abort: false,
+ permission: true,
+ question: true,
+ parked: true,
+};
+
+export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences = {
+ version: 1,
+ ntfy: {
+ enabled: false,
+ server: "https://ntfy.sh",
+ topic: "",
+ events: { ...DEFAULT_EVENTS },
+ },
+ browser: {
+ desktop: true,
+ sound: false,
+ volume: 0.5,
+ events: { ...DEFAULT_EVENTS },
+ },
+ parkedPermissionSeconds: 30,
+};
+
+function eventMap(value: unknown): Record {
+ const source = value && typeof value === "object" ? (value as Record) : {};
+ return Object.fromEntries(
+ NOTIFY_EVENTS.map((event) => [event, typeof source[event] === "boolean" ? source[event] : DEFAULT_EVENTS[event]]),
+ ) as Record;
+}
+
+export function trustedNtfyOrigin(env: NodeJS.ProcessEnv = process.env): string {
+ const configured = env.NTFY_SERVER ?? "https://ntfy.sh";
+ const url = new URL(configured);
+ if (!["http:", "https:"].includes(url.protocol) || url.pathname !== "/") {
+ throw new Error("NTFY_SERVER must be an HTTP(S) origin");
+ }
+ return url.origin;
+}
+
+function validServer(value: unknown): string {
+ if (typeof value !== "string") throw new Error("ntfy.server must be an HTTP(S) origin");
+ const url = new URL(value);
+ if (!["http:", "https:"].includes(url.protocol) || url.pathname !== "/") {
+ throw new Error("ntfy.server must be an HTTP(S) origin");
+ }
+ const trusted = trustedNtfyOrigin();
+ if (url.origin !== trusted) {
+ throw new Error("ntfy.server is fixed by NTFY_SERVER on the BFF");
+ }
+ return trusted;
+}
+
+function validTopic(value: unknown): string {
+ if (typeof value !== "string" || (value && !/^[A-Za-z0-9_.~-]{1,64}$/.test(value))) {
+ throw new Error("ntfy.topic must use 1-64 letters, digits, '.', '_', '~' or '-'");
+ }
+ return value;
+}
+
+export function normalizePreferences(value: unknown): NotificationPreferences {
+ const source = value && typeof value === "object" ? (value as Record) : {};
+ const ntfy = source.ntfy && typeof source.ntfy === "object" ? (source.ntfy as Record) : {};
+ const browser = source.browser && typeof source.browser === "object" ? (source.browser as Record) : {};
+ const seconds = Number(source.parkedPermissionSeconds ?? 30);
+ const volume = Number(browser.volume ?? 0.5);
+ return {
+ version: 1,
+ ntfy: {
+ enabled: ntfy.enabled === true,
+ server: validServer(ntfy.server ?? "https://ntfy.sh"),
+ topic: validTopic(ntfy.topic ?? ""),
+ events: eventMap(ntfy.events),
+ },
+ browser: {
+ desktop: browser.desktop !== false,
+ sound: browser.sound === true,
+ volume: Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 0.5,
+ events: eventMap(browser.events),
+ },
+ parkedPermissionSeconds: Number.isFinite(seconds) ? Math.max(5, Math.min(3600, Math.trunc(seconds))) : 30,
+ };
+}
+
+export class PreferenceStore {
+ constructor(
+ readonly file = process.env.NOTIFICATION_PREFS_FILE || path.resolve(process.cwd(), ".state/notification-prefs.json"),
+ ) {}
+
+ async read(): Promise {
+ try {
+ return normalizePreferences(JSON.parse(await readFile(this.file, "utf8")));
+ } catch {
+ const topic = process.env.NTFY_TOPIC ?? DEFAULT_NOTIFICATION_PREFERENCES.ntfy.topic;
+ return normalizePreferences({
+ ...DEFAULT_NOTIFICATION_PREFERENCES,
+ ntfy: {
+ ...DEFAULT_NOTIFICATION_PREFERENCES.ntfy,
+ enabled: Boolean(topic),
+ server: trustedNtfyOrigin(),
+ topic,
+ },
+ });
+ }
+ }
+
+ async write(value: unknown): Promise {
+ const normalized = normalizePreferences(value);
+ await mkdir(path.dirname(this.file), { recursive: true });
+ const temporary = `${this.file}.${process.pid}.tmp`;
+ await writeFile(temporary, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
+ await rename(temporary, this.file);
+ return normalized;
+ }
+}
diff --git a/server/notifications/service.ts b/server/notifications/service.ts
new file mode 100644
index 00000000..d1eb3bb8
--- /dev/null
+++ b/server/notifications/service.ts
@@ -0,0 +1,123 @@
+import type { OpencodeConfig } from "../opencode/client.js";
+import { request } from "../opencode/client.js";
+import type { EventBus, OpencodeEvent } from "../opencode/events.js";
+import { sendNtfy, type NotificationMessage } from "./ntfy.js";
+import { PreferenceStore, type NotifyEvent } from "./preferences.js";
+
+export interface PermissionRequest {
+ id: string;
+ sessionID: string;
+ permission: string;
+ patterns: string[];
+}
+
+export function classifyEvent(event: OpencodeEvent): NotifyEvent | null {
+ if (event.type === "session.idle") return "idle";
+ if (event.type === "permission.asked") return "permission";
+ if (event.type === "question.asked") return "question";
+ if (event.type === "session.error") {
+ const error = event.properties.error;
+ return error && typeof error === "object" && (error as Record).name === "MessageAbortedError"
+ ? "abort"
+ : "error";
+ }
+ return null;
+}
+
+function permission(event: OpencodeEvent): PermissionRequest | null {
+ const source = event.properties;
+ return typeof source.id === "string" && typeof source.sessionID === "string"
+ ? {
+ id: source.id,
+ sessionID: source.sessionID,
+ permission: typeof source.permission === "string" ? source.permission : "permission",
+ patterns: Array.isArray(source.patterns) ? source.patterns.map(String) : [],
+ }
+ : null;
+}
+
+export class NotificationService {
+ private timers = new Map();
+ private seen = new Map();
+ private readonly onEvent = (event: OpencodeEvent) => void this.handle(event);
+
+ constructor(
+ private readonly config: OpencodeConfig,
+ private readonly bus: EventBus,
+ private readonly store: PreferenceStore,
+ ) {}
+
+ start(): void {
+ this.bus.on("event", this.onEvent);
+ }
+
+ stop(): void {
+ this.bus.off("event", this.onEvent);
+ for (const timer of this.timers.values()) clearTimeout(timer);
+ this.timers.clear();
+ }
+
+ private async handle(event: OpencodeEvent): Promise {
+ if (event.type === "permission.replied") {
+ const id = String(event.properties.requestID ?? "");
+ const key = `${event.directory ?? ""}:${id}`;
+ const timer = this.timers.get(key);
+ if (timer) clearTimeout(timer);
+ this.timers.delete(key);
+ return;
+ }
+ const kind = classifyEvent(event);
+ if (!kind) return;
+ const identity = String(event.properties.id ?? event.properties.requestID ?? event.properties.sessionID ?? "");
+ const dedupeKey = `${event.type}:${identity}`;
+ const now = Date.now();
+ if (now - (this.seen.get(dedupeKey) ?? 0) < 5_000) return;
+ this.seen.set(dedupeKey, now);
+ if (this.seen.size > 500) {
+ for (const [key, timestamp] of this.seen) {
+ if (now - timestamp > 60_000) this.seen.delete(key);
+ }
+ }
+ const preferences = await this.store.read();
+ const sessionID = String(event.properties.sessionID ?? "");
+ const details = kind === "permission" ? permission(event) : null;
+ const message: NotificationMessage = {
+ event: kind,
+ title: kind === "permission" ? "OpenCode needs permission" : `OpenCode: ${kind}`,
+ body: details ? `${details.permission} requires review` : `Session ${sessionID || "updated"}`,
+ };
+ await sendNtfy(preferences, message).catch((error) => console.warn("[ntfy]", String(error)));
+ if (kind === "permission" && details && event.directory) {
+ this.scheduleParked(event.directory, details, preferences.parkedPermissionSeconds);
+ }
+ }
+
+ private scheduleParked(directory: string, pending: PermissionRequest, seconds: number): void {
+ const key = `${directory}:${pending.id}`;
+ const existing = this.timers.get(key);
+ if (existing) clearTimeout(existing);
+ this.timers.set(
+ key,
+ setTimeout(() => {
+ this.timers.delete(key);
+ void request(this.config, "/permission", { directory })
+ .then(async (requests) => {
+ if (!requests.some((item) => item.id === pending.id)) return;
+ const preferences = await this.store.read();
+ await sendNtfy(preferences, {
+ event: "parked",
+ title: "OpenCode is parked",
+ body: `${pending.permission} has waited ${seconds}s for a reply`,
+ priority: "high",
+ });
+ this.bus.emit("event", {
+ type: "notification.parked",
+ properties: { requestID: pending.id, sessionID: pending.sessionID },
+ directory,
+ } satisfies OpencodeEvent);
+ })
+ .catch((error) => console.warn("[parked-permission]", String(error)));
+ }, seconds * 1000),
+ );
+ }
+}
diff --git a/server/opencode/config.ts b/server/opencode/config.ts
new file mode 100644
index 00000000..ad856752
--- /dev/null
+++ b/server/opencode/config.ts
@@ -0,0 +1,122 @@
+import { request, type OpencodeConfig } from "./client.js";
+
+export interface AppSettings {
+ model?: string;
+ small_model?: string;
+ default_agent?: string;
+ subagent_depth?: number;
+ compaction?: {
+ auto?: boolean;
+ prune?: boolean;
+ reserved?: number;
+ };
+}
+
+function optionalString(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
+
+function optionalCount(value: unknown): number | undefined {
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined;
+}
+
+/** Return only settings safe to expose; provider/MCP secrets are never copied. */
+export function publicSettings(value: unknown): AppSettings {
+ const source = value && typeof value === "object" ? (value as Record) : {};
+ const compactionSource =
+ source.compaction && typeof source.compaction === "object"
+ ? (source.compaction as Record)
+ : {};
+ const compaction: NonNullable = {};
+ if (typeof compactionSource.auto === "boolean") compaction.auto = compactionSource.auto;
+ if (typeof compactionSource.prune === "boolean") compaction.prune = compactionSource.prune;
+ const reserved = optionalCount(compactionSource.reserved);
+ if (reserved !== undefined) compaction.reserved = reserved;
+
+ const settings: AppSettings = {};
+ const model = optionalString(source.model);
+ const smallModel = optionalString(source.small_model);
+ const defaultAgent = optionalString(source.default_agent);
+ const depth = optionalCount(source.subagent_depth);
+ if (model) settings.model = model;
+ if (smallModel) settings.small_model = smallModel;
+ if (defaultAgent) settings.default_agent = defaultAgent;
+ if (depth !== undefined) settings.subagent_depth = depth;
+ if (Object.keys(compaction).length) settings.compaction = compaction;
+ return settings;
+}
+
+export function validateSettingsPatch(value: unknown): AppSettings {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error("settings patch must be an object");
+ }
+ const source = value as Record;
+ const allowed = new Set(["model", "small_model", "default_agent", "subagent_depth", "compaction"]);
+ for (const key of Object.keys(source)) {
+ if (!allowed.has(key)) throw new Error(`unsupported setting '${key}'`);
+ }
+ if ("subagent_depth" in source && optionalCount(source.subagent_depth) === undefined) {
+ throw new Error("subagent_depth must be a non-negative integer");
+ }
+ if ("compaction" in source) {
+ if (!source.compaction || typeof source.compaction !== "object" || Array.isArray(source.compaction)) {
+ throw new Error("compaction must be an object");
+ }
+ const compaction = source.compaction as Record;
+ for (const key of Object.keys(compaction)) {
+ if (!new Set(["auto", "prune", "reserved"]).has(key)) {
+ throw new Error(`unsupported compaction setting '${key}'`);
+ }
+ }
+ if ("auto" in compaction && typeof compaction.auto !== "boolean") {
+ throw new Error("compaction.auto must be boolean");
+ }
+ if ("prune" in compaction && typeof compaction.prune !== "boolean") {
+ throw new Error("compaction.prune must be boolean");
+ }
+ if ("reserved" in compaction && optionalCount(compaction.reserved) === undefined) {
+ throw new Error("compaction.reserved must be a non-negative integer");
+ }
+ }
+ return publicSettings(source);
+}
+
+export async function getGlobalSettings(config: OpencodeConfig): Promise {
+ return publicSettings(await request(config, "/global/config"));
+}
+
+export async function patchGlobalSettings(
+ config: OpencodeConfig,
+ patch: AppSettings,
+): Promise {
+ return publicSettings(
+ await request(config, "/global/config", { method: "PATCH", body: patch }),
+ );
+}
+
+export async function getEffectivePermissions(
+ config: OpencodeConfig,
+ directory: string,
+): Promise {
+ const effective = await request>(config, "/config", { directory });
+ return effective.permission ?? null;
+}
+
+interface ProviderCatalogue {
+ providers?: Array<{
+ id?: string;
+ models?: Record;
+ }>;
+}
+
+export async function getModelContextLimit(
+ config: OpencodeConfig,
+ directory: string,
+ providerID: string,
+ modelID: string,
+): Promise {
+ const catalogue = await request(config, "/config/providers", { directory });
+ const provider = catalogue.providers?.find((item) => item.id === providerID);
+ const limit = provider?.models?.[modelID]?.limit?.context;
+ return typeof limit === "number" && limit > 0 ? limit : null;
+}
diff --git a/server/opencode/mcp.ts b/server/opencode/mcp.ts
new file mode 100644
index 00000000..86185314
--- /dev/null
+++ b/server/opencode/mcp.ts
@@ -0,0 +1,28 @@
+import { request, type OpencodeConfig } from "./client.js";
+
+export type McpStatus =
+ | { status: "connected" }
+ | { status: "disabled" }
+ | { status: "failed"; error: string }
+ | { status: "needs_auth" }
+ | { status: "needs_client_registration"; error: string };
+
+export type McpStatusMap = Record;
+
+export function listMcp(config: OpencodeConfig, directory: string): Promise {
+ return request(config, "/mcp", { directory });
+}
+
+export async function setMcpConnected(
+ config: OpencodeConfig,
+ directory: string,
+ name: string,
+ connected: boolean,
+): Promise {
+ await request(config, `/mcp/${encodeURIComponent(name)}/${connected ? "connect" : "disconnect"}`, {
+ method: "POST",
+ directory,
+ });
+ // The boolean only means the operation ran, not that connection succeeded.
+ return listMcp(config, directory);
+}
diff --git a/server/opencode/sessions.ts b/server/opencode/sessions.ts
index 9890662f..95213056 100644
--- a/server/opencode/sessions.ts
+++ b/server/opencode/sessions.ts
@@ -42,7 +42,7 @@ interface RawSession {
directory?: string;
parentID?: string;
agent?: string;
- model?: { providerID?: string; modelID?: string };
+ model?: { providerID?: string; modelID?: string; id?: string };
cost?: number;
tokens?: {
input?: number;
@@ -61,7 +61,9 @@ export function toSummary(raw: RawSession, running: boolean): SessionSummary {
directory: raw.directory ?? "",
parentID: raw.parentID,
agent: raw.agent,
- model: raw.model,
+ model: raw.model
+ ? { providerID: raw.model.providerID, modelID: raw.model.modelID ?? raw.model.id }
+ : undefined,
cost: raw.cost ?? 0,
tokens: {
input: raw.tokens?.input ?? 0,
@@ -155,6 +157,7 @@ export interface PromptInput {
text: string;
agent?: string;
model?: { providerID: string; modelID: string };
+ attachments?: Array<{ filename: string; mime: string; url: string }>;
}
/**
@@ -176,7 +179,15 @@ export async function prompt(
body: {
...(input.agent ? { agent: input.agent } : {}),
...(input.model ? { model: input.model } : {}),
- parts: [{ type: "text", text: input.text }],
+ parts: [
+ { type: "text", text: input.text },
+ ...(input.attachments ?? []).map((attachment) => ({
+ type: "file" as const,
+ mime: attachment.mime,
+ filename: attachment.filename,
+ url: attachment.url,
+ })),
+ ],
},
});
}
diff --git a/server/opencode/workspace.ts b/server/opencode/workspace.ts
new file mode 100644
index 00000000..4e901c49
--- /dev/null
+++ b/server/opencode/workspace.ts
@@ -0,0 +1,114 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+import { request, type OpencodeConfig } from "./client.js";
+import { isSensitiveWorkspacePath } from "../paths.js";
+
+const execFileAsync = promisify(execFile);
+
+export interface WorkspaceNode {
+ name: string;
+ path: string;
+ type: "file" | "directory";
+ ignored: boolean;
+}
+
+export interface WorkspaceFile {
+ path: string;
+ type: "text" | "binary";
+ content: string;
+ encoding?: "base64";
+ mimeType?: string;
+}
+
+export interface VcsFileDiff {
+ file: string;
+ patch?: string;
+ additions: number;
+ deletions: number;
+ status?: "added" | "deleted" | "modified";
+}
+
+export interface GitCommit {
+ sha: string;
+ shortSha: string;
+ subject: string;
+ author: string;
+ authoredAt: string;
+}
+
+interface RawFileNode {
+ name?: string;
+ path?: string;
+ absolute?: string;
+ type?: string;
+ ignored?: boolean;
+}
+
+export async function listWorkspace(
+ config: OpencodeConfig,
+ directory: string,
+ relativePath: string,
+): Promise<{ path: string; dirs: WorkspaceNode[]; files: WorkspaceNode[]; nextPageId: null }> {
+ const raw = await request(config, "/file", {
+ directory,
+ query: { path: relativePath },
+ });
+ const nodes = raw.map((node) => ({
+ name: node.name ?? (node.path?.split("/").pop() || ""),
+ path: node.path ?? node.name ?? "",
+ type: node.type === "directory" ? ("directory" as const) : ("file" as const),
+ ignored: node.ignored === true,
+ })).filter((node) => !node.ignored && !isSensitiveWorkspacePath(node.path));
+ return {
+ path: relativePath,
+ dirs: nodes.filter((node) => node.type === "directory").sort((a, b) => a.name.localeCompare(b.name)),
+ files: nodes.filter((node) => node.type === "file").sort((a, b) => a.name.localeCompare(b.name)),
+ nextPageId: null,
+ };
+}
+
+export async function readWorkspaceFile(
+ config: OpencodeConfig,
+ directory: string,
+ relativePath: string,
+): Promise {
+ const file = await request>(config, "/file/content", {
+ directory,
+ query: { path: relativePath },
+ });
+ return { path: relativePath, ...file };
+}
+
+export function listChanges(
+ config: OpencodeConfig,
+ directory: string,
+ mode: "git" | "branch",
+ context: number,
+): Promise {
+ return request(config, "/vcs/diff", {
+ directory,
+ query: { mode, context },
+ }).then((changes) => changes.filter((change) => !isSensitiveWorkspacePath(change.file)));
+}
+
+export function parseCommits(value: string): GitCommit[] {
+ return value
+ .split("\x1e")
+ .map((record) => record.trim())
+ .filter(Boolean)
+ .map((record) => {
+ const [sha = "", shortSha = "", subject = "", author = "", authoredAt = ""] = record.split("\x00");
+ return { sha, shortSha, subject, author, authoredAt };
+ });
+}
+
+export async function listCommits(directory: string, limit: number): Promise {
+ const safeLimit = Math.max(1, Math.min(100, Math.trunc(limit) || 50));
+ const { stdout } = await execFileAsync(
+ "git",
+ ["-C", directory, "log", "-n", String(safeLimit), "--date=iso-strict", "--pretty=format:%H%x00%h%x00%s%x00%an%x00%aI%x1e"],
+ { timeout: 10_000, maxBuffer: 2 * 1024 * 1024, encoding: "utf8" },
+ );
+ return parseCommits(stdout);
+}
diff --git a/server/opencode/worktrees.ts b/server/opencode/worktrees.ts
new file mode 100644
index 00000000..64fc3a4d
--- /dev/null
+++ b/server/opencode/worktrees.ts
@@ -0,0 +1,139 @@
+import path from "node:path";
+import { realpath } from "node:fs/promises";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+import { request, type OpencodeConfig } from "./client.js";
+import type { EventBus, OpencodeEvent } from "./events.js";
+import { requireProjectDirectory, worktreesRoot } from "../paths.js";
+
+const execFileAsync = promisify(execFile);
+
+export interface Worktree {
+ name: string;
+ branch?: string;
+ directory: string;
+}
+
+export async function listWorktrees(config: OpencodeConfig, directory: string): Promise {
+ const directories = await request(config, "/experimental/worktree", { directory });
+ return directories.map((item) => ({ name: path.basename(item), directory: item }));
+}
+
+function worktreeFromEvent(event: OpencodeEvent): Worktree | null {
+ const source =
+ event.properties.info && typeof event.properties.info === "object"
+ ? (event.properties.info as Record)
+ : event.properties;
+ // Real OpenCode puts the new directory on the global-event envelope and
+ // emits only {name, branch} in properties.
+ const directory = event.directory ?? (typeof source.directory === "string" ? source.directory : undefined);
+ if (!directory) return null;
+ return {
+ name: typeof source.name === "string" ? source.name : path.basename(directory),
+ ...(typeof source.branch === "string" ? { branch: source.branch } : {}),
+ directory,
+ };
+}
+
+/** Create before prompting, and wait for checkout/bootstrap to actually finish. */
+export async function createWorktree(
+ config: OpencodeConfig,
+ bus: EventBus,
+ directory: string,
+ name?: string,
+): Promise {
+ let created: Worktree | null = null;
+ const buffered: OpencodeEvent[] = [];
+ let resolveReady!: (value: Worktree) => void;
+ let rejectReady!: (error: Error) => void;
+ const ready = new Promise((resolve, reject) => {
+ resolveReady = resolve;
+ rejectReady = reject;
+ });
+ // The POST may itself hang; attach a handler immediately so the readiness
+ // timeout cannot become an unhandled rejection while we are still awaiting it.
+ void ready.catch(() => undefined);
+ const onEvent = (event: OpencodeEvent) => {
+ if (event.type !== "worktree.ready" && event.type !== "worktree.failed") return;
+ if (!created) {
+ buffered.push(event);
+ return;
+ }
+ const worktree = worktreeFromEvent(event);
+ if (event.type === "worktree.ready" && worktree?.directory !== created.directory) return;
+ if (event.type === "worktree.failed" && worktree?.name !== created.name) return;
+ if (event.type === "worktree.failed") {
+ rejectReady(new Error(String(event.properties.message ?? event.properties.error ?? "worktree setup failed")));
+ } else if (worktree) {
+ resolveReady(worktree);
+ }
+ };
+ bus.on("event", onEvent);
+ const timeout = setTimeout(() => rejectReady(new Error("worktree setup timed out")), 60_000);
+ try {
+ created = await request(config, "/experimental/worktree", {
+ method: "POST",
+ directory,
+ body: name ? { name } : {},
+ signal: AbortSignal.timeout(60_000),
+ });
+ for (const event of buffered) onEvent(event);
+ return await ready;
+ } finally {
+ clearTimeout(timeout);
+ bus.off("event", onEvent);
+ }
+}
+
+async function requireListedWorktree(
+ config: OpencodeConfig,
+ projectDirectory: string,
+ worktreeDirectory: string,
+): Promise {
+ const [project, target] = await Promise.all([
+ realpath(projectDirectory),
+ requireProjectDirectory(worktreeDirectory, worktreesRoot()),
+ ]);
+ if (target === project) throw new Error("the primary checkout is not a removable worktree");
+ const listed = await listWorktrees(config, projectDirectory);
+ const canonicalListed = await Promise.all(listed.map((worktree) => realpath(worktree.directory).catch(() => "")));
+ if (!canonicalListed.includes(target)) {
+ throw new Error("worktree is not registered for this project");
+ }
+ const commonDirectory = async (directory: string) => {
+ const { stdout } = await execFileAsync("git", ["-C", directory, "rev-parse", "--path-format=absolute", "--git-common-dir"], {
+ timeout: 5_000,
+ encoding: "utf8",
+ });
+ return realpath(stdout.trim());
+ };
+ const [projectCommon, targetCommon] = await Promise.all([commonDirectory(project), commonDirectory(target)]);
+ if (projectCommon !== targetCommon) throw new Error("worktree does not belong to this project");
+}
+
+export async function resetWorktree(
+ config: OpencodeConfig,
+ projectDirectory: string,
+ worktreeDirectory: string,
+): Promise {
+ await requireListedWorktree(config, projectDirectory, worktreeDirectory);
+ await request(config, "/experimental/worktree/reset", {
+ method: "POST",
+ directory: projectDirectory,
+ body: { directory: worktreeDirectory },
+ });
+}
+
+export async function deleteWorktree(
+ config: OpencodeConfig,
+ projectDirectory: string,
+ worktreeDirectory: string,
+): Promise {
+ await requireListedWorktree(config, projectDirectory, worktreeDirectory);
+ await request(config, "/experimental/worktree", {
+ method: "DELETE",
+ directory: projectDirectory,
+ body: { directory: worktreeDirectory },
+ });
+}
diff --git a/server/paths.ts b/server/paths.ts
new file mode 100644
index 00000000..eef12d62
--- /dev/null
+++ b/server/paths.ts
@@ -0,0 +1,143 @@
+import { realpath } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+const execFileAsync = promisify(execFile);
+
+export class PathError extends Error {
+ constructor(
+ readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = "PathError";
+ }
+}
+
+function expandHome(value: string): string {
+ return value === "~" || value.startsWith("~/")
+ ? path.join(os.homedir(), value.slice(2))
+ : value;
+}
+
+export function projectsRoot(env: NodeJS.ProcessEnv = process.env): string {
+ return path.resolve(expandHome(env.PROJECTS_DIR || "~/Documents/Projects"));
+}
+
+export function worktreesRoot(env: NodeJS.ProcessEnv = process.env): string {
+ return path.resolve(
+ expandHome(env.OPENCODE_WORKTREE_ROOT || "~/.local/share/opencode/worktree"),
+ );
+}
+
+/**
+ * Canonicalise a project before forwarding it to OpenCode.
+ *
+ * OpenCode's file routes do not apply agent permission rules. Checking only
+ * for an absolute path would expose every readable host file to a browser on
+ * the tailnet. realpath also closes symlink escapes from PROJECTS_DIR.
+ */
+export async function requireProjectDirectory(
+ value: unknown,
+ root = projectsRoot(),
+): Promise {
+ if (typeof value !== "string" || !value.trim()) {
+ throw new PathError(400, "a 'directory' query parameter is required");
+ }
+ if (!path.isAbsolute(value)) {
+ throw new PathError(400, "'directory' must be an absolute path");
+ }
+
+ let canonicalRoot: string;
+ let canonicalDirectory: string;
+ try {
+ [canonicalRoot, canonicalDirectory] = await Promise.all([realpath(root), realpath(value)]);
+ } catch {
+ throw new PathError(400, "'directory' must identify an existing project");
+ }
+ const relative = path.relative(canonicalRoot, canonicalDirectory);
+ if (!relative) {
+ throw new PathError(403, "'directory' must identify a project below the configured root");
+ }
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
+ throw new PathError(403, "'directory' is outside PROJECTS_DIR");
+ }
+ return canonicalDirectory;
+}
+
+export async function requireWorkspaceDirectory(value: unknown): Promise {
+ try {
+ return await requireProjectDirectory(value);
+ } catch (error) {
+ if (!(error instanceof PathError) || error.status !== 403) throw error;
+ }
+ return requireProjectDirectory(value, worktreesRoot()).catch(() => {
+ throw new PathError(403, "'directory' is outside PROJECTS_DIR and the OpenCode worktree root");
+ });
+}
+
+/** Normalise a workspace-relative path and reject traversal/absolute paths. */
+export function requireRelativePath(value: unknown): string {
+ if (value === undefined || value === null || value === "") return "";
+ if (typeof value !== "string" || path.isAbsolute(value)) {
+ throw new PathError(400, "'path' must be workspace-relative");
+ }
+ const normalized = path.posix.normalize(value.replaceAll("\\", "/"));
+ if (normalized === ".." || normalized.startsWith("../")) {
+ throw new PathError(400, "'path' must not traverse outside the workspace");
+ }
+ return normalized === "." ? "" : normalized.replace(/^\.\//, "");
+}
+
+const SENSITIVE_SEGMENT = /^(\.git|\.env(?:\..*)?|\.ssh|\.aws|credentials?|id_rsa|id_ed25519)$/i;
+
+export function isSensitiveWorkspacePath(relativePath: string): boolean {
+ return relativePath.split("/").filter(Boolean).some((segment) => SENSITIVE_SEGMENT.test(segment));
+}
+
+/** Resolve a child path, reject symlink escapes, ignored files and common secrets. */
+export async function requireReadableWorkspacePath(
+ directory: string,
+ relativePath: string,
+): Promise {
+ if (isSensitiveWorkspacePath(relativePath)) {
+ throw new PathError(403, "sensitive workspace paths are not readable through the API");
+ }
+ if (!relativePath) return relativePath;
+
+ let workspace: string;
+ let target: string;
+ try {
+ [workspace, target] = await Promise.all([
+ realpath(directory),
+ realpath(path.join(directory, relativePath)),
+ ]);
+ } catch {
+ throw new PathError(404, "workspace path not found");
+ }
+ const containment = path.relative(workspace, target);
+ if (containment.startsWith("..") || path.isAbsolute(containment)) {
+ throw new PathError(403, "workspace path resolves outside the project");
+ }
+ const canonicalRelative = containment.replaceAll(path.sep, "/");
+ if (isSensitiveWorkspacePath(canonicalRelative)) {
+ throw new PathError(403, "sensitive workspace paths are not readable through the API");
+ }
+
+ try {
+ await execFileAsync("git", ["-C", workspace, "check-ignore", "-q", "--", canonicalRelative], {
+ timeout: 5_000,
+ });
+ throw new PathError(403, "ignored workspace paths are not readable through the API");
+ } catch (error) {
+ if (error instanceof PathError) throw error;
+ // git check-ignore exits 1 for a visible file and 128 outside a git repo.
+ const code = (error as { code?: unknown }).code;
+ if (code !== 1 && code !== 128) throw error;
+ }
+ // Callers must forward this canonical relative target, never the original
+ // symlink alias; otherwise the link could be swapped after validation.
+ return canonicalRelative;
+}
diff --git a/server/routes/forge.ts b/server/routes/forge.ts
new file mode 100644
index 00000000..f6801d68
--- /dev/null
+++ b/server/routes/forge.ts
@@ -0,0 +1,28 @@
+import { Router } from "express";
+
+import { getReviewStatus, mergeReview, parseReviewUrl } from "../forge.js";
+
+export function forgeRoutes(): Router {
+ const router = Router();
+ router.get("/forge/review", (req, res) => {
+ try {
+ const ref = parseReviewUrl(String(req.query.url ?? ""));
+ void getReviewStatus(ref)
+ .then((review) => res.json({ review }))
+ .catch((error: unknown) => res.status(502).json({ error: error instanceof Error ? error.message : String(error) }));
+ } catch (error) {
+ res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
+ }
+ });
+ router.post("/forge/review/merge", (req, res) => {
+ try {
+ const ref = parseReviewUrl(String(req.body?.url ?? ""));
+ void mergeReview(ref, String(req.body?.expectedSha ?? ""))
+ .then(() => res.json({ merged: true }))
+ .catch((error: unknown) => res.status(502).json({ error: error instanceof Error ? error.message : String(error) }));
+ } catch (error) {
+ res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
+ }
+ });
+ return router;
+}
diff --git a/server/routes/mcp.ts b/server/routes/mcp.ts
new file mode 100644
index 00000000..02203aae
--- /dev/null
+++ b/server/routes/mcp.ts
@@ -0,0 +1,49 @@
+import { Router } from "express";
+
+import { OpencodeError, type OpencodeConfig } from "../opencode/client.js";
+import { listMcp, setMcpConnected } from "../opencode/mcp.js";
+import { PathError, requireWorkspaceDirectory } from "../paths.js";
+import { getEffectivePermissions } from "../opencode/config.js";
+import { request } from "../opencode/client.js";
+
+export function mcpRoutes(config: OpencodeConfig): Router {
+ const router = Router();
+
+ const fail = (res: import("express").Response, error: unknown) => {
+ if (error instanceof PathError) res.status(error.status).json({ error: error.message });
+ else if (error instanceof OpencodeError && error.status === 404) res.status(404).json({ error: "MCP server not found" });
+ else res.status(502).json({ error: error instanceof Error ? error.message : String(error) });
+ };
+
+ router.get("/mcp", (req, res) => {
+ requireWorkspaceDirectory(req.query.directory)
+ .then((directory) => listMcp(config, directory))
+ .then((servers) => res.json({ servers }))
+ .catch((error: unknown) => fail(res, error));
+ });
+ router.post("/mcp/:name/:action", (req, res) => {
+ const name = typeof req.params.name === "string" ? req.params.name : "";
+ const action = req.params.action;
+ if (!name || (action !== "connect" && action !== "disconnect")) {
+ res.status(400).json({ error: "invalid MCP operation" });
+ return;
+ }
+ requireWorkspaceDirectory(req.query.directory)
+ .then((directory) => setMcpConnected(config, directory, name, action === "connect"))
+ .then((servers) => res.json({ servers }))
+ .catch((error: unknown) => fail(res, error));
+ });
+ router.get("/permissions", (req, res) => {
+ requireWorkspaceDirectory(req.query.directory)
+ .then((directory) => getEffectivePermissions(config, directory))
+ .then((permissions) => res.json({ permissions }))
+ .catch((error: unknown) => fail(res, error));
+ });
+ router.get("/lsp", (req, res) => {
+ requireWorkspaceDirectory(req.query.directory)
+ .then((directory) => request(config, "/lsp", { directory }))
+ .then((servers) => res.json({ servers }))
+ .catch((error: unknown) => fail(res, error));
+ });
+ return router;
+}
diff --git a/server/routes/notifications.ts b/server/routes/notifications.ts
new file mode 100644
index 00000000..c94fee98
--- /dev/null
+++ b/server/routes/notifications.ts
@@ -0,0 +1,29 @@
+import { Router } from "express";
+
+import { sendNtfy } from "../notifications/ntfy.js";
+import { PreferenceStore } from "../notifications/preferences.js";
+
+export function notificationRoutes(store: PreferenceStore): Router {
+ const router = Router();
+ router.get("/notifications", (_req, res) => {
+ store.read().then((preferences) =>
+ res.json({ preferences, tokenConfigured: Boolean(process.env.NTFY_TOKEN) }),
+ );
+ });
+ router.patch("/notifications", (req, res) => {
+ store
+ .write(req.body)
+ .then((preferences) => res.json({ preferences, tokenConfigured: Boolean(process.env.NTFY_TOKEN) }))
+ .catch((error: unknown) => res.status(400).json({ error: error instanceof Error ? error.message : String(error) }));
+ });
+ router.post("/notifications/test", (_req, res) => {
+ store
+ .read()
+ .then((preferences) =>
+ sendNtfy(preferences, { event: "idle", title: "OpenCode notification test", body: "Notifications are configured." }),
+ )
+ .then(() => res.json({ sent: true }))
+ .catch((error: unknown) => res.status(502).json({ error: error instanceof Error ? error.message : String(error) }));
+ });
+ return router;
+}
diff --git a/server/routes/preview.ts b/server/routes/preview.ts
new file mode 100644
index 00000000..cb240790
--- /dev/null
+++ b/server/routes/preview.ts
@@ -0,0 +1,120 @@
+import { Router, type Request, type Response } from "express";
+
+const MAX_BYTES = 25 * 1024 * 1024;
+const FORWARD_HEADERS = ["accept", "accept-language", "range"] as const;
+const RESPONSE_HEADERS = [
+ "content-type",
+ "content-range",
+ "accept-ranges",
+ "cache-control",
+ "etag",
+ "last-modified",
+] as const;
+
+export function parseAllowedPorts(
+ value: string | undefined,
+ forbidden: number[] = [],
+): Set {
+ const blocked = new Set(forbidden);
+ return new Set(
+ (value ?? "")
+ .split(",")
+ .map((entry) => Number(entry.trim()))
+ .filter((port) => Number.isInteger(port) && port > 0 && port <= 65535 && !blocked.has(port)),
+ );
+}
+
+function forwardPath(req: Request): string {
+ const match = /^\/preview\/\d+(\/.*)?$/.exec(req.path);
+ return match?.[1] || "/";
+}
+
+async function readLimited(body: ReadableStream | null): Promise {
+ if (!body) return Buffer.alloc(0);
+ const reader = body.getReader();
+ const chunks: Buffer[] = [];
+ let total = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) return Buffer.concat(chunks, total);
+ total += value.byteLength;
+ if (total > MAX_BYTES) {
+ await reader.cancel();
+ throw new RangeError("preview response exceeds 25 MiB");
+ }
+ chunks.push(Buffer.from(value));
+ }
+}
+
+export function previewRoutes(allowedPorts: Set): Router {
+ const router = Router();
+ router.all(/^\/preview\/(\d+)(\/.*)?$/, async (req: Request, res: Response) => {
+ if (req.method !== "GET" && req.method !== "HEAD") {
+ res.status(405).set("Allow", "GET, HEAD").json({ error: "preview supports GET and HEAD only" });
+ return;
+ }
+ const port = Number(req.params[0]);
+ if (!allowedPorts.has(port)) {
+ res.status(403).json({ error: "preview port is not allowlisted" });
+ return;
+ }
+
+ const target = new URL(forwardPath(req), `http://127.0.0.1:${port}`);
+ const original = new URL(req.originalUrl, "http://bff.invalid");
+ target.search = original.search;
+ const headers: Record = {};
+ for (const name of FORWARD_HEADERS) {
+ const value = req.get(name);
+ if (value) headers[name] = value;
+ }
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 20_000);
+ try {
+ const upstream = await fetch(target, {
+ method: req.method,
+ headers,
+ redirect: "manual",
+ signal: controller.signal,
+ });
+ const contentLength = Number(upstream.headers.get("content-length") ?? 0);
+ if (contentLength > MAX_BYTES) {
+ res.status(413).json({ error: "preview response exceeds 25 MiB" });
+ return;
+ }
+ for (const name of RESPONSE_HEADERS) {
+ const value = upstream.headers.get(name);
+ if (value) res.set(name, value);
+ }
+ res.set("Content-Security-Policy", "sandbox allow-forms allow-modals allow-popups allow-scripts");
+ res.set("X-Content-Type-Options", "nosniff");
+ const location = upstream.headers.get("location");
+ if (location) {
+ const destination = new URL(location, target);
+ if (destination.origin !== target.origin) {
+ res.status(502).json({ error: "preview refused a cross-origin redirect" });
+ return;
+ }
+ res.set("location", `/api/preview/${port}${destination.pathname}${destination.search}${destination.hash}`);
+ }
+ if (req.method === "HEAD") {
+ res.status(upstream.status).end();
+ return;
+ }
+ const body = await readLimited(upstream.body);
+ res.status(upstream.status).send(body);
+ } catch (error) {
+ if (error instanceof RangeError) {
+ res.removeHeader("content-length");
+ res.removeHeader("content-range");
+ res.status(413).json({ error: error.message });
+ } else {
+ const message = error instanceof Error && error.name === "AbortError" ? "preview timed out" : "preview target unavailable";
+ res.status(502).json({ error: message });
+ }
+ } finally {
+ clearTimeout(timeout);
+ }
+ });
+ return router;
+}
diff --git a/server/routes/sessions.ts b/server/routes/sessions.ts
index 6b05344d..fda959c9 100644
--- a/server/routes/sessions.ts
+++ b/server/routes/sessions.ts
@@ -7,8 +7,9 @@
import { Router, type Request, type Response } from "express";
-import { OpencodeError, type OpencodeConfig } from "../opencode/client.js";
+import { OpencodeError, request, type OpencodeConfig } from "../opencode/client.js";
import type { EventBus } from "../opencode/events.js";
+import { PathError, requireWorkspaceDirectory } from "../paths.js";
import {
abortSession,
createSession,
@@ -19,17 +20,13 @@ import {
listTodos,
prompt,
} from "../opencode/sessions.js";
+import { createWorktree } from "../opencode/worktrees.js";
+import { getModelContextLimit } from "../opencode/config.js";
/** Resolve and validate the project scope for a request. */
-function directoryOf(req: Request): string {
+async function directoryOf(req: Request): Promise {
const value = req.query.directory ?? req.body?.directory;
- if (typeof value !== "string" || !value.trim()) {
- throw new HttpError(400, "a 'directory' query parameter is required");
- }
- if (!value.startsWith("/")) {
- throw new HttpError(400, "'directory' must be an absolute path");
- }
- return value;
+ return requireWorkspaceDirectory(value);
}
/**
@@ -54,6 +51,21 @@ class HttpError extends Error {
}
}
+function promptAttachments(value: unknown): Array<{ filename: string; mime: string; url: string }> {
+ if (value === undefined) return [];
+ if (!Array.isArray(value) || value.length > 4) throw new HttpError(400, "at most four image attachments are allowed");
+ return value.map((item) => {
+ const source = item && typeof item === "object" ? (item as Record) : {};
+ const filename = typeof source.filename === "string" ? source.filename.slice(0, 200) : "image";
+ const mime = typeof source.mime === "string" ? source.mime : "";
+ const url = typeof source.url === "string" ? source.url : "";
+ if (!/^image\/(png|jpeg|gif|webp)$/.test(mime) || !url.startsWith(`data:${mime};base64,`) || url.length > 4_200_000) {
+ throw new HttpError(400, "attachments must be PNG, JPEG, GIF or WebP data URLs under 3 MiB");
+ }
+ return { filename, mime, url };
+ });
+}
+
/**
* Map thrown errors onto responses without leaking stack traces.
*
@@ -63,7 +75,7 @@ class HttpError extends Error {
* get the honest status instead.
*/
function fail(res: Response, error: unknown, options: { notFoundOn5xx?: boolean } = {}): void {
- if (error instanceof HttpError) {
+ if (error instanceof HttpError || error instanceof PathError) {
res.status(error.status).json({ error: error.message });
return;
}
@@ -102,7 +114,7 @@ export function sessionRoutes(config: OpencodeConfig, bus: EventBus): Router {
router.get(
"/sessions",
asyncRoute(async (req, res) => {
- const directory = directoryOf(req);
+ const directory = await directoryOf(req);
const limit = Number(req.query.limit ?? 100);
const sessions = await listSessions(config, directory, {
limit: Number.isFinite(limit) ? limit : 100,
@@ -116,8 +128,16 @@ export function sessionRoutes(config: OpencodeConfig, bus: EventBus): Router {
router.post(
"/sessions",
asyncRoute(async (req, res) => {
- const directory = directoryOf(req);
- const { title, agent, model, prompt: initialPrompt } = req.body ?? {};
+ const projectDirectory = await directoryOf(req);
+ const { title, agent, model, prompt: initialPrompt, isolated, worktreeName } = req.body ?? {};
+ const directory = isolated === true
+ ? (await createWorktree(
+ config,
+ bus,
+ projectDirectory,
+ typeof worktreeName === "string" ? worktreeName : undefined,
+ )).directory
+ : projectDirectory;
const session = await createSession(config, { directory, title, agent, model });
// Fire-and-forget the opening turn so the response is not held for it.
if (typeof initialPrompt === "string" && initialPrompt.trim()) {
@@ -130,7 +150,7 @@ export function sessionRoutes(config: OpencodeConfig, bus: EventBus): Router {
router.get(
"/sessions/:id",
sessionRoute(async (req, res) => {
- const directory = directoryOf(req);
+ const directory = await directoryOf(req);
res.json({ session: await getSession(config, directory, paramOf(req, "id")) });
}),
);
@@ -138,7 +158,7 @@ export function sessionRoutes(config: OpencodeConfig, bus: EventBus): Router {
router.delete(
"/sessions/:id",
sessionRoute(async (req, res) => {
- const directory = directoryOf(req);
+ const directory = await directoryOf(req);
await deleteSession(config, directory, paramOf(req, "id"));
res.status(204).end();
}),
@@ -147,7 +167,7 @@ export function sessionRoutes(config: OpencodeConfig, bus: EventBus): Router {
router.get(
"/sessions/:id/messages",
sessionRoute(async (req, res) => {
- const directory = directoryOf(req);
+ const directory = await directoryOf(req);
// Raw {info, parts} — the client adapter owns the shaping so there is
// exactly one place that understands OpenCode's wire format.
const [messages, session] = await Promise.all([
@@ -161,20 +181,39 @@ export function sessionRoutes(config: OpencodeConfig, bus: EventBus): Router {
router.get(
"/sessions/:id/todos",
sessionRoute(async (req, res) => {
- const directory = directoryOf(req);
+ const directory = await directoryOf(req);
res.json({ todos: await listTodos(config, directory, paramOf(req, "id")) });
}),
);
+ router.get(
+ "/sessions/:id/model-limit",
+ sessionRoute(async (req, res) => {
+ const directory = await directoryOf(req);
+ const session = await getSession(config, directory, paramOf(req, "id"));
+ const providerID = session.model?.providerID;
+ const modelID = session.model?.modelID;
+ const context = providerID && modelID
+ ? await getModelContextLimit(config, directory, providerID, modelID)
+ : null;
+ res.json({ context });
+ }),
+ );
+
router.post(
"/sessions/:id/prompt",
sessionRoute(async (req, res) => {
- const directory = directoryOf(req);
- const { text, agent, model } = req.body ?? {};
+ const directory = await directoryOf(req);
+ const { text, agent, model, attachments } = req.body ?? {};
if (typeof text !== "string" || !text.trim()) {
throw new HttpError(400, "'text' is required");
}
- await prompt(config, directory, paramOf(req, "id"), { text, agent, model });
+ await prompt(config, directory, paramOf(req, "id"), {
+ text,
+ agent,
+ model,
+ attachments: promptAttachments(attachments),
+ });
// 202: accepted, running server-side. Progress arrives over SSE.
res.status(202).json({ accepted: true });
}),
@@ -183,12 +222,37 @@ export function sessionRoutes(config: OpencodeConfig, bus: EventBus): Router {
router.post(
"/sessions/:id/abort",
sessionRoute(async (req, res) => {
- const directory = directoryOf(req);
+ const directory = await directoryOf(req);
await abortSession(config, directory, paramOf(req, "id"));
res.json({ aborted: true });
}),
);
+ router.get(
+ "/permission-requests",
+ asyncRoute(async (req, res) => {
+ const directory = await directoryOf(req);
+ res.json({ requests: await request(config, "/permission", { directory }) });
+ }),
+ );
+
+ router.post(
+ "/permission-requests/:requestId/reply",
+ asyncRoute(async (req, res) => {
+ const directory = await directoryOf(req);
+ const reply = req.body?.reply;
+ if (reply !== "once" && reply !== "always" && reply !== "reject") {
+ throw new HttpError(400, "reply must be 'once', 'always' or 'reject'");
+ }
+ await request(config, `/permission/${encodeURIComponent(paramOf(req, "requestId"))}/reply`, {
+ method: "POST",
+ directory,
+ body: { reply, ...(typeof req.body?.message === "string" ? { message: req.body.message } : {}) },
+ });
+ res.json({ replied: true });
+ }),
+ );
+
/**
* SSE fan-out. One upstream subscription serves every connected tab.
*
diff --git a/server/routes/settings.ts b/server/routes/settings.ts
new file mode 100644
index 00000000..f4ef1c50
--- /dev/null
+++ b/server/routes/settings.ts
@@ -0,0 +1,30 @@
+import { Router } from "express";
+
+import { type OpencodeConfig } from "../opencode/client.js";
+import {
+ getGlobalSettings,
+ patchGlobalSettings,
+ validateSettingsPatch,
+} from "../opencode/config.js";
+
+export function settingsRoutes(config: OpencodeConfig): Router {
+ const router = Router();
+ router.get("/settings", (_req, res) => {
+ getGlobalSettings(config)
+ .then((settings) => res.json({ settings }))
+ .catch((error: unknown) => res.status(502).json({ error: String(error) }));
+ });
+ router.patch("/settings", (req, res) => {
+ let patch;
+ try {
+ patch = validateSettingsPatch(req.body);
+ } catch (error) {
+ res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
+ return;
+ }
+ patchGlobalSettings(config, patch)
+ .then((settings) => res.json({ settings }))
+ .catch((error: unknown) => res.status(502).json({ error: String(error) }));
+ });
+ return router;
+}
diff --git a/server/routes/workspace.ts b/server/routes/workspace.ts
new file mode 100644
index 00000000..1ef859c9
--- /dev/null
+++ b/server/routes/workspace.ts
@@ -0,0 +1,57 @@
+import { Router, type Response } from "express";
+
+import { OpencodeError, type OpencodeConfig } from "../opencode/client.js";
+import {
+ listChanges,
+ listCommits,
+ listWorkspace,
+ readWorkspaceFile,
+} from "../opencode/workspace.js";
+import { PathError, requireReadableWorkspacePath, requireRelativePath, requireWorkspaceDirectory } from "../paths.js";
+
+function fail(res: Response, error: unknown): void {
+ if (error instanceof PathError) res.status(error.status).json({ error: error.message });
+ else if (error instanceof OpencodeError) res.status(502).json({ error: error.message });
+ else res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
+}
+
+export function workspaceRoutes(config: OpencodeConfig): Router {
+ const router = Router();
+ router.get("/workspace/tree", (req, res) => {
+ requireWorkspaceDirectory(req.query.directory)
+ .then(async (directory) => {
+ const relative = requireRelativePath(req.query.path);
+ const safeRelative = await requireReadableWorkspacePath(directory, relative);
+ return listWorkspace(config, directory, safeRelative);
+ })
+ .then((tree) => res.json(tree))
+ .catch((error: unknown) => fail(res, error));
+ });
+ router.get("/workspace/file", (req, res) => {
+ requireWorkspaceDirectory(req.query.directory)
+ .then(async (directory) => {
+ const relative = requireRelativePath(req.query.path);
+ if (!relative) throw new PathError(400, "'path' is required");
+ const safeRelative = await requireReadableWorkspacePath(directory, relative);
+ return readWorkspaceFile(config, directory, safeRelative);
+ })
+ .then((file) => res.json(file))
+ .catch((error: unknown) => fail(res, error));
+ });
+ router.get("/workspace/changes", (req, res) => {
+ const mode = req.query.mode === "branch" ? "branch" : "git";
+ const rawContext = Number(req.query.context ?? 3);
+ const context = Number.isFinite(rawContext) ? Math.max(0, Math.min(20, Math.trunc(rawContext))) : 3;
+ requireWorkspaceDirectory(req.query.directory)
+ .then((directory) => listChanges(config, directory, mode, context))
+ .then((changes) => res.json({ changes }))
+ .catch((error: unknown) => fail(res, error));
+ });
+ router.get("/workspace/commits", (req, res) => {
+ requireWorkspaceDirectory(req.query.directory)
+ .then((directory) => listCommits(directory, Number(req.query.limit ?? 50)))
+ .then((commits) => res.json({ commits }))
+ .catch((error: unknown) => fail(res, error));
+ });
+ return router;
+}
diff --git a/server/routes/worktrees.ts b/server/routes/worktrees.ts
new file mode 100644
index 00000000..951185f3
--- /dev/null
+++ b/server/routes/worktrees.ts
@@ -0,0 +1,51 @@
+import { Router, type Response } from "express";
+
+import { type OpencodeConfig } from "../opencode/client.js";
+import type { EventBus } from "../opencode/events.js";
+import {
+ createWorktree,
+ deleteWorktree,
+ listWorktrees,
+ resetWorktree,
+} from "../opencode/worktrees.js";
+import { PathError, requireProjectDirectory } from "../paths.js";
+
+function fail(res: Response, error: unknown): void {
+ if (error instanceof PathError) res.status(error.status).json({ error: error.message });
+ else res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
+}
+
+function target(body: unknown): string {
+ const value = body && typeof body === "object" ? (body as Record).worktreeDirectory : undefined;
+ if (typeof value !== "string" || !value) throw new PathError(400, "'worktreeDirectory' is required");
+ return value;
+}
+
+export function worktreeRoutes(config: OpencodeConfig, bus: EventBus): Router {
+ const router = Router();
+ router.get("/worktrees", (req, res) => {
+ requireProjectDirectory(req.query.directory)
+ .then((directory) => listWorktrees(config, directory))
+ .then((worktrees) => res.json({ worktrees }))
+ .catch((error: unknown) => fail(res, error));
+ });
+ router.post("/worktrees", (req, res) => {
+ requireProjectDirectory(req.query.directory)
+ .then((directory) => createWorktree(config, bus, directory, typeof req.body?.name === "string" ? req.body.name : undefined))
+ .then((worktree) => res.status(201).json({ worktree }))
+ .catch((error: unknown) => fail(res, error));
+ });
+ router.post("/worktrees/reset", (req, res) => {
+ requireProjectDirectory(req.query.directory)
+ .then((directory) => resetWorktree(config, directory, target(req.body)))
+ .then(() => res.json({ reset: true }))
+ .catch((error: unknown) => fail(res, error));
+ });
+ router.delete("/worktrees", (req, res) => {
+ requireProjectDirectory(req.query.directory)
+ .then((directory) => deleteWorktree(config, directory, target(req.body)))
+ .then(() => res.status(204).end())
+ .catch((error: unknown) => fail(res, error));
+ });
+ return router;
+}
diff --git a/tests/e2e/mock-opencode.ts b/tests/e2e/mock-opencode.ts
index 07a32aed..32b30211 100644
--- a/tests/e2e/mock-opencode.ts
+++ b/tests/e2e/mock-opencode.ts
@@ -17,7 +17,8 @@
// Run standalone: npx tsx tests/e2e/mock-opencode.ts [port]
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
-import { readFileSync } from "node:fs";
+import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
+import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import path from "node:path";
@@ -26,15 +27,23 @@ const fixture = JSON.parse(
readFileSync(path.resolve(here, "../fixtures/session-messages.json"), "utf8"),
) as unknown[];
-export const MOCK_DIRECTORY = "/tmp/mock-project";
+const MOCK_DIRECTORY_INPUT = "/tmp/mock-project";
+mkdirSync(MOCK_DIRECTORY_INPUT, { recursive: true });
+export const MOCK_DIRECTORY = realpathSync(MOCK_DIRECTORY_INPUT);
+if (!existsSync(path.join(MOCK_DIRECTORY, ".git"))) {
+ execFileSync("git", ["init", "-q", MOCK_DIRECTORY]);
+ writeFileSync(path.join(MOCK_DIRECTORY, "README.md"), "# Mock project\n");
+ execFileSync("git", ["-C", MOCK_DIRECTORY, "add", "README.md"]);
+ execFileSync("git", ["-C", MOCK_DIRECTORY, "-c", "user.name=E2E", "-c", "user.email=e2e@example.test", "commit", "-qm", "fixture"]);
+}
-const SESSIONS = [
+const SESSIONS: Array> = [
{
id: "ses_mock_done",
title: "Add a health endpoint",
directory: MOCK_DIRECTORY,
agent: "build",
- model: { providerID: "anthropic", modelID: "claude-opus-5" },
+ model: { providerID: "anthropic", id: "claude-opus-5" },
cost: 0.0431,
tokens: { input: 110, output: 940, reasoning: 250, cache: { read: 10400, write: 800 } },
time: { created: 1787000000000, updated: 1787000012000 },
@@ -44,7 +53,7 @@ const SESSIONS = [
title: "Refactor the parser",
directory: MOCK_DIRECTORY,
agent: "build",
- model: { providerID: "anthropic", modelID: "claude-opus-5" },
+ model: { providerID: "anthropic", id: "claude-opus-5" },
cost: 0.12,
tokens: { input: 20, output: 300, reasoning: 0, cache: { read: 900, write: 100 } },
time: { created: 1787000100000, updated: 1787000200000 },
@@ -65,6 +74,33 @@ const TODOS = [
{ content: "Write a test", status: "pending", priority: "medium" },
];
+let globalConfig: Record = {};
+let mcpServers: Record = {
+ github: { status: "connected" },
+ docs: { status: "failed", error: "mock connection refused" },
+ auth: { status: "needs_auth" },
+};
+const worktrees = [`${MOCK_DIRECTORY}.worktrees/fixture`];
+let pendingPermissions = [{ id: "perm_mock", sessionID: "ses_mock_done", permission: "bash", patterns: ["npm test"] }];
+mkdirSync(worktrees[0], { recursive: true });
+const eventClients = new Set();
+
+function body(req: IncomingMessage): Promise> {
+ return new Promise((resolve, reject) => {
+ let raw = "";
+ req.on("data", (chunk) => (raw += chunk));
+ req.on("end", () => {
+ try { resolve(raw ? (JSON.parse(raw) as Record) : {}); }
+ catch (error) { reject(error); }
+ });
+ });
+}
+
+function emit(type: string, properties: Record, directory = MOCK_DIRECTORY): void {
+ const frame = `data: ${JSON.stringify({ directory, payload: { type, properties } })}\n\n`;
+ for (const client of eventClients) client.write(frame);
+}
+
function json(res: ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body);
res.writeHead(status, { "Content-Type": "application/json" });
@@ -95,6 +131,7 @@ function handle(req: IncomingMessage, res: ServerResponse): void {
Connection: "keep-alive",
});
res.write(`data: ${JSON.stringify({ payload: { type: "server.connected", properties: {} } })}\n\n`);
+ eventClients.add(res);
// The real server heartbeats every 10s with a type absent from the typed
// union — clients must tolerate it.
const beat = setInterval(() => {
@@ -102,10 +139,90 @@ function handle(req: IncomingMessage, res: ServerResponse): void {
`data: ${JSON.stringify({ directory: MOCK_DIRECTORY, payload: { type: "server.heartbeat", properties: {} } })}\n\n`,
);
}, 1_000);
- req.on("close", () => clearInterval(beat));
+ req.on("close", () => { clearInterval(beat); eventClients.delete(res); });
return;
}
+ if (pathname === "/global/config") {
+ if (req.method === "GET") return json(res, 200, globalConfig);
+ if (req.method === "PATCH") {
+ void body(req).then((patch) => {
+ globalConfig = {
+ ...globalConfig,
+ ...patch,
+ ...(patch.compaction && typeof patch.compaction === "object"
+ ? { compaction: { ...((globalConfig.compaction as object) ?? {}), ...(patch.compaction as object) } }
+ : {}),
+ };
+ json(res, 200, globalConfig);
+ });
+ return;
+ }
+ }
+
+ if (pathname === "/config") {
+ return json(res, 200, { model: "anthropic/claude-opus-5", permission: { "*": "ask", read: "allow" } });
+ }
+ if (pathname === "/config/providers") {
+ return json(res, 200, {
+ providers: [{ id: "anthropic", models: { "claude-opus-5": { limit: { context: 200000 } } } }],
+ default: { anthropic: "claude-opus-5" },
+ });
+ }
+
+ if (pathname === "/mcp" && req.method === "GET") return json(res, 200, mcpServers);
+ const mcpMatch = /^\/mcp\/([^/]+)\/(connect|disconnect)$/.exec(pathname);
+ if (mcpMatch && req.method === "POST") {
+ const name = decodeURIComponent(mcpMatch[1]);
+ if (!(name in mcpServers)) return json(res, 404, { error: "unknown MCP" });
+ mcpServers = { ...mcpServers, [name]: { status: mcpMatch[2] === "connect" ? "connected" : "disabled" } };
+ return json(res, 200, true);
+ }
+
+ if (pathname === "/lsp") return json(res, 200, { typescript: { status: "connected" } });
+ if (pathname === "/permission") return json(res, 200, pendingPermissions);
+ const permissionReply = /^\/permission\/([^/]+)\/reply$/.exec(pathname);
+ if (permissionReply && req.method === "POST") {
+ pendingPermissions = pendingPermissions.filter((request) => request.id !== decodeURIComponent(permissionReply[1]));
+ return json(res, 200, true);
+ }
+
+ if (pathname === "/file") {
+ const relative = url.searchParams.get("path") ?? "";
+ return json(res, 200, relative === "src"
+ ? [{ name: "index.ts", path: "src/index.ts", type: "file", ignored: false }]
+ : [
+ { name: "src", path: "src", type: "directory", ignored: false },
+ { name: "README.md", path: "README.md", type: "file", ignored: false },
+ { name: "node_modules", path: "node_modules", type: "directory", ignored: true },
+ ]);
+ }
+ if (pathname === "/file/content") {
+ const relative = url.searchParams.get("path") ?? "";
+ return json(res, 200, { type: "text", content: relative === "README.md" ? "# Mock project" : "export const answer = 42;" });
+ }
+ if (pathname === "/vcs/diff") {
+ return json(res, 200, [{ file: "src/index.ts", status: "modified", additions: 1, deletions: 1, patch: "@@ -1 +1 @@\n-old\n+new" }]);
+ }
+
+ if (pathname === "/experimental/worktree") {
+ if (req.method === "GET") return json(res, 200, worktrees);
+ if (req.method === "POST") {
+ void body(req).then((input) => {
+ const name = typeof input.name === "string" ? input.name : `mock-${Date.now()}`;
+ const directory = `${MOCK_DIRECTORY}.worktrees/${name}`;
+ mkdirSync(directory, { recursive: true });
+ worktrees.push(directory);
+ const value = { name, branch: name, directory };
+ json(res, 200, value);
+ setTimeout(() => emit("worktree.ready", { name, branch: name }, directory), 10);
+ });
+ return;
+ }
+ if (req.method === "DELETE") return json(res, 200, true);
+ }
+ if (pathname === "/experimental/worktree/reset" && req.method === "POST") return json(res, 200, true);
+
if (pathname === "/session/status") {
return json(res, 200, { ses_mock_running: { type: "busy" } });
}
@@ -120,14 +237,16 @@ function handle(req: IncomingMessage, res: ServerResponse): void {
req.on("data", (chunk) => (raw += chunk));
req.on("end", () => {
const body = raw ? (JSON.parse(raw) as { title?: string }) : {};
- json(res, 200, {
+ const created = {
id: `ses_mock_new_${Date.now()}`,
title: body.title ?? "Untitled session",
directory: directory ?? MOCK_DIRECTORY,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: Date.now(), updated: Date.now() },
- });
+ };
+ SESSIONS.push(created);
+ json(res, 200, created);
});
return;
}
diff --git a/tests/e2e/mock-preview.ts b/tests/e2e/mock-preview.ts
new file mode 100644
index 00000000..5615a91f
--- /dev/null
+++ b/tests/e2e/mock-preview.ts
@@ -0,0 +1,31 @@
+import { createServer } from "node:http";
+
+const port = Number(process.argv[2] || 4600);
+createServer((req, res) => {
+ if (req.url === "/repos/acme/demo/pulls/7") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ title: "Mock pull request", state: "open", mergeable: true, user: { login: "octocat" }, head: { sha: "abc123" } }));
+ return;
+ }
+ if (req.url === "/repos/acme/demo/commits/abc123/check-runs") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ check_runs: [{ conclusion: "success" }] }));
+ return;
+ }
+ if (req.url === "/repos/acme/demo/pulls/7/merge" && req.method === "PUT") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ merged: true }));
+ return;
+ }
+ if (req.url === "/redirect") {
+ res.writeHead(302, { Location: "/target" }).end();
+ return;
+ }
+ res.writeHead(200, { "Content-Type": "application/json", "X-Unsafe": "must-not-forward" });
+ res.end(JSON.stringify({
+ path: req.url,
+ authorization: req.headers.authorization ?? null,
+ cookie: req.headers.cookie ?? null,
+ host: req.headers.host ?? null,
+ }));
+}).listen(port, "127.0.0.1", () => console.log(`[mock-preview] listening on ${port}`));
diff --git a/tests/e2e/smoke.api.spec.ts b/tests/e2e/smoke.api.spec.ts
index ee79e222..1b607939 100644
--- a/tests/e2e/smoke.api.spec.ts
+++ b/tests/e2e/smoke.api.spec.ts
@@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test";
// API tier — exercises the real BFF against the mock OpenCode server.
// No browser, no agent run.
-const DIR = "/tmp/mock-project";
+const DIR = process.platform === "darwin" ? "/private/tmp/mock-project" : "/tmp/mock-project";
test.describe("health", () => {
test("reports upstream reachability and version", async ({ request }) => {
@@ -43,10 +43,9 @@ test.describe("directory scoping", () => {
for (const session of body.sessions) expect(session.directory).toBe(DIR);
});
- test("an unknown directory yields no sessions rather than an error", async ({ request }) => {
+ test("rejects a nonexistent directory before forwarding it", async ({ request }) => {
const res = await request.get("/api/sessions?directory=/tmp/nope");
- expect(res.ok()).toBe(true);
- expect((await res.json()).sessions).toEqual([]);
+ expect(res.status()).toBe(400);
});
});
@@ -114,6 +113,13 @@ test.describe("prompting", () => {
expect(res.status()).toBe(400);
});
+ test("rejects unsafe image attachments", async ({ request }) => {
+ const res = await request.post(`/api/sessions/ses_mock_done/prompt?directory=${DIR}`, {
+ data: { text: "inspect", attachments: [{ filename: "secret", mime: "text/plain", url: "file:///etc/passwd" }] },
+ });
+ expect(res.status()).toBe(400);
+ });
+
test("creates a session", async ({ request }) => {
const res = await request.post("/api/sessions", {
data: { directory: DIR, title: "e2e created" },
@@ -172,3 +178,95 @@ test.describe("event stream", () => {
expect(["data", "still-open"]).toContain(second);
});
});
+
+test.describe("settings and tools", () => {
+ test("global settings round-trip only public fields", async ({ request }) => {
+ const saved = await request.patch("/api/settings", {
+ data: { model: "anthropic/claude-opus-5", compaction: { auto: true, reserved: 4096 } },
+ });
+ expect(saved.ok()).toBe(true);
+ expect((await saved.json()).settings).toEqual({
+ model: "anthropic/claude-opus-5",
+ compaction: { auto: true, reserved: 4096 },
+ });
+ const rejected = await request.patch("/api/settings", { data: { provider: { token: "secret" } } });
+ expect(rejected.status()).toBe(400);
+ });
+
+ test("MCP action refetches resulting status", async ({ request }) => {
+ const before = await (await request.get(`/api/mcp?directory=${DIR}`)).json();
+ expect(before.servers.docs).toMatchObject({ status: "failed", error: "mock connection refused" });
+ const after = await (await request.post(`/api/mcp/docs/connect?directory=${DIR}`)).json();
+ expect(after.servers.docs).toEqual({ status: "connected" });
+ });
+
+ test("returns LSP and read-only effective permissions", async ({ request }) => {
+ expect((await (await request.get(`/api/lsp?directory=${DIR}`)).json()).servers).toHaveProperty("typescript");
+ expect((await (await request.get(`/api/permissions?directory=${DIR}`)).json()).permissions).toEqual({ "*": "ask", read: "allow" });
+ });
+});
+
+test.describe("workspace", () => {
+ test("lists directories first and reads a file", async ({ request }) => {
+ const tree = await (await request.get(`/api/workspace/tree?directory=${DIR}&path=`)).json();
+ expect(tree.dirs).toContainEqual(expect.objectContaining({ name: "src", type: "directory" }));
+ expect(tree.files[0]).toMatchObject({ name: "README.md", type: "file" });
+ const file = await (await request.get(`/api/workspace/file?directory=${DIR}&path=README.md`)).json();
+ expect(file).toMatchObject({ type: "text", content: "# Mock project" });
+ });
+
+ test("rejects traversal before it reaches OpenCode", async ({ request }) => {
+ const res = await request.get(`/api/workspace/file?directory=${DIR}&path=../secret`);
+ expect(res.status()).toBe(400);
+ });
+
+ test("returns diffs and local git history", async ({ request }) => {
+ const changes = await (await request.get(`/api/workspace/changes?directory=${DIR}&mode=git`)).json();
+ expect(changes.changes[0].file).toBe("src/index.ts");
+ const commits = await (await request.get(`/api/workspace/commits?directory=${DIR}`)).json();
+ expect(commits.commits[0].subject).toBe("fixture");
+ });
+});
+
+test.describe("preview security", () => {
+ test("allows only configured ports and strips credentials", async ({ request }) => {
+ const denied = await request.get("/api/preview/9999/");
+ expect(denied.status()).toBe(403);
+ const proxied = await request.get("/api/preview/4600/hello?q=1", {
+ headers: { Authorization: "Bearer must-not-forward", Cookie: "secret=yes" },
+ });
+ const body = await proxied.json();
+ expect(body.path).toBe("/hello?q=1");
+ expect(body.authorization).toBeNull();
+ expect(body.cookie).toBeNull();
+ expect(proxied.headers()["x-unsafe"]).toBeUndefined();
+ expect(proxied.headers()["content-security-policy"]).toContain("sandbox");
+ });
+
+ test("rewrites root-relative redirects under the proxy mount", async ({ request }) => {
+ const res = await request.get("/api/preview/4600/redirect", { maxRedirects: 0 });
+ expect(res.status()).toBe(302);
+ expect(res.headers().location).toBe("/api/preview/4600/target");
+ });
+});
+
+test.describe("worktrees", () => {
+ test("lists and creates a ready isolated worktree", async ({ request }) => {
+ const listed = await (await request.get(`/api/worktrees?directory=${DIR}`)).json();
+ expect(listed.worktrees.length).toBeGreaterThan(0);
+ const created = await request.post(`/api/worktrees?directory=${DIR}`, { data: { name: "e2e-isolated" } });
+ expect(created.status()).toBe(201);
+ expect((await created.json()).worktree.directory).toContain("e2e-isolated");
+ });
+});
+
+test.describe("permission remote control", () => {
+ test("lists and answers a parked permission", async ({ request }) => {
+ const before = await (await request.get(`/api/permission-requests?directory=${DIR}`)).json();
+ expect(before.requests).toContainEqual(expect.objectContaining({ id: "perm_mock" }));
+ const reply = await request.post(`/api/permission-requests/perm_mock/reply?directory=${DIR}`, { data: { reply: "once" } });
+ expect(reply.ok()).toBe(true);
+ const after = await (await request.get(`/api/permission-requests?directory=${DIR}`)).json();
+ expect(after.requests).toEqual([]);
+ });
+});
diff --git a/tests/e2e/smoke.ui.spec.ts b/tests/e2e/smoke.ui.spec.ts
index 3dd37761..1a62f5ac 100644
--- a/tests/e2e/smoke.ui.spec.ts
+++ b/tests/e2e/smoke.ui.spec.ts
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
// Browser tier — the built SPA against the real BFF against the mock agent.
-const DIR = "/tmp/mock-project";
+const DIR = process.platform === "darwin" ? "/private/tmp/mock-project" : "/tmp/mock-project";
const hub = `/?directory=${encodeURIComponent(DIR)}`;
test.describe("hub", () => {
@@ -10,8 +10,9 @@ test.describe("hub", () => {
await page.goto(hub);
await expect(page.getByTestId("opencode-session-list")).toBeVisible();
const rows = page.getByTestId("opencode-session-row");
- await expect(rows).toHaveCount(2); // archived one is hidden
+ expect(await rows.count()).toBeGreaterThanOrEqual(2);
await expect(page.getByText("Add a health endpoint")).toBeVisible();
+ await expect(page.getByText("Old archived work")).toHaveCount(0);
});
test("shows a running pill for the busy session", async ({ page }) => {
@@ -57,6 +58,11 @@ test.describe("transcript", () => {
await expect(page.getByTestId("opencode-thought")).toContainText("2.0s");
});
+ test("shows live context usage against the model limit", async ({ page }) => {
+ await page.goto(conversation);
+ await expect(page.getByTestId("opencode-context-tokens")).toContainText("%");
+ });
+
// Encrypted-only reasoning must not produce an empty row.
test("drops reasoning that carries no readable text", async ({ page }) => {
await page.goto(conversation);
@@ -118,6 +124,15 @@ test.describe("composer", () => {
await expect(page.getByTestId("opencode-composer")).toHaveValue("");
});
+ test("accepts an image attachment", async ({ page }) => {
+ await page.goto(`/sessions/ses_mock_done?directory=${encodeURIComponent(DIR)}`);
+ await page.getByTestId("opencode-attach").setInputFiles({ name: "pixel.png", mimeType: "image/png", buffer: Buffer.from("89504e470d0a1a0a", "hex") });
+ await expect(page.getByTestId("opencode-attachment-chip")).toContainText("pixel.png");
+ await page.getByTestId("opencode-composer").fill("inspect this");
+ await page.getByTestId("opencode-send").click();
+ await expect(page.getByTestId("opencode-attachment-chip")).toHaveCount(0);
+ });
+
test("disables send when empty", async ({ page }) => {
await page.goto(`/sessions/ses_mock_done?directory=${encodeURIComponent(DIR)}`);
await expect(page.getByTestId("opencode-send")).toBeDisabled();
@@ -146,3 +161,60 @@ test.describe("mobile", () => {
expect(overflow).toBeLessThanOrEqual(1);
});
});
+
+test.describe("settings and tools UI", () => {
+ test("edits compaction settings", async ({ page }) => {
+ await page.goto("/settings");
+ await page.getByTestId("opencode-setting-model").fill("anthropic/claude-opus-5");
+ await page.getByTestId("opencode-compaction-auto").check();
+ await page.getByTestId("opencode-compaction-reserved").fill("4096");
+ await page.getByTestId("opencode-settings-save").click();
+ await expect(page.getByText("Saved", { exact: true })).toBeVisible();
+ });
+
+ test("shows MCP failures, LSP status and permissions", async ({ page }) => {
+ await page.goto(`/tools?directory=${encodeURIComponent(DIR)}`);
+ await expect(page.getByTestId("opencode-mcp-row").filter({ hasText: "docs" })).toContainText(/connected|mock connection refused/);
+ await expect(page.getByTestId("opencode-lsp-status")).toContainText("typescript");
+ await expect(page.getByTestId("opencode-effective-permissions")).toContainText("allow");
+ });
+
+ test("keeps browser and ntfy event toggles independent", async ({ page }) => {
+ await page.goto("/settings/notifications");
+ const browser = page.getByTestId("opencode-notify-browser-idle");
+ const ntfy = page.getByTestId("opencode-notify-ntfy-idle");
+ const ntfyBefore = await ntfy.isChecked();
+ await browser.click();
+ expect(await ntfy.isChecked()).toBe(ntfyBefore);
+ });
+});
+
+test.describe("workspace UI", () => {
+ const conversation = `/sessions/ses_mock_done?directory=${encodeURIComponent(DIR)}`;
+
+ test("opens files, changes, commands and preview", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await page.goto(conversation);
+ await page.getByTestId("opencode-inspector-commands").click();
+ await expect(page.getByTestId("opencode-command-row")).toHaveCount(3);
+ await page.getByTestId("opencode-inspector-links").click();
+ await expect(page.getByTestId("opencode-merge-request-link")).toContainText("Mock pull request");
+ await expect(page.getByTestId("opencode-merge-request-link")).toContainText("pipeline passed");
+ await page.getByTestId("opencode-workspace-open").click();
+ await page.getByTestId("opencode-file-node").filter({ hasText: "README.md" }).click();
+ await expect(page.getByTestId("opencode-file-viewer")).toContainText("Mock project");
+ await page.getByTestId("opencode-workspace-changes").click();
+ await expect(page.getByTestId("opencode-diff-viewer")).toContainText("+new");
+ await page.getByTestId("opencode-workspace-preview").click();
+ await expect(page.getByTestId("opencode-preview-frame")).toBeVisible();
+ });
+
+ test("workspace drawer fits a phone", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 740 });
+ await page.goto(conversation);
+ await page.getByTestId("opencode-workspace-open").click();
+ await expect(page.getByTestId("opencode-workspace-panels")).toBeVisible();
+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
+ expect(overflow).toBeLessThanOrEqual(1);
+ });
+});
diff --git a/tests/fixtures/session-messages.json b/tests/fixtures/session-messages.json
index d39207f4..0696f8ff 100644
--- a/tests/fixtures/session-messages.json
+++ b/tests/fixtures/session-messages.json
@@ -103,7 +103,7 @@
"sessionID": "ses_fixture",
"messageID": "msg_asst_001",
"type": "text",
- "text": "I'll add the route now."
+ "text": "I'll add the route now. Review: https://github.com/acme/demo/pull/7"
},
{
"id": "prt_patch_001",
diff --git a/tests/forge.test.ts b/tests/forge.test.ts
new file mode 100644
index 00000000..b051f523
--- /dev/null
+++ b/tests/forge.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+
+import { parseReviewUrl } from "../server/forge.js";
+
+describe("forge URL parsing", () => {
+ it("parses bounded GitHub pull request URLs", () => {
+ expect(parseReviewUrl("https://github.com/acme/demo/pull/7")).toMatchObject({
+ forge: "github", owner: "acme", repo: "demo", number: 7,
+ });
+ });
+
+ it("parses configured GitLab merge request URLs", () => {
+ expect(parseReviewUrl("https://gitlab.com/group/project/-/merge_requests/42")).toMatchObject({
+ forge: "gitlab", project: "group/project", number: 42,
+ });
+ });
+
+ it("rejects arbitrary and lookalike hosts", () => {
+ expect(() => parseReviewUrl("https://github.com.attacker.test/acme/demo/pull/7")).toThrow();
+ expect(() => parseReviewUrl("http://127.0.0.1/admin")).toThrow();
+ });
+});
diff --git a/tests/notifications.test.ts b/tests/notifications.test.ts
new file mode 100644
index 00000000..a14efc6a
--- /dev/null
+++ b/tests/notifications.test.ts
@@ -0,0 +1,51 @@
+import { readFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+import { classifyEvent } from "../server/notifications/service.js";
+import {
+ normalizePreferences,
+ PreferenceStore,
+} from "../server/notifications/preferences.js";
+
+describe("notification preferences", () => {
+ it("normalises independent event channels and clamps values", () => {
+ const value = normalizePreferences({
+ browser: { sound: true, volume: 2, events: { idle: false } },
+ ntfy: { server: "https://ntfy.sh", topic: "valid-topic", events: { parked: false } },
+ parkedPermissionSeconds: 1,
+ });
+ expect(value.browser.volume).toBe(1);
+ expect(value.browser.events.idle).toBe(false);
+ expect(value.ntfy.events.idle).toBe(true);
+ expect(value.ntfy.events.parked).toBe(false);
+ expect(value.parkedPermissionSeconds).toBe(5);
+ });
+
+ it("atomically round-trips and recovers from malformed JSON", async () => {
+ const file = path.join(os.tmpdir(), `dca-prefs-${Date.now()}.json`);
+ const store = new PreferenceStore(file);
+ const saved = await store.write({ ntfy: { server: "https://ntfy.sh", topic: "team" } });
+ expect(JSON.parse(await readFile(file, "utf8"))).toEqual(saved);
+ await import("node:fs/promises").then(({ writeFile }) => writeFile(file, "not json"));
+ expect((await store.read()).version).toBe(1);
+ });
+
+ it("rejects unsafe ntfy destinations and topics", () => {
+ expect(() => normalizePreferences({ ntfy: { server: "file:///etc", topic: "ok" } })).toThrow();
+ expect(() => normalizePreferences({ ntfy: { server: "https://ntfy.sh/path", topic: "ok" } })).toThrow();
+ expect(() => normalizePreferences({ ntfy: { server: "https://ntfy.sh", topic: "bad/topic" } })).toThrow();
+ });
+});
+
+describe("notification event classification", () => {
+ it("distinguishes abort from an agent error", () => {
+ expect(classifyEvent({ type: "session.error", properties: { error: { name: "MessageAbortedError" } } })).toBe("abort");
+ expect(classifyEvent({ type: "session.error", properties: { error: { name: "ProviderError" } } })).toBe("error");
+ });
+
+ it("ignores unknown events", () => {
+ expect(classifyEvent({ type: "server.heartbeat", properties: {} })).toBeNull();
+ });
+});
diff --git a/tests/settings-workspace.test.ts b/tests/settings-workspace.test.ts
new file mode 100644
index 00000000..d4482692
--- /dev/null
+++ b/tests/settings-workspace.test.ts
@@ -0,0 +1,66 @@
+import { mkdtemp, mkdir, realpath, symlink } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+
+import { publicSettings, validateSettingsPatch } from "../server/opencode/config.js";
+import { parseCommits } from "../server/opencode/workspace.js";
+import { requireProjectDirectory, requireReadableWorkspacePath, requireRelativePath } from "../server/paths.js";
+import { parseAllowedPorts } from "../server/routes/preview.js";
+
+describe("public settings", () => {
+ it("allow-lists fields and never returns secrets", () => {
+ expect(publicSettings({
+ model: "anthropic/opus",
+ provider: { token: "secret" },
+ mcp: { private: { url: "secret" } },
+ compaction: { auto: true, reserved: 1000, future: "ignored" },
+ })).toEqual({ model: "anthropic/opus", compaction: { auto: true, reserved: 1000 } });
+ });
+
+ it("rejects unsupported and invalid patches", () => {
+ expect(() => validateSettingsPatch({ provider: {} })).toThrow("unsupported setting");
+ expect(() => validateSettingsPatch({ subagent_depth: -1 })).toThrow("non-negative integer");
+ expect(() => validateSettingsPatch({ compaction: { reserved: 1.5 } })).toThrow("non-negative integer");
+ });
+});
+
+describe("workspace paths", () => {
+ it("accepts a canonical child and rejects a symlink escape", async () => {
+ const root = await mkdtemp(path.join(os.tmpdir(), "dca-root-"));
+ const project = path.join(root, "project");
+ const outside = await mkdtemp(path.join(os.tmpdir(), "dca-outside-"));
+ await mkdir(project);
+ expect(await requireProjectDirectory(project, root)).toBe(await realpath(project));
+ await symlink(outside, path.join(root, "escape"));
+ await expect(requireProjectDirectory(path.join(root, "escape"), root)).rejects.toMatchObject({ status: 403 });
+ });
+
+ it("rejects traversal and absolute file paths", () => {
+ expect(requireRelativePath("src/index.ts")).toBe("src/index.ts");
+ expect(() => requireRelativePath("../secret")).toThrow("must not traverse");
+ expect(() => requireRelativePath("/etc/passwd")).toThrow("workspace-relative");
+ });
+
+ it("rejects sensitive children and symlinks that escape a valid project", async () => {
+ const root = await mkdtemp(path.join(os.tmpdir(), "dca-root-"));
+ const project = path.join(root, "project");
+ const outside = await mkdtemp(path.join(os.tmpdir(), "dca-secret-"));
+ await mkdir(project);
+ await symlink(outside, path.join(project, "leak"));
+ await expect(requireReadableWorkspacePath(project, "leak")).rejects.toMatchObject({ status: 403 });
+ await expect(requireReadableWorkspacePath(project, ".env")).rejects.toMatchObject({ status: 403 });
+ });
+});
+
+describe("workspace derivations", () => {
+ it("parses NUL-delimited git log records", () => {
+ const commits = parseCommits("abc\x00abc123\x00Subject\x00Ada\x002026-01-01T00:00:00Z\x1e");
+ expect(commits).toEqual([{ sha: "abc", shortSha: "abc123", subject: "Subject", author: "Ada", authoredAt: "2026-01-01T00:00:00Z" }]);
+ });
+
+ it("allowlists ports and always drops forbidden listeners", () => {
+ expect([...parseAllowedPorts("5173, 4173,garbage,4096", [4096])]).toEqual([5173, 4173]);
+ expect(parseAllowedPorts(undefined).size).toBe(0);
+ });
+});
diff --git a/tests/transcript-adapter.test.ts b/tests/transcript-adapter.test.ts
index 8536baa5..63032d65 100644
--- a/tests/transcript-adapter.test.ts
+++ b/tests/transcript-adapter.test.ts
@@ -35,7 +35,9 @@ describe("normalizeTranscript", () => {
it("maps assistant text to an agent row", () => {
const agent = events.filter((e) => e.kind === "agent");
- expect(agent.map((e) => (e as { text: string }).text)).toEqual(["I'll add the route now."]);
+ expect(agent.map((e) => (e as { text: string }).text)).toEqual([
+ "I'll add the route now. Review: https://github.com/acme/demo/pull/7",
+ ]);
});
it("emits step-start and step-finish as bookkeeping, never as rows", () => {