diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 9ac4f1154e91..7a77fbdf1787 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -18,6 +18,7 @@ import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/lang
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
import { SettingsProvider } from "@/settings/model"
import { TabsProvider } from "@/shell/tabs/tabs"
+import { SessionLinks } from "@/shell/links/links"
import { ErrorPage } from "@/shell/errors/error"
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
import { ExtensionRoot } from "@/runtime/extension/root"
@@ -104,6 +105,7 @@ export function AppInterface(props: {
+
{props.children}
{rootProps.children}
diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts
index 61adb92dd10a..b10f4f98c468 100644
--- a/packages/app/src/runtime/i18n/en.ts
+++ b/packages/app/src/runtime/i18n/en.ts
@@ -20,6 +20,7 @@ export const dict = {
"voice.failure.connection": "Couldn’t connect the call.",
...DESKTOP_NATIVE_ENGLISH,
"session.location.unavailable": "Session location unavailable",
+ "link.session.notFound": "Session not found on your servers",
"session.location.description": "Choose another directory to continue this session.",
"session.location.choose": "Choose directory",
"session.location.worktree": "Choose worktree",
diff --git a/packages/app/src/runtime/platform/platform.tsx b/packages/app/src/runtime/platform/platform.tsx
index 47b9f02f00e3..2eb69c52768c 100644
--- a/packages/app/src/runtime/platform/platform.tsx
+++ b/packages/app/src/runtime/platform/platform.tsx
@@ -103,6 +103,9 @@ type PlatformBase = {
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
setPinchZoomEnabled?(enabled: boolean): Promise | void
+ /** Receive `opencode://` links the OS opened in this window, until the returned function is called (desktop only) */
+ onOpenLink?(callback: (url: string) => void): () => void
+
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise | void
diff --git a/packages/app/src/shell/links/link.test.ts b/packages/app/src/shell/links/link.test.ts
new file mode 100644
index 000000000000..549fdfa68690
--- /dev/null
+++ b/packages/app/src/shell/links/link.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, test } from "bun:test"
+import { parseSessionLink } from "./link"
+
+const session = "ses_efcd8972fffeT5zZbj9YtIl3LU"
+
+describe("parseSessionLink", () => {
+ test("reads a session and its server hint", () => {
+ expect(parseSessionLink(`opencode://session/${session}`)).toEqual({ sessionID: session })
+ expect(parseSessionLink(`opencode://session/${session}?server=https://devbox.example.com:4096/`)).toEqual({
+ sessionID: session,
+ server: "https://devbox.example.com:4096",
+ })
+ expect(parseSessionLink(`opencode://session/${session}?server=sidecar`)).toEqual({
+ sessionID: session,
+ server: "sidecar",
+ })
+ })
+
+ test("rejects other links and malicious input", () => {
+ expect(parseSessionLink("opencode://console/authorized?window=a")).toBeUndefined()
+ expect(parseSessionLink(`https://session/${session}`)).toBeUndefined()
+ expect(parseSessionLink(`opencode://session/${session}/x`)).toBeUndefined()
+ expect(parseSessionLink("opencode://session/ses_..%2F..%2Fetc")).toBeUndefined()
+ expect(parseSessionLink(`opencode://session/${session}?prompt=hi`)).toBeUndefined()
+ expect(parseSessionLink(`opencode://session/${session}?server=a&server=b`)).toBeUndefined()
+ expect(parseSessionLink(`opencode://session/${session}?server=javascript:alert(1)`)).toBeUndefined()
+ expect(parseSessionLink(`opencode://session/${session}?server=https://user:pw@host`)).toBeUndefined()
+ expect(parseSessionLink(`opencode://session/${session}?server=https://host/path`)).toBeUndefined()
+ expect(parseSessionLink(`opencode://session/${session}#x`)).toBeUndefined()
+ expect(parseSessionLink(`opencode://session/${session}\n`)).toBeUndefined()
+ })
+
+ test("rejects oversized links", () => {
+ expect(parseSessionLink(`opencode://session/${session}?server=${"a".repeat(2100)}`)).toBeUndefined()
+ expect(parseSessionLink(`opencode://session/${session}?server=${"a".repeat(257)}`)).toBeUndefined()
+ expect(parseSessionLink(`opencode://session/ses_${"a".repeat(65)}`)).toBeUndefined()
+ })
+})
diff --git a/packages/app/src/shell/links/link.ts b/packages/app/src/shell/links/link.ts
new file mode 100644
index 000000000000..b7593ed39bd7
--- /dev/null
+++ b/packages/app/src/shell/links/link.ts
@@ -0,0 +1,47 @@
+export type SessionLink = { sessionID: string; server?: string }
+
+const MAX_LINK_LENGTH = 2048
+
+const SESSION_ID = /^ses_[0-9A-Za-z]{8,64}$/
+
+// A client-local server key: "sidecar" or an extension's `${extension}:${id}`.
+const SERVER_KEY = /^[\w.@:-]{1,256}$/
+
+/**
+ * Reads `opencode://session/[?server=]`. Desktop main validates links before they reach the app;
+ * this checks again because the app is the side that acts on them.
+ */
+export function parseSessionLink(value: string): SessionLink | undefined {
+ if (value.length > MAX_LINK_LENGTH || /[\s\u0000-\u001f\u007f]/.test(value)) return
+ const url = URL.parse(value)
+
+ if (!url || url.protocol !== "opencode:" || url.hostname !== "session") return
+
+ if (url.username || url.password || url.port || url.hash) return
+ const sessionID = url.pathname.slice(1)
+
+ if (!SESSION_ID.test(sessionID)) return
+ const keys = [...url.searchParams.keys()]
+
+ if (keys.length > 1 || keys.some((key) => key !== "server")) return
+ const hint = url.searchParams.get("server")
+
+ if (hint === null) return { sessionID }
+ const server = serverHint(hint)
+
+ if (!server) return
+
+ return { sessionID, server }
+}
+
+// An http(s) origin is the portable form; anything else is a key only the device that made it knows.
+function serverHint(value: string) {
+ if (!/^https?:/i.test(value)) return SERVER_KEY.test(value) ? value : undefined
+ const url = URL.parse(value)
+
+ if (!url || url.username || url.password || url.search || url.hash) return
+
+ if (url.pathname !== "/" && url.pathname !== "") return
+
+ return url.origin
+}
diff --git a/packages/app/src/shell/links/links.tsx b/packages/app/src/shell/links/links.tsx
new file mode 100644
index 000000000000..3e0b59451b01
--- /dev/null
+++ b/packages/app/src/shell/links/links.tsx
@@ -0,0 +1,90 @@
+import { createEffect, createSignal, onCleanup } from "solid-js"
+import { usePlatform } from "@/runtime/platform/platform"
+import { useLanguage } from "@/runtime/i18n/language"
+import { ServerConnection, useServers } from "@/runtime/server/registry"
+import { useGlobal } from "@/runtime/server/runtime"
+import { showToast } from "@/shell/notifications/toast"
+import { openNotificationSession } from "@/shell/notifications/notification"
+import { rootSession } from "@/shell/routes/session"
+import { useTabs } from "@/shell/tabs/tabs"
+import { parseSessionLink } from "./link"
+import { resolveLinkSession } from "./resolve"
+
+const PROBE_TIMEOUT = 3_000
+
+/** Opens `opencode://session/…` links the platform hands this window. Links only navigate. */
+export function SessionLinks() {
+ const platform = usePlatform()
+ const language = useLanguage()
+ const servers = useServers()
+ const global = useGlobal()
+ const tabs = useTabs()
+ const [pending, setPending] = createSignal([])
+
+ onCleanup(platform.onOpenLink?.((url) => setPending((urls) => [...urls, url])) ?? (() => {}))
+
+ createEffect(() => {
+ if (!servers.hydrated() || !tabs.ready()) return
+ const urls = pending()
+
+ if (!urls.length) return
+ setPending([])
+ urls.forEach((url) => void open(url))
+ })
+
+ async function open(url: string) {
+ const link = parseSessionLink(url)
+
+ if (!link) return
+
+ const found = await resolveLinkSession({
+ sessionID: link.sessionID,
+ hint: link.server,
+ // The sidecar is asked first; it is the one most likely to have the session and the quickest to answer.
+ servers: global.servers
+ .list()
+ .toSorted((a, b) => Number(ServerConnection.builtin(b)) - Number(ServerConnection.builtin(a)))
+ .flatMap((conn) => {
+ const key = ServerConnection.key(conn)
+ const ctx = global.serverCtx(key)
+
+ if (!ctx) return []
+
+ return [
+ {
+ key,
+ url: conn.http.url,
+ get: (sessionID: string, signal: AbortSignal) => ctx.sdk.api.session.get({ sessionID }, { signal }),
+ },
+ ]
+ }),
+ timeout: PROBE_TIMEOUT,
+ })
+
+ if (!found) {
+ showToast({ variant: "error", title: language.t("link.session.notFound") })
+
+ return
+ }
+
+ const ctx = global.serverCtx(found.key)
+ ctx?.data.session.remember(found.session)
+
+ // A subagent opens in its root session's tab, routed to the subagent.
+ const root = ctx
+ ? await rootSession(found.session, async (sessionID) => {
+ const cached = ctx.data.session.get(sessionID)
+
+ if (cached) return cached
+ const ancestor = await ctx.sdk.api.session.get({ sessionID })
+ ctx.data.session.remember(ancestor)
+
+ return ancestor
+ }).catch(() => found.session)
+ : found.session
+
+ openNotificationSession(tabs, found.key, root.id, found.session)
+ }
+
+ return null
+}
diff --git a/packages/app/src/shell/links/resolve.test.ts b/packages/app/src/shell/links/resolve.test.ts
new file mode 100644
index 000000000000..10a4b12659fe
--- /dev/null
+++ b/packages/app/src/shell/links/resolve.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, test } from "bun:test"
+import { resolveLinkSession, type LinkServer } from "./resolve"
+
+type Session = { id: string }
+
+function server(key: string, url: string, sessions: string[], calls: string[] = []): LinkServer {
+ return {
+ key,
+ url,
+ get: async (sessionID) => {
+ calls.push(key)
+
+ if (sessions.includes(sessionID)) return { id: sessionID }
+ throw new Error("NotFound")
+ },
+ }
+}
+
+// Answers only when its signal aborts, like a server that is down.
+function hanging(key: string, calls: string[] = []): LinkServer {
+ return {
+ key,
+ url: "http://down.example.com",
+ get: (_sessionID, signal) => {
+ calls.push(key)
+
+ return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason)))
+ },
+ }
+}
+
+describe("resolveLinkSession", () => {
+ test("asks only the server the link names when it has the session", async () => {
+ const calls: string[] = []
+
+ const servers = [
+ server("sidecar", "http://127.0.0.1:4096", ["ses_a"], calls),
+ server("https://devbox.example.com:4096", "https://devbox.example.com:4096", ["ses_a"], calls),
+ ]
+
+ expect(
+ await resolveLinkSession({ sessionID: "ses_a", hint: "https://devbox.example.com:4096", servers, timeout: 100 }),
+ ).toEqual({ key: "https://devbox.example.com:4096", session: { id: "ses_a" } })
+ expect(calls).toEqual(["https://devbox.example.com:4096"])
+ })
+
+ test("matches a named server by key or by origin", async () => {
+ const servers = [server("ssh:devbox", "http://127.0.0.1:52000/", ["ses_a"])]
+
+ expect((await resolveLinkSession({ sessionID: "ses_a", hint: "ssh:devbox", servers, timeout: 100 }))?.key).toBe(
+ "ssh:devbox",
+ )
+ expect(
+ (await resolveLinkSession({ sessionID: "ses_a", hint: "http://127.0.0.1:52000", servers, timeout: 100 }))?.key,
+ ).toBe("ssh:devbox")
+ })
+
+ test("finds the session on another server when the named one is unknown or lacks it", async () => {
+ const servers = [
+ server("sidecar", "http://127.0.0.1:4096", ["ses_a"]),
+ server("https://other.example.com", "https://other.example.com", []),
+ ]
+
+ expect(
+ (await resolveLinkSession({ sessionID: "ses_a", hint: "https://devbox.example.com:4096", servers, timeout: 100 }))
+ ?.key,
+ ).toBe("sidecar")
+ expect(
+ (await resolveLinkSession({ sessionID: "ses_a", hint: "https://other.example.com", servers, timeout: 100 }))?.key,
+ ).toBe("sidecar")
+ expect((await resolveLinkSession({ sessionID: "ses_a", servers, timeout: 100 }))?.key).toBe("sidecar")
+ })
+
+ test("does not wait for a server that is down", async () => {
+ const calls: string[] = []
+ const servers = [hanging("ssh:down", calls), server("sidecar", "http://127.0.0.1:4096", ["ses_a"], calls)]
+ const started = Date.now()
+
+ expect((await resolveLinkSession({ sessionID: "ses_a", servers, timeout: 5_000 }))?.key).toBe("sidecar")
+ expect(Date.now() - started).toBeLessThan(1_000)
+ expect(calls).toEqual(["ssh:down", "sidecar"])
+ })
+
+ test("gives up when no server has the session", async () => {
+ const servers = [hanging("ssh:down"), server("sidecar", "http://127.0.0.1:4096", [])]
+
+ expect(await resolveLinkSession({ sessionID: "ses_a", hint: "sidecar", servers, timeout: 20 })).toBeUndefined()
+ expect(await resolveLinkSession({ sessionID: "ses_a", servers: [], timeout: 20 })).toBeUndefined()
+ })
+})
diff --git a/packages/app/src/shell/links/resolve.ts b/packages/app/src/shell/links/resolve.ts
new file mode 100644
index 000000000000..812bfceaea69
--- /dev/null
+++ b/packages/app/src/shell/links/resolve.ts
@@ -0,0 +1,40 @@
+export type LinkServer = {
+ key: Key
+ url: string
+ get: (sessionID: string, signal: AbortSignal) => Promise
+}
+
+/**
+ * Finds which server has a linked session. A server the link names (by key or origin) is asked first; otherwise every
+ * server is asked at once and the first to have it wins, since session IDs are unique. The same server can be listed
+ * under different keys on different clients (the sidecar here, an http server on another Mac), which is why a named
+ * server that is not listed still finds the session. Never adds a server.
+ */
+export async function resolveLinkSession(input: {
+ sessionID: string
+ hint?: string
+ servers: LinkServer[]
+ timeout: number
+}) {
+ const named = input.hint
+ ? input.servers.find((server) => server.key === input.hint || URL.parse(server.url)?.origin === input.hint)
+ : undefined
+
+ const hit = named ? await probe(named, input.sessionID, input.timeout) : undefined
+
+ if (hit) return hit
+ const rest = input.servers.filter((server) => server !== named)
+
+ return Promise.any(
+ rest.map((server) =>
+ probe(server, input.sessionID, input.timeout).then((found) => found ?? Promise.reject(new Error("not found"))),
+ ),
+ ).catch(() => undefined)
+}
+
+function probe(server: LinkServer, sessionID: string, timeout: number) {
+ return server.get(sessionID, AbortSignal.timeout(timeout)).then(
+ (session) => ({ key: server.key, session }),
+ () => undefined,
+ )
+}
diff --git a/packages/app/src/shell/notifications/notification.test.ts b/packages/app/src/shell/notifications/notification.test.ts
index d14f6d574404..f804376a3583 100644
--- a/packages/app/src/shell/notifications/notification.test.ts
+++ b/packages/app/src/shell/notifications/notification.test.ts
@@ -1,16 +1,16 @@
import { expect, test } from "bun:test"
-import type { ServerConnection } from "@/runtime/server/registry"
+import { ServerConnection } from "@/runtime/server/registry"
import type { Tab } from "@/shell/tabs/tabs"
import { openNotificationSession } from "./notification"
-test("opens notification sessions through the tab router", () => {
- const server = "local\nhttp://localhost:4096" as ServerConnection.Key
- const tab = { type: "session" as const, server, sessionId: "session-1" }
+test("opens notification sessions through the tab router, leaving a subagent the tab showed", () => {
+ const server = ServerConnection.Key.make("local\nhttp://localhost:4096")
+ const tab = { type: "session" as const, server, sessionId: "session-1", routeSessionId: "child" }
const calls: string[] = []
const tabs = {
- addSessionTab: (input: Omit) => {
- calls.push(`add:${input.sessionId}`)
+ addSessionTab: (input: { sessionId: string; routeSessionId?: string }) => {
+ calls.push(`add:${input.sessionId}>${input.routeSessionId}`)
return tab
},
@@ -18,11 +18,35 @@ test("opens notification sessions through the tab router", () => {
calls.push(`route:${sessionID}`)
},
select: (input: Tab) => {
- calls.push(`select:${input.type === "session" ? input.sessionId : input.draftID}`)
+ calls.push(`select:${input.type === "session" ? `${input.sessionId}>${input.routeSessionId}` : input.draftID}`)
},
}
openNotificationSession(tabs, server, "session-1")
- expect(calls).toEqual(["add:session-1", "route:session-1", "select:session-1"])
+ expect(calls).toEqual(["add:session-1>undefined", "route:session-1", "select:session-1>undefined"])
+})
+
+test("opens a child session inside its root session's tab, routed from the moment the tab is added", () => {
+ const server = ServerConnection.Key.make("sidecar")
+ const tab = { type: "session" as const, server, sessionId: "root" }
+ const calls: string[] = []
+
+ const tabs = {
+ addSessionTab: (input: { sessionId: string; routeSessionId?: string }) => {
+ calls.push(`add:${input.sessionId}>${input.routeSessionId}`)
+
+ return tab
+ },
+ rememberSessionRoute: (_tab: typeof tab, sessionID: string, parentID?: string) => {
+ calls.push(`route:${sessionID}:${parentID}`)
+ },
+ select: (input: Tab) => {
+ calls.push(`select:${input.type === "session" ? `${input.sessionId}>${input.routeSessionId}` : input.draftID}`)
+ },
+ }
+
+ openNotificationSession(tabs, server, "root", { id: "child", parentID: "root" })
+
+ expect(calls).toEqual(["add:root>child", "route:child:root", "select:root>child"])
})
diff --git a/packages/app/src/shell/notifications/notification.tsx b/packages/app/src/shell/notifications/notification.tsx
index b841a201630f..5dcd7fced31f 100644
--- a/packages/app/src/shell/notifications/notification.tsx
+++ b/packages/app/src/shell/notifications/notification.tsx
@@ -17,12 +17,23 @@ import { useServer } from "@/runtime/server/current"
type NotificationTabs = Pick, "addSessionTab" | "rememberSessionRoute" | "select">
-export function openNotificationSession(tabs: NotificationTabs, server: ServerConnection.Key, sessionID: string) {
- const tab = tabs.addSessionTab({ server, sessionId: sessionID })
+/** Opens a session's tab, or reuses it, and selects it; `route` shows a child session inside its root's tab. */
+export function openNotificationSession(
+ tabs: NotificationTabs,
+ server: ServerConnection.Key,
+ sessionID: string,
+ route?: { id: string; parentID?: string },
+) {
+ const child = route && route.id !== sessionID ? route : undefined
+ const routed = { routeSessionId: child?.id, routeParentId: child?.parentID }
+ // A new tab carries its route from the start, since it joins the store in a transition; an existing
+ // tab is returned with its old route, so the route is remembered and set on the value selected.
+ const tab = tabs.addSessionTab({ server, sessionId: sessionID, ...(child && routed) })
if (tab.type !== "session") return
- tabs.rememberSessionRoute(tab, sessionID)
- tabs.select(tab)
+
+ tabs.rememberSessionRoute(tab, child?.id ?? sessionID, child?.parentID)
+ tabs.select({ ...tab, ...routed })
}
/** A session is unread when its latest terminal execution is newer than the last one any client viewed. */
diff --git a/packages/desktop/src/renderer/env.d.ts b/packages/desktop/src/renderer/env.d.ts
index ba7836cbfe30..9d0cc322d103 100644
--- a/packages/desktop/src/renderer/env.d.ts
+++ b/packages/desktop/src/renderer/env.d.ts
@@ -7,8 +7,5 @@ declare global {
interface Window {
electron: ElectronNative
- __OPENCODE__?: {
- deepLinks?: string[]
- }
}
}
diff --git a/packages/desktop/src/renderer/platform/index.ts b/packages/desktop/src/renderer/platform/index.ts
index 2baf88b6c10a..84d8b22cdb24 100644
--- a/packages/desktop/src/renderer/platform/index.ts
+++ b/packages/desktop/src/renderer/platform/index.ts
@@ -4,6 +4,7 @@ import { setPinchZoomEnabled, setZoomLevel, webviewZoom, zoomLevel } from "../wi
import { windowFullscreen } from "../window/fullscreen"
import { DragCancelEvent } from "../../shared/ipc-transport"
import { createExtensionBridge } from "../extensions"
+import { onOpenLink } from "../startup/deep-links"
import { createDesktopFiles } from "./files"
import { createDesktopMenuAction } from "./menu"
import { createDesktopNotify } from "./notifications"
@@ -48,6 +49,7 @@ export function createDesktopPlatform(api: ElectronAPI, windowState: DesktopWind
return () => window.removeEventListener(DragCancelEvent, callback)
},
+ onOpenLink,
runDesktopMenuAction: createDesktopMenuAction(api),
checkAppExists: async (appName) => {
return api.checkAppExists(appName)
diff --git a/packages/desktop/src/renderer/startup/deep-links.ts b/packages/desktop/src/renderer/startup/deep-links.ts
index fff01a8413fe..33acb6ea0eeb 100644
--- a/packages/desktop/src/renderer/startup/deep-links.ts
+++ b/packages/desktop/src/renderer/startup/deep-links.ts
@@ -1,15 +1,24 @@
import type { ElectronAPI } from "../api-types"
-const deepLinkEvent = "opencode:deep-link"
+let receiver: ((url: string) => void) | undefined
+const buffered: string[] = []
+
+// Starts before the app mounts so a link that launched the window waits until the app subscribes.
export function startDeepLinks(api: ElectronAPI) {
- void api.consumeInitialDeepLinks().then(emitDeepLinks)
- api.onDeepLink(emitDeepLinks)
+ api.onDeepLink(deliver)
+ void api.consumeInitialDeepLinks().then(deliver)
+}
+
+export function onOpenLink(receive: (url: string) => void) {
+ receiver = receive
+ buffered.splice(0).forEach(receive)
+
+ return () => {
+ if (receiver === receive) receiver = undefined
+ }
}
-function emitDeepLinks(urls: string[]) {
- if (urls.length === 0) return
- window.__OPENCODE__ ??= {}
- window.__OPENCODE__.deepLinks = [...(window.__OPENCODE__.deepLinks ?? []), ...urls]
- window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } }))
+function deliver(urls: string[]) {
+ urls.forEach((url) => (receiver ? receiver(url) : buffered.push(url)))
}