mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-10 03:42:37 +00:00
222 lines
10 KiB
TypeScript
222 lines
10 KiB
TypeScript
export * as BrowserConnection from "./connection.js"
|
|
|
|
import type { Context } from "@opencode/plugin/effect/plugin"
|
|
import type { RpcRegistration } from "@opencode/plugin/effect/rpc"
|
|
import type { Session } from "@opencode/schema/session"
|
|
import { Tool } from "@opencode/schema/tool"
|
|
import { Deferred, Effect, Schema, Stream } from "effect"
|
|
import { Browser } from "./rpc.js"
|
|
import { BrowserTunnel } from "./tunnel.js"
|
|
|
|
type Attachment = {
|
|
connectionID: string
|
|
state: Browser.State
|
|
closed: Deferred.Deferred<"closed" | "replaced">
|
|
pending: Map<string, { command: Browser.Command; result: Deferred.Deferred<Browser.Result, Tool.Error> }>
|
|
tunnels: BrowserTunnel.Tunnels
|
|
}
|
|
|
|
export type Connection = Effect.Success<ReturnType<typeof make>>
|
|
|
|
export const make = Effect.fn("BrowserConnection.make")(function* (
|
|
ctx: Pick<Context, "rpc" | "session" | "location" | "event">,
|
|
) {
|
|
const browsers = new Map<Session.ID, Attachment>()
|
|
let active = true
|
|
const close = (sessionID: Session.ID, reason: "closed" | "replaced" = "closed") =>
|
|
Effect.gen(function* () {
|
|
const browser = browsers.get(sessionID)
|
|
if (!browser) return
|
|
browsers.delete(sessionID)
|
|
browser.tunnels.dispose()
|
|
yield* Deferred.succeed(browser.closed, reason)
|
|
})
|
|
yield* Effect.addFinalizer(() => {
|
|
active = false
|
|
return Effect.forEach(browsers.keys(), (id) => close(id), { discard: true })
|
|
})
|
|
const tunnels = (input: {
|
|
sessionID: Session.ID
|
|
connectionID: string
|
|
}): Effect.Effect<BrowserTunnel.Tunnels, Error> => {
|
|
const browser = browsers.get(input.sessionID)
|
|
return browser?.connectionID === input.connectionID
|
|
? Effect.succeed(browser.tunnels)
|
|
: Effect.fail(new Error("Browser attachment is unavailable; its network connections were closed."))
|
|
}
|
|
const rpc: RpcRegistration<typeof Browser.Definition> = yield* ctx.rpc
|
|
.register(Browser.Definition, {
|
|
attach: (input, call) =>
|
|
Effect.gen(function* () {
|
|
const session = yield* ctx.session
|
|
.get({ sessionID: input.sessionID })
|
|
.pipe(Effect.mapError(() => call.error("unavailable", "Session not found.", {})))
|
|
if (session.location.directory !== ctx.location.directory)
|
|
return yield* Effect.fail(call.error("unavailable", "Session belongs to another location.", {}))
|
|
const browser = yield* Effect.acquireRelease(
|
|
Effect.gen(function* () {
|
|
if (!active) return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
|
|
yield* close(input.sessionID, "replaced")
|
|
const browser: Attachment = {
|
|
connectionID: input.connectionID,
|
|
state: { tabs: [], focusedTabID: null },
|
|
closed: yield* Deferred.make<"closed" | "replaced">(),
|
|
pending: new Map(),
|
|
tunnels: BrowserTunnel.make(),
|
|
}
|
|
browsers.set(input.sessionID, browser)
|
|
return browser
|
|
}),
|
|
(browser) => (browsers.get(input.sessionID) === browser ? close(input.sessionID) : Effect.void),
|
|
)
|
|
yield* rpc.events
|
|
.emit("control", { type: "attached", connectionID: input.connectionID, version: 4 })
|
|
.pipe(Effect.orDie)
|
|
return yield* Deferred.await(browser.closed)
|
|
}).pipe(Effect.scoped),
|
|
state: (input, call) =>
|
|
Effect.gen(function* () {
|
|
const browser = browsers.get(input.sessionID)
|
|
if (!browser || browser.connectionID !== input.connectionID)
|
|
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
|
|
browser.state = input.state
|
|
}),
|
|
command: (input, call) =>
|
|
Effect.gen(function* () {
|
|
const browser = browsers.get(input.sessionID)
|
|
const pending =
|
|
browser?.connectionID === input.connectionID ? browser.pending.get(input.requestID) : undefined
|
|
if (!pending)
|
|
return yield* Effect.fail(call.error("unavailable", "Browser request is no longer available.", {}))
|
|
return pending.command
|
|
}),
|
|
result: (input, call) =>
|
|
Effect.gen(function* () {
|
|
const browser = browsers.get(input.sessionID)
|
|
if (!browser || browser.connectionID !== input.connectionID)
|
|
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
|
|
const pending = browser.pending.get(input.requestID)
|
|
if (!pending) return
|
|
if (input.outcome.type === "failure")
|
|
return yield* Deferred.fail(
|
|
pending.result,
|
|
new Tool.Error({ message: `[browser.${input.outcome.code}] ${input.outcome.message}` }),
|
|
).pipe(Effect.asVoid)
|
|
yield* Deferred.succeed(pending.result, input.outcome.result)
|
|
}).pipe(Effect.asVoid),
|
|
"tunnel.open": (input, call) =>
|
|
tunnels(input).pipe(
|
|
Effect.flatMap((network) => network.open(input.target)),
|
|
Effect.mapError((error) => call.error("unavailable", error.message, {})),
|
|
),
|
|
"tunnel.read": (input, call) =>
|
|
tunnels(input).pipe(
|
|
Effect.flatMap((network) => network.read(input.tunnelID)),
|
|
Effect.mapError((error) => call.error("unavailable", error.message, {})),
|
|
),
|
|
"tunnel.write": (input, call) =>
|
|
tunnels(input).pipe(
|
|
Effect.flatMap((network) => network.write(input.tunnelID, input.data, input.end)),
|
|
Effect.mapError((error) => call.error("unavailable", error.message, {})),
|
|
),
|
|
"tunnel.close": (input, call) =>
|
|
tunnels(input).pipe(
|
|
Effect.flatMap((network) => network.close(input.tunnelID)),
|
|
Effect.mapError((error) => call.error("unavailable", error.message, {})),
|
|
),
|
|
})
|
|
.pipe(Effect.orDie)
|
|
yield* ctx.event.subscribe().pipe(
|
|
Stream.filter((event) => event.type === "session.deleted" || event.type === "session.moved"),
|
|
Stream.runForEach((event) => close(event.data.sessionID)),
|
|
Effect.forkScoped({ startImmediately: true }),
|
|
)
|
|
|
|
return {
|
|
target: Effect.fn("BrowserConnection.target")(function* (sessionID: Session.ID, action: Browser.Action) {
|
|
const browser = browsers.get(sessionID)
|
|
if (!browser)
|
|
return yield* new Tool.Error({
|
|
message:
|
|
"[browser.disconnected] No desktop browser is connected to this session. Open this session in the desktop app, enable the experimental browser setting, and wait for it to connect. Then call browser.tabs.list({}). Repeating browser actions while disconnected will not help.",
|
|
})
|
|
const tab = "tabID" in action ? browser.state.tabs.find((tab) => tab.id === action.tabID) : undefined
|
|
if ("tabID" in action && !tab)
|
|
return yield* new Tool.Error({
|
|
message:
|
|
"[browser.tab_unavailable] This tab is closed or does not belong to the connected session. Call browser.tabs.list({}) and use an exact returned tabID. If no tabs exist, use browser.tabs.open({}). Never substitute a request ID, file ID, or element ref for tabID.",
|
|
})
|
|
// Keep the selected attachment and document, even while permissions or file IO wait.
|
|
return {
|
|
tab,
|
|
inspect: () =>
|
|
request(rpc, browser, action, tab, [], { inspect: true }).pipe(
|
|
Effect.flatMap((result) => Schema.decodeUnknownEffect(Browser.Target)(result.value)),
|
|
Effect.mapError(
|
|
(error) =>
|
|
new Tool.Error({
|
|
message:
|
|
error instanceof Tool.Error
|
|
? error.message
|
|
: "Browser returned invalid target metadata. Check desktop/plugin versions; no action was authorized.",
|
|
error,
|
|
}),
|
|
),
|
|
),
|
|
request: (files: readonly Browser.File[], target?: Browser.Target) =>
|
|
request(rpc, browser, action, tab, files, { target }),
|
|
}
|
|
}),
|
|
}
|
|
})
|
|
|
|
const request = Effect.fn("BrowserConnection.request")(function* (
|
|
rpc: RpcRegistration<typeof Browser.Definition>,
|
|
browser: Attachment,
|
|
action: Browser.Action,
|
|
tab: Browser.Tab | undefined,
|
|
files: readonly Browser.File[],
|
|
inspection: Pick<Browser.Command, "inspect" | "target">,
|
|
) {
|
|
const requestID = crypto.randomUUID()
|
|
const pending = yield* Deferred.make<Browser.Result, Tool.Error>()
|
|
const command =
|
|
(action.type === "files.upload" || action.type === "files.drop") && !inspection.inspect
|
|
? { ...action, paths: files.map((file) => file.name) }
|
|
: action
|
|
browser.pending.set(requestID, {
|
|
command: { action: command, ...(tab ? { generation: tab.generation } : {}), files, ...inspection },
|
|
result: pending,
|
|
})
|
|
return yield* rpc.events.emit("control", { type: "command", connectionID: browser.connectionID, requestID }).pipe(
|
|
Effect.mapError(
|
|
(error) =>
|
|
new Tool.Error({
|
|
message: `Could not dispatch browser.${action.type}. Check the desktop connection and call browser.tabs.list({}) before deciding whether to retry.`,
|
|
error,
|
|
}),
|
|
),
|
|
Effect.andThen(Deferred.await(pending)),
|
|
Effect.raceFirst(
|
|
Deferred.await(browser.closed).pipe(
|
|
Effect.andThen(
|
|
new Tool.Error({
|
|
message:
|
|
"[browser.disconnected] Browser connection closed; the action may already have run. Reconnect this session in the desktop app, call browser.tabs.list({}), and inspect the target tab with browser.snapshot({tabID}). Do not repeat clicks, submissions, uploads, or evaluations until their outcome is known.",
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
Effect.onInterrupt(() =>
|
|
rpc.events.emit("control", { type: "cancel", connectionID: browser.connectionID, requestID }).pipe(Effect.ignore),
|
|
),
|
|
Effect.timeoutOrElse({
|
|
duration: "60 seconds",
|
|
orElse: () =>
|
|
new Tool.Error({
|
|
message: `[browser.timeout] browser.${action.type} did not finish within 60 seconds; its outcome is unknown. Check the desktop connection, call browser.tabs.list({}), and inspect the tab or browser.files.list({tabID}) for completed work. Do not blindly repeat a mutating action or start another recording.`,
|
|
}),
|
|
}),
|
|
Effect.ensuring(Effect.sync(() => browser.pending.delete(requestID))),
|
|
)
|
|
})
|