Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
10 kB · 222 lines
TypeScript
at working
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223export * 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 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))), )})