Decouple bundled UI from runtime API and add remote instance tooling (#1228)

Add a packaged-client runtime boundary so the shared UI can talk to local,
desktop, remote, and VS Code runtimes through the right transport instead of
assuming one same-origin web server.

Centralize OpenChamber-owned API access behind RuntimeAPIs, runtimeFetch, and
runtime URL helpers, while keeping official OpenCode traffic on the SDK path.
Support runtime switching, remote host selection, desktop client credentials,
and headless connection links for pairing packaged clients with remote
OpenChamber servers.

Harden the new auth model by moving long-lived client tokens out of browser
URLs, introducing short-lived scoped URL tokens for browser-owned transports,
restricting URL-token access to explicit readable/realtime routes, and making
client-token management session-scoped or self-scoped as appropriate.

Update browser-owned assets and preview proxy flows to work with the split
runtime model, including authenticated project icons, preview token propagation,
CSP-safe preview bridge injection, and preview proxy auth that survives
short-lived URL-token expiry.

Tighten Electron security boundaries for packaged clients by gating privileged
preload state to trusted origins and requiring explicit confirmation before
connect deep-links import or switch remote runtimes.

Also refresh agent guidance and project skills so future runtime/API, auth,
preview, UI, CLI, settings, locale, and drag-to-reorder work follows the new
architecture.
This commit is contained in:
Bohdan Triapitsyn
2026-06-02 00:43:05 +03:00
committed by GitHub
parent a4314c189b
commit 2031e3b4a8
282 changed files with 16524 additions and 4259 deletions
+202 -133
View File
@@ -31,6 +31,46 @@ let _optimisticRemove: ((input: { sessionID: string; messageID: string }) => voi
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
type SdkResult<T> = {
data?: T
error?: unknown
response?: { status?: number }
}
function formatSdkError(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === "string") return error
if (error && typeof error === "object") {
const message = (error as { message?: unknown }).message
if (typeof message === "string" && message.length > 0) return message
const data = (error as { data?: unknown }).data
if (data && typeof data === "object") {
const dataMessage = (data as { message?: unknown }).message
if (typeof dataMessage === "string" && dataMessage.length > 0) return dataMessage
}
}
try {
return JSON.stringify(error)
} catch {
return String(error)
}
}
function assertSdkSuccess<T>(result: SdkResult<T>, operation: string): T | undefined {
if (!result.error) return result.data
const status = result.response?.status
throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`)
}
function assertSdkData<T>(result: SdkResult<T>, operation: string): T {
const data = assertSdkSuccess(result, operation)
if (data === undefined || data === null) {
throw new Error(`${operation} failed: empty response`)
}
return data
}
export function setActionRefs(
sdk: OpencodeClient,
childStores: ChildStoreManager,
@@ -61,6 +101,20 @@ function dirStore() {
return _childStores.ensureChild(d)
}
function dirStoreForDirectory(directory: string) {
if (!_childStores) throw new Error("Child stores not initialized")
if (!directory) throw new Error("No directory")
return _childStores.ensureChild(directory)
}
function dirStoreForSession(sessionId: string): { store: DirectoryStoreApi; directory?: string } {
const directory = getSessionDirectory(sessionId)
if (directory) {
return { store: dirStoreForDirectory(directory), directory }
}
return { store: dirStore(), directory: dir() }
}
function dir() {
return _getDirectory() || undefined
}
@@ -96,15 +150,47 @@ export async function waitForConnectionOrThrow(): Promise<void> {
throw connectionLostError()
}
function getSessionDirectory(sessionId: string): string | undefined {
return useSessionUIStore.getState().getDirectoryForSession(sessionId) || dir()
type SessionListSnapshot = {
directory: string
sessions: Session[]
}
function getDirectoryStore(directory?: string) {
if (!_childStores) throw new Error("Child stores not initialized")
const resolvedDirectory = directory || _getDirectory()
if (!resolvedDirectory) throw new Error("No current directory")
return _childStores.ensureChild(resolvedDirectory)
type DirectoryStoreApi = ReturnType<ChildStoreManager["ensureChild"]>
function getGlobalSessionSnapshot(sessionId: string): Session | null {
const global = useGlobalSessionsStore.getState()
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null
}
function restoreGlobalSessionSnapshot(session: Session | null): void {
if (!session) return
useGlobalSessionsStore.getState().upsertSession(session)
}
function getSessionDirectory(sessionId: string): string | undefined {
return findSessionDirectoryInChildStores(sessionId)
|| useSessionUIStore.getState().getDirectoryForSession(sessionId)
|| dir()
}
function findSessionDirectoryInChildStores(sessionId: string): string | null {
const stores = _childStores
if (!stores || !sessionId) return null
for (const [directory, store] of stores.children) {
const state = store.getState()
if (
state.session.some((session) => session.id === sessionId)
|| Object.prototype.hasOwnProperty.call(state.message, sessionId)
|| Object.prototype.hasOwnProperty.call(state.session_status ?? {}, sessionId)
|| Object.prototype.hasOwnProperty.call(state.permission ?? {}, sessionId)
|| Object.prototype.hasOwnProperty.call(state.question ?? {}, sessionId)
) {
return directory
}
}
return null
}
function getSessionReplyClient(sessionId?: string): OpencodeClient {
@@ -192,13 +278,10 @@ export async function createSession(
parentID?: string | null,
): Promise<Session | null> {
try {
const result = await sdk().session.create({
directory: directoryOverride ?? dir(),
const session = await opencodeClient.createSession({
title,
parentID: parentID ?? undefined,
})
const session = result.data
if (!session) return null
}, directoryOverride ?? dir())
const sessionDirectory = (session as { directory?: string }).directory ?? directoryOverride ?? null
// Pre-populate routing index so SSE events arriving before session.created
@@ -216,62 +299,70 @@ export async function createSession(
}
}
/** Optimistically remove a session from the child store list. Returns previous list for rollback. */
function optimisticRemoveSession(sessionId: string, directory?: string): Session[] | null {
const store = getDirectoryStore(directory)
const current = store.getState()
const sessions = [...current.session]
const result = Binary.search(sessions, sessionId, (s) => s.id)
if (result.found) {
const snapshot = current.session
sessions.splice(result.index, 1)
store.setState({ session: sessions })
return snapshot
/** Optimistically remove a session from every live child store that has it. */
function optimisticRemoveSession(sessionId: string, preferredDirectory?: string): SessionListSnapshot[] {
if (!_childStores) return []
const snapshots: SessionListSnapshot[] = []
const visited = new Set<string>()
const candidates: Array<[string, DirectoryStoreApi]> = []
if (preferredDirectory) {
const preferredStore = _childStores.children.get(preferredDirectory)
if (preferredStore) {
candidates.push([preferredDirectory, preferredStore])
visited.add(preferredDirectory)
}
}
for (const entry of _childStores.children.entries()) {
if (visited.has(entry[0])) continue
candidates.push(entry)
}
for (const [directory, store] of candidates) {
const current = store.getState()
if (!current.session.some((session) => session.id === sessionId)) {
continue
}
snapshots.push({ directory, sessions: current.session })
store.setState({ session: current.session.filter((session) => session.id !== sessionId) })
}
return snapshots
}
function restoreSessionListSnapshots(snapshots: SessionListSnapshot[]): void {
if (!_childStores) return
for (const snapshot of snapshots) {
const store = _childStores.children.get(snapshot.directory)
if (!store) continue
store.setState({ session: snapshot.sessions })
}
return null
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function deleteSession(sessionId: string, _options?: Record<string, unknown>): Promise<boolean> {
const sessionDirectory = getSessionDirectory(sessionId)
// Remove from UI immediately, rollback on error
let snapshot = optimisticRemoveSession(sessionId, sessionDirectory)
let removedFromDir: string | null = snapshot ? (sessionDirectory ?? null) : null
// If the session wasn't in the resolved directory (e.g. archived session
// whose original child store was disposed), search all child stores.
if (!snapshot && _childStores) {
for (const [dir, store] of _childStores.children.entries()) {
const current = store.getState()
const sessions = [...current.session]
const result = Binary.search(sessions, sessionId, (s) => s.id)
if (result.found) {
snapshot = current.session
sessions.splice(result.index, 1)
store.setState({ session: sessions })
removedFromDir = dir
break
}
}
}
const snapshots = optimisticRemoveSession(sessionId, sessionDirectory)
const globalSnapshot = getGlobalSessionSnapshot(sessionId)
useGlobalSessionsStore.getState().removeSessions([sessionId])
const ui = useSessionUIStore.getState()
if (ui.currentSessionId === sessionId) {
ui.setCurrentSession(null)
}
try {
await sdk().session.delete({ sessionID: sessionId, directory: sessionDirectory })
const deleted = await opencodeClient.deleteSession(sessionId, sessionDirectory)
if (deleted !== true) {
throw new Error("session.delete failed: server did not confirm deletion")
}
useGlobalSessionsStore.getState().removeSessions([sessionId])
return true
} catch (error) {
console.error("[session-actions] deleteSession failed", error)
if (snapshot && removedFromDir) {
try {
getDirectoryStore(removedFromDir).setState({ session: snapshot })
} catch {
// child store may have been disposed since — ignore rollback
}
}
restoreSessionListSnapshots(snapshots)
restoreGlobalSessionSnapshot(globalSnapshot)
return false
}
}
@@ -279,72 +370,71 @@ export async function deleteSession(sessionId: string, _options?: Record<string,
/** Delete a session specifying which directory it lives in. Used by agent groups for cross-directory deletes. */
export async function deleteSessionInDirectory(sessionId: string, directory: string): Promise<boolean> {
if (!_childStores) return false
const store = _childStores.ensureChild(directory)
const current = store.getState()
const sessions = [...current.session]
const result = Binary.search(sessions, sessionId, (s) => s.id)
let snapshot: Session[] | null = null
if (result.found) {
snapshot = current.session
sessions.splice(result.index, 1)
store.setState({ session: sessions })
}
const snapshots = optimisticRemoveSession(sessionId, directory)
const globalSnapshot = getGlobalSessionSnapshot(sessionId)
useGlobalSessionsStore.getState().removeSessions([sessionId])
const ui = useSessionUIStore.getState()
if (ui.currentSessionId === sessionId) ui.setCurrentSession(null)
try {
await sdk().session.delete({ sessionID: sessionId, directory })
const deleted = await opencodeClient.deleteSession(sessionId, directory)
if (deleted !== true) {
throw new Error("session.delete failed: server did not confirm deletion")
}
useGlobalSessionsStore.getState().removeSessions([sessionId])
return true
} catch (error) {
console.error("[session-actions] deleteSessionInDirectory failed", error)
if (snapshot) store.setState({ session: snapshot })
restoreSessionListSnapshots(snapshots)
restoreGlobalSessionSnapshot(globalSnapshot)
return false
}
}
export async function archiveSession(sessionId: string): Promise<boolean> {
const sessionDirectory = getSessionDirectory(sessionId)
const snapshot = optimisticRemoveSession(sessionId, sessionDirectory)
const snapshots = optimisticRemoveSession(sessionId, sessionDirectory)
const globalSnapshot = getGlobalSessionSnapshot(sessionId)
const archivedAt = Date.now()
useGlobalSessionsStore.getState().archiveSessions([sessionId], archivedAt)
const ui = useSessionUIStore.getState()
if (ui.currentSessionId === sessionId) {
ui.setCurrentSession(null)
}
try {
const archivedAt = Date.now()
await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, time: { archived: archivedAt } })
useGlobalSessionsStore.getState().archiveSessions([sessionId], archivedAt)
const archived = await opencodeClient.updateSession(sessionId, { time: { archived: archivedAt } }, sessionDirectory)
if (!archived) {
throw new Error("session.update failed: server did not return the archived session")
}
useGlobalSessionsStore.getState().upsertSession(archived)
return true
} catch (error) {
console.error("[session-actions] archiveSession failed", error)
if (snapshot) getDirectoryStore(sessionDirectory).setState({ session: snapshot })
restoreSessionListSnapshots(snapshots)
restoreGlobalSessionSnapshot(globalSnapshot)
return false
}
}
export async function updateSessionTitle(sessionId: string, title: string): Promise<void> {
const sessionDirectory = getSessionDirectory(sessionId)
const result = await sdk().session.update({ sessionID: sessionId, directory: sessionDirectory, title })
if (result.data) {
useGlobalSessionsStore.getState().upsertSession(result.data)
}
const session = await opencodeClient.updateSession(sessionId, { title }, sessionDirectory)
useGlobalSessionsStore.getState().upsertSession(session)
}
export async function shareSession(sessionId: string): Promise<Session | null> {
const sessionDirectory = getSessionDirectory(sessionId)
const result = await sdk().session.share({ sessionID: sessionId, directory: sessionDirectory })
if (result.data) {
useGlobalSessionsStore.getState().upsertSession(result.data)
}
return result.data ?? null
const session = assertSdkData(result, "session.share")
useGlobalSessionsStore.getState().upsertSession(session)
return session
}
export async function unshareSession(sessionId: string): Promise<Session | null> {
const sessionDirectory = getSessionDirectory(sessionId)
const result = await sdk().session.unshare({ sessionID: sessionId, directory: sessionDirectory })
if (result.data) {
useGlobalSessionsStore.getState().upsertSession(result.data)
}
return result.data ?? null
const session = assertSdkData(result, "session.unshare")
useGlobalSessionsStore.getState().upsertSession(session)
return session
}
// ---------------------------------------------------------------------------
@@ -494,12 +584,7 @@ export async function respondToPermission(
const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
requestID: requestId,
reply: response,
...(directory ? { directory } : {}),
})
if (!result.data) {
if (await opencodeClient.replyToPermission(requestId, response, { directory }) !== true) {
throw new Error("Permission reply failed")
}
}
@@ -512,12 +597,7 @@ export async function dismissPermission(
const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
requestID: requestId,
reply: "reject",
...(directory ? { directory } : {}),
})
if (!result.data) {
if (await opencodeClient.replyToPermission(requestId, "reject", { directory }) !== true) {
throw new Error("Permission dismissal failed")
}
}
@@ -535,12 +615,7 @@ export async function respondToQuestion(
const directory = resolveDirectoryForBlockingRequest("question", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({
requestID: requestId,
answers: answers as Array<Array<string>>,
...(directory ? { directory } : {}),
})
if (!result.data) {
if (await opencodeClient.replyToQuestion(requestId, answers, directory) !== true) {
throw new Error("Question reply failed")
}
}
@@ -557,7 +632,7 @@ export async function rejectQuestion(
requestID: requestId,
...(directory ? { directory } : {}),
})
if (!result.data) {
if (assertSdkData(result, "question.reject") !== true) {
throw new Error("Question rejection failed")
}
}
@@ -572,18 +647,18 @@ export async function rejectQuestion(
* 1. Abort if session is busy
* 2. Extract text from the target message for prompt restoration
* 3. Optimistically set revert marker so messages hide immediately
* 4. Call SDK session.revert() and merge returned session
* 4. Call the runtime revert endpoint and merge returned session
* 5. Set pendingInputText so the reverted message text appears in the input
*/
export async function revertToMessage(sessionId: string, messageId: string): Promise<void> {
const store = dirStore()
const { store, directory } = dirStoreForSession(sessionId)
const state = store.getState()
// Abort if busy before mutating session state
const status = state.session_status[sessionId]
if (status && status.type !== "idle") {
try {
await sdk().session.abort({ sessionID: sessionId, directory: dir() })
await sdk().session.abort({ sessionID: sessionId, directory })
} catch {
// ignore abort errors
}
@@ -649,16 +724,13 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
// Call SDK and merge authoritative result into store
try {
const directory = dir()
const result = await sdk().session.revert({ sessionID: sessionId, directory, messageID: messageId })
if (result.data) {
const current = store.getState()
const updated = [...current.session]
const idx = updated.findIndex((s) => s.id === sessionId)
if (idx >= 0) {
updated[idx] = result.data
store.setState({ session: updated })
}
const revertedSession = await opencodeClient.revertSession(sessionId, messageId, undefined, directory)
const current = store.getState()
const updated = [...current.session]
const idx = updated.findIndex((s) => s.id === sessionId)
if (idx >= 0) {
updated[idx] = revertedSession
store.setState({ session: updated })
}
if (directory) {
sessionEvents.requestGitRefresh({ directory })
@@ -685,9 +757,10 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
}
export async function refetchSessionMessages(sessionId: string): Promise<void> {
const store = dirStore()
const result = await sdk().session.messages({ sessionID: sessionId, directory: dir(), limit: MESSAGE_REFETCH_LIMIT })
const records = (result.data ?? []).filter((record: { info?: { id?: string } }) => !!record?.info?.id)
const { store, directory } = dirStoreForSession(sessionId)
const result = await sdk().session.messages({ sessionID: sessionId, directory, limit: MESSAGE_REFETCH_LIMIT })
const records = (assertSdkSuccess(result, "session.messages") ?? [])
.filter((record: { info?: { id?: string } }) => !!record?.info?.id)
if (records.length === 0) return
store.setState((state) => {
@@ -709,7 +782,7 @@ export async function refetchSessionMessages(sessionId: string): Promise<void> {
* Restore all previously reverted messages. Aborts if busy, merges result.
*/
export async function unrevertSession(sessionId: string): Promise<void> {
const store = dirStore()
const { store, directory } = dirStoreForSession(sessionId)
const state = store.getState()
const previousMessageCount = state.message[sessionId]?.length ?? 0
@@ -717,21 +790,20 @@ export async function unrevertSession(sessionId: string): Promise<void> {
const status = state.session_status[sessionId]
if (status && status.type !== "idle") {
try {
await sdk().session.abort({ sessionID: sessionId, directory: dir() })
await sdk().session.abort({ sessionID: sessionId, directory })
} catch {
// ignore
}
}
const result = await sdk().session.unrevert({ sessionID: sessionId, directory: dir() })
if (result.data) {
const current = store.getState()
const sessions = [...current.session]
const idx = sessions.findIndex((s) => s.id === sessionId)
if (idx >= 0) {
sessions[idx] = result.data
store.setState({ session: sessions })
}
const result = await sdk().session.unrevert({ sessionID: sessionId, directory })
const unrevertedSession = assertSdkData(result, "session.unrevert")
const current = store.getState()
const sessions = [...current.session]
const idx = sessions.findIndex((s) => s.id === sessionId)
if (idx >= 0) {
sessions[idx] = unrevertedSession
store.setState({ session: sessions })
}
for (let attempt = 0; attempt < UNREVERT_REFETCH_ATTEMPTS; attempt += 1) {
if (attempt > 0) await wait(UNREVERT_REFETCH_RETRY_MS)
@@ -745,12 +817,12 @@ export async function unrevertSession(sessionId: string): Promise<void> {
* Fork from a user message.
*
* 1. Extract text from the message for input restoration
* 2. Call SDK session.fork()
* 2. Call the runtime fork endpoint
* 3. Insert the new session into the child store (so sidebar updates immediately)
* 4. Switch to new session and set pending input text
*/
export async function forkFromMessage(sessionId: string, messageId: string): Promise<void> {
const store = dirStore()
const { store, directory } = dirStoreForSession(sessionId)
const state = store.getState()
// Extract message text and file attachments for input restoration.
@@ -766,10 +838,7 @@ export async function forkFromMessage(sessionId: string, messageId: string): Pro
.trim()
const fileParts = parts.filter((p) => p.type === "file" && !isSyntheticPart(p)) as Array<Record<string, unknown>>
const result = await sdk().session.fork({ sessionID: sessionId, directory: dir(), messageID: messageId })
if (!result.data) return
const forkedSession = result.data
const forkedSession = await opencodeClient.forkSession(sessionId, messageId, directory)
// Insert new session into child store so sidebar updates immediately
const current = store.getState()