refactor(runtime): split runtime-rpc.ts into a schema-validated method registry (#980)

This commit is contained in:
Neil
2026-04-23 00:27:20 -07:00
committed by GitHub
parent 29743b8f3b
commit 96c5ca2636
13 changed files with 1406 additions and 1947 deletions
+101
View File
@@ -0,0 +1,101 @@
// Why: this is the single boundary between raw RPC frames and the OrcaRuntimeService.
// Keeping the schema, handler, and result type attached to one object makes the
// CLI-facing contract greppable and lets the dispatcher verify every payload
// against the same shape the handler consumed during development.
import { ZodError, type ZodType } from 'zod'
import type { OrcaRuntimeService } from '../orca-runtime'
export type RpcEnvelopeMeta = {
runtimeId: string
}
export type RpcSuccess = {
id: string
ok: true
result: unknown
_meta: RpcEnvelopeMeta
}
export type RpcFailure = {
id: string
ok: false
error: {
code: string
message: string
data?: unknown
}
_meta: RpcEnvelopeMeta
}
export type RpcResponse = RpcSuccess | RpcFailure
export type RpcRequest = {
id: string
authToken: string
method: string
params?: unknown
}
export type RpcContext = {
runtime: OrcaRuntimeService
}
export type RpcHandler<TParams> = (params: TParams, ctx: RpcContext) => Promise<unknown> | unknown
// Why: defineMethod preserves the inferred param type locally so each handler
// is fully typed, but the erased `RpcMethod` form is what the dispatcher
// actually stores. The erasure lives in one cast inside defineMethod rather
// than in every method file, which is the tradeoff for the variance problem
// of `RpcHandler` being contravariant in its param type.
export type RpcMethod = {
readonly name: string
readonly params: ZodType | null
readonly handler: (params: unknown, ctx: RpcContext) => Promise<unknown> | unknown
}
type DefineMethodSpec<TSchema extends ZodType | null> = {
name: string
params: TSchema
handler: RpcHandler<TSchema extends ZodType ? TSchema['_output'] : void>
}
export function defineMethod<TSchema extends ZodType | null>(
spec: DefineMethodSpec<TSchema>
): RpcMethod {
return {
name: spec.name,
params: spec.params,
handler: spec.handler as RpcMethod['handler']
}
}
export type RpcRegistry = ReadonlyMap<string, RpcMethod>
export function buildRegistry(methods: readonly RpcMethod[]): RpcRegistry {
const registry = new Map<string, RpcMethod>()
for (const method of methods) {
if (registry.has(method.name)) {
throw new Error(`duplicate_rpc_method:${method.name}`)
}
registry.set(method.name, method)
}
return registry
}
export class InvalidArgumentError extends Error {
constructor(message: string) {
super(message)
this.name = 'InvalidArgumentError'
}
}
// Why: zod aggregates all failing fields into `issues`, but the CLI surfaces
// a single string to users. Pick the first issue's message so callers see a
// message that matches the original handler's `Missing terminal handle`-style
// phrasing (each schema supplies that literal message on its own constraint).
export function formatZodError(error: ZodError): string {
const first = error.issues[0]
return first?.message ?? 'invalid_argument'
}
export { ZodError }
+78
View File
@@ -0,0 +1,78 @@
// Why: the dispatcher is the one place that knows how to turn a validated
// RPC request into a response envelope. Splitting it from the transport
// makes it unit-testable without spinning up a socket, and keeps
// runtime-rpc.ts focused on framing/auth/connection bookkeeping.
import {
ZodError,
buildRegistry,
formatZodError,
type RpcEnvelopeMeta,
type RpcMethod,
type RpcRegistry,
type RpcRequest,
type RpcResponse
} from './core'
import { errorResponse, mapBrowserError, mapRuntimeError, successResponse } from './errors'
import { ALL_RPC_METHODS } from './methods'
import type { OrcaRuntimeService } from '../orca-runtime'
export type DispatcherOptions = {
runtime: OrcaRuntimeService
methods?: readonly RpcMethod[]
}
export class RpcDispatcher {
private readonly runtime: OrcaRuntimeService
private readonly registry: RpcRegistry
constructor({ runtime, methods = ALL_RPC_METHODS }: DispatcherOptions) {
this.runtime = runtime
this.registry = buildRegistry(methods)
}
async dispatch(request: RpcRequest): Promise<RpcResponse> {
const meta = this.meta()
const method = this.registry.get(request.method)
if (!method) {
return errorResponse(
request.id,
meta,
'method_not_found',
`Unknown method: ${request.method}`
)
}
let parsedParams: unknown
if (method.params === null) {
parsedParams = undefined
} else {
const rawParams = request.params ?? {}
const result = method.params.safeParse(rawParams)
if (!result.success) {
return errorResponse(request.id, meta, 'invalid_argument', formatZodError(result.error))
}
parsedParams = result.data
}
try {
const result = await method.handler(parsedParams, { runtime: this.runtime })
return successResponse(request.id, meta, result)
} catch (error) {
// Why: browser methods throw BrowserError with a structured `code`;
// every other runtime error has a plain-message code. Routing by method
// prefix keeps the mapping a single decision rather than a per-method
// flag callers must remember to set.
if (request.method.startsWith('browser.')) {
return mapBrowserError(request.id, meta, error)
}
if (error instanceof ZodError) {
return errorResponse(request.id, meta, 'invalid_argument', formatZodError(error))
}
return mapRuntimeError(request.id, meta, error)
}
}
private meta(): RpcEnvelopeMeta {
return { runtimeId: this.runtime.getRuntimeId() }
}
}
+73
View File
@@ -0,0 +1,73 @@
// Why: every RPC response needs the same runtimeId envelope, and the
// runtime/browser error allowlists define the contract the CLI relies on to
// format human-facing messages. Centralizing this mapping keeps the allowlist
// auditable in one place instead of spread across per-method branches.
import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core'
export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
return {
id,
ok: true,
result,
_meta: meta
}
}
export function errorResponse(
id: string,
meta: RpcEnvelopeMeta,
code: string,
message: string,
data?: unknown
): RpcFailure {
return {
id,
ok: false,
error: data === undefined ? { code, message } : { code, message, data },
_meta: meta
}
}
// Why: the OrcaRuntimeService throws plain Error objects whose `message` is
// actually a stable error code. This allowlist is the contract the CLI relies
// on — expanding or renaming entries without updating the CLI would silently
// change user-visible error codes.
const RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
'runtime_unavailable',
'selector_not_found',
'selector_ambiguous',
'terminal_handle_stale',
'terminal_not_writable',
'terminal_exited',
'terminal_gone',
'no_active_terminal',
'repo_not_found',
'timeout',
'invalid_limit'
])
export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure {
const message = error instanceof Error ? error.message : String(error)
if (RUNTIME_PASSTHROUGH_CODES.has(message)) {
return errorResponse(id, meta, message, message)
}
if (message === 'invalid_terminal_send') {
return errorResponse(id, meta, 'invalid_argument', 'Missing terminal send payload')
}
return errorResponse(id, meta, 'runtime_error', message)
}
// Why: browser errors carry a structured .code property (BrowserError from
// cdp-bridge.ts) that maps directly to agent-facing error codes. We forward
// that code rather than falling back to the runtime allowlist, because the
// browser surface area uses its own code namespace (browser_no_tab, etc.).
export function mapBrowserError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure {
if (
error instanceof Error &&
'code' in error &&
typeof (error as { code: unknown }).code === 'string'
) {
return errorResponse(id, meta, (error as { code: string }).code, error.message)
}
return mapRuntimeError(id, meta, error)
}
@@ -0,0 +1,233 @@
import { defineMethod, type RpcMethod } from '../core'
import { BrowserTarget } from '../schemas'
import {
Check,
Drag,
Element,
Eval,
Exec,
Fill,
Find,
FullScreenshot,
Get,
Goto,
Highlight,
Is,
KeyboardInsert,
Keypress,
LimitParam,
Screenshot,
Scroll,
Select,
SelectorPath,
TabClose,
TabCreate,
TabList,
TabSwitch,
Type,
Upload,
Wait
} from './browser-schemas'
export const BROWSER_CORE_METHODS: RpcMethod[] = [
defineMethod({
name: 'browser.snapshot',
params: BrowserTarget,
handler: async (params, { runtime }) => runtime.browserSnapshot(params)
}),
defineMethod({
name: 'browser.click',
params: Element,
handler: async (params, { runtime }) => runtime.browserClick(params)
}),
defineMethod({
name: 'browser.goto',
params: Goto,
handler: async (params, { runtime }) => runtime.browserGoto(params)
}),
defineMethod({
name: 'browser.fill',
params: Fill,
handler: async (params, { runtime }) => runtime.browserFill(params)
}),
defineMethod({
name: 'browser.type',
params: Type,
handler: async (params, { runtime }) => runtime.browserType(params)
}),
defineMethod({
name: 'browser.select',
params: Select,
handler: async (params, { runtime }) => runtime.browserSelect(params)
}),
defineMethod({
name: 'browser.scroll',
params: Scroll,
handler: async (params, { runtime }) => runtime.browserScroll(params)
}),
defineMethod({
name: 'browser.back',
params: BrowserTarget,
handler: async (params, { runtime }) => runtime.browserBack(params)
}),
defineMethod({
name: 'browser.reload',
params: BrowserTarget,
handler: async (params, { runtime }) => runtime.browserReload(params)
}),
defineMethod({
name: 'browser.screenshot',
params: Screenshot,
handler: async (params, { runtime }) => runtime.browserScreenshot(params)
}),
defineMethod({
name: 'browser.eval',
params: Eval,
handler: async (params, { runtime }) => runtime.browserEval(params)
}),
defineMethod({
name: 'browser.tabList',
params: TabList,
handler: async (params, { runtime }) => runtime.browserTabList(params)
}),
defineMethod({
name: 'browser.tabSwitch',
params: TabSwitch,
handler: async (params, { runtime }) => runtime.browserTabSwitch(params)
}),
defineMethod({
name: 'browser.tabCreate',
params: TabCreate,
handler: async (params, { runtime }) => runtime.browserTabCreate(params)
}),
defineMethod({
name: 'browser.tabClose',
params: TabClose,
handler: async (params, { runtime }) => runtime.browserTabClose(params)
}),
defineMethod({
name: 'browser.hover',
params: Element,
handler: async (params, { runtime }) => runtime.browserHover(params)
}),
defineMethod({
name: 'browser.drag',
params: Drag,
handler: async (params, { runtime }) => runtime.browserDrag(params)
}),
defineMethod({
name: 'browser.upload',
params: Upload,
handler: async (params, { runtime }) => runtime.browserUpload(params)
}),
defineMethod({
name: 'browser.wait',
params: Wait,
handler: async (params, { runtime }) => runtime.browserWait(params)
}),
defineMethod({
name: 'browser.check',
params: Check,
handler: async (params, { runtime }) => runtime.browserCheck(params)
}),
defineMethod({
name: 'browser.focus',
params: Element,
handler: async (params, { runtime }) => runtime.browserFocus(params)
}),
defineMethod({
name: 'browser.clear',
params: Element,
handler: async (params, { runtime }) => runtime.browserClear(params)
}),
defineMethod({
name: 'browser.selectAll',
params: Element,
handler: async (params, { runtime }) => runtime.browserSelectAll(params)
}),
defineMethod({
name: 'browser.keypress',
params: Keypress,
handler: async (params, { runtime }) => runtime.browserKeypress(params)
}),
defineMethod({
name: 'browser.pdf',
params: BrowserTarget,
handler: async (params, { runtime }) => runtime.browserPdf(params)
}),
defineMethod({
name: 'browser.fullScreenshot',
params: FullScreenshot,
handler: async (params, { runtime }) => runtime.browserFullScreenshot(params)
}),
defineMethod({
name: 'browser.dblclick',
params: Element,
handler: async (params, { runtime }) => runtime.browserDblclick(params)
}),
defineMethod({
name: 'browser.forward',
params: BrowserTarget,
handler: async (params, { runtime }) => runtime.browserForward(params)
}),
defineMethod({
name: 'browser.scrollIntoView',
params: Element,
handler: async (params, { runtime }) => runtime.browserScrollIntoView(params)
}),
defineMethod({
name: 'browser.get',
params: Get,
handler: async (params, { runtime }) => runtime.browserGet(params)
}),
defineMethod({
name: 'browser.is',
params: Is,
handler: async (params, { runtime }) => runtime.browserIs(params)
}),
defineMethod({
name: 'browser.keyboardInsertText',
params: KeyboardInsert,
handler: async (params, { runtime }) => runtime.browserKeyboardInsertText(params)
}),
defineMethod({
name: 'browser.find',
params: Find,
handler: async (params, { runtime }) => runtime.browserFind(params)
}),
defineMethod({
name: 'browser.console',
params: LimitParam,
handler: async (params, { runtime }) => runtime.browserConsoleLog(params)
}),
defineMethod({
name: 'browser.network',
params: LimitParam,
handler: async (params, { runtime }) => runtime.browserNetworkLog(params)
}),
defineMethod({
name: 'browser.exec',
params: Exec,
handler: async (params, { runtime }) => runtime.browserExec(params)
}),
defineMethod({
name: 'browser.capture.start',
params: BrowserTarget,
handler: async (params, { runtime }) => runtime.browserCaptureStart(params)
}),
defineMethod({
name: 'browser.capture.stop',
params: BrowserTarget,
handler: async (params, { runtime }) => runtime.browserCaptureStop(params)
}),
defineMethod({
name: 'browser.download',
params: SelectorPath,
handler: async (params, { runtime }) => runtime.browserDownload(params)
}),
defineMethod({
name: 'browser.highlight',
params: Highlight,
handler: async (params, { runtime }) => runtime.browserHighlight(params)
})
]
@@ -0,0 +1,160 @@
import { defineMethod, type RpcMethod } from '../core'
import { BrowserTarget } from '../schemas'
import {
ClipboardWrite,
CookieDelete,
CookieGet,
CookieSet,
DialogAccept,
Geolocation,
InterceptEnable,
MouseButton,
MouseWheel,
MouseXY,
SetCredentials,
SetDevice,
SetHeaders,
SetMedia,
SetOffline,
StorageKey,
StorageKeyValue,
Viewport
} from './browser-schemas'
export const BROWSER_EXTRA_METHODS: RpcMethod[] = [
defineMethod({
name: 'browser.cookie.get',
params: CookieGet,
handler: async (params, { runtime }) => runtime.browserCookieGet(params)
}),
defineMethod({
name: 'browser.cookie.set',
params: CookieSet,
handler: async (params, { runtime }) => runtime.browserCookieSet(params)
}),
defineMethod({
name: 'browser.cookie.delete',
params: CookieDelete,
handler: async (params, { runtime }) => runtime.browserCookieDelete(params)
}),
defineMethod({
name: 'browser.viewport',
params: Viewport,
handler: async (params, { runtime }) => runtime.browserSetViewport(params)
}),
defineMethod({
name: 'browser.geolocation',
params: Geolocation,
handler: async (params, { runtime }) => runtime.browserSetGeolocation(params)
}),
defineMethod({
name: 'browser.intercept.enable',
params: InterceptEnable,
handler: async (params, { runtime }) => runtime.browserInterceptEnable(params)
}),
defineMethod({
name: 'browser.intercept.disable',
params: BrowserTarget,
handler: async (params, { runtime }) => runtime.browserInterceptDisable(params)
}),
defineMethod({
name: 'browser.intercept.list',
params: BrowserTarget,
handler: async (params, { runtime }) => runtime.browserInterceptList(params)
}),
defineMethod({
name: 'browser.mouseMove',
params: MouseXY,
handler: async (params, { runtime }) => runtime.browserMouseMove(params)
}),
defineMethod({
name: 'browser.mouseDown',
params: MouseButton,
handler: async (params, { runtime }) => runtime.browserMouseDown(params)
}),
defineMethod({
name: 'browser.mouseUp',
params: MouseButton,
handler: async (params, { runtime }) => runtime.browserMouseUp(params)
}),
defineMethod({
name: 'browser.mouseWheel',
params: MouseWheel,
handler: async (params, { runtime }) => runtime.browserMouseWheel(params)
}),
defineMethod({
name: 'browser.setDevice',
params: SetDevice,
handler: async (params, { runtime }) => runtime.browserSetDevice(params)
}),
defineMethod({
name: 'browser.setOffline',
params: SetOffline,
handler: async (params, { runtime }) => runtime.browserSetOffline(params)
}),
defineMethod({
name: 'browser.setHeaders',
params: SetHeaders,
handler: async (params, { runtime }) => runtime.browserSetHeaders(params)
}),
defineMethod({
name: 'browser.setCredentials',
params: SetCredentials,
handler: async (params, { runtime }) => runtime.browserSetCredentials(params)
}),
defineMethod({
name: 'browser.setMedia',
params: SetMedia,
handler: async (params, { runtime }) => runtime.browserSetMedia(params)
}),
defineMethod({
name: 'browser.clipboardRead',
params: BrowserTarget,
handler: async (params, { runtime }) => runtime.browserClipboardRead(params)
}),
defineMethod({
name: 'browser.clipboardWrite',
params: ClipboardWrite,
handler: async (params, { runtime }) => runtime.browserClipboardWrite(params)
}),
defineMethod({
name: 'browser.dialogAccept',
params: DialogAccept,
handler: async (params, { runtime }) => runtime.browserDialogAccept(params)
}),
defineMethod({
name: 'browser.dialogDismiss',
params: BrowserTarget,
handler: async (params, { runtime }) => runtime.browserDialogDismiss(params)
}),
defineMethod({
name: 'browser.storage.local.get',
params: StorageKey,
handler: async (params, { runtime }) => runtime.browserStorageLocalGet(params)
}),
defineMethod({
name: 'browser.storage.local.set',
params: StorageKeyValue,
handler: async (params, { runtime }) => runtime.browserStorageLocalSet(params)
}),
defineMethod({
name: 'browser.storage.local.clear',
params: BrowserTarget,
handler: async (params, { runtime }) => runtime.browserStorageLocalClear(params)
}),
defineMethod({
name: 'browser.storage.session.get',
params: StorageKey,
handler: async (params, { runtime }) => runtime.browserStorageSessionGet(params)
}),
defineMethod({
name: 'browser.storage.session.set',
params: StorageKeyValue,
handler: async (params, { runtime }) => runtime.browserStorageSessionSet(params)
}),
defineMethod({
name: 'browser.storage.session.clear',
params: BrowserTarget,
handler: async (params, { runtime }) => runtime.browserStorageSessionClear(params)
})
]
@@ -0,0 +1,303 @@
// Why: the browser method surface area is large enough that keeping every
// schema in the same file as its handler registration pushes the file past
// the 300-line lint cap. Grouping all browser schemas here keeps each
// handler file focused on dispatch wiring.
import { z } from 'zod'
import {
BrowserTarget,
OptionalBoolean,
OptionalFiniteNumber,
OptionalPlainString,
OptionalString,
requiredString
} from '../schemas'
export const Element = BrowserTarget.extend({
element: requiredString('Missing required --element')
})
export const Goto = BrowserTarget.extend({
url: requiredString('Missing required --url')
})
export const Fill = BrowserTarget.extend({
element: requiredString('Missing required --element'),
value: z.custom<string>((v) => typeof v === 'string', {
message: 'Missing required --value'
})
})
export const Type = BrowserTarget.extend({
input: requiredString('Missing required --input')
})
export const Select = BrowserTarget.extend({
element: requiredString('Missing required --element'),
value: z.custom<string>((v) => typeof v === 'string', {
message: 'Missing required --value'
})
})
export const Scroll = BrowserTarget.extend({
direction: z.custom<'up' | 'down'>((v) => v === 'up' || v === 'down', {
message: 'Missing required --direction (up or down)'
}),
amount: z
.unknown()
.transform((v) => (typeof v === 'number' && v > 0 ? v : undefined))
.pipe(z.number().optional())
})
export const Screenshot = BrowserTarget.extend({
format: z
.unknown()
.transform((v) => (v === 'png' || v === 'jpeg' ? v : undefined))
.pipe(z.enum(['png', 'jpeg']).optional())
})
export const FullScreenshot = BrowserTarget.extend({
format: z
.unknown()
.transform((v) => (v === 'jpeg' ? 'jpeg' : 'png'))
.pipe(z.enum(['png', 'jpeg']))
})
export const Eval = BrowserTarget.extend({
expression: requiredString('Missing required --expression')
})
export const TabList = z.object({
worktree: OptionalString
})
// Why: --index xor --page must be present. The refine guards that invariant
// so the dispatcher surfaces a single legible error instead of either shape
// leaking into the runtime.
export const TabSwitch = BrowserTarget.extend({
index: z
.unknown()
.transform((v) => (typeof v === 'number' ? v : undefined))
.pipe(z.number().optional())
}).refine(
(val) => {
if (val.page !== undefined) {
return true
}
return val.index !== undefined && Number.isInteger(val.index) && val.index >= 0
},
{ message: 'Missing required --index (non-negative integer) or --page' }
)
export const TabCreate = z.object({
url: OptionalString,
worktree: OptionalString
})
export const TabClose = z.object({
index: z
.unknown()
.transform((v) => (typeof v === 'number' ? v : undefined))
.pipe(z.number().optional()),
page: OptionalString,
worktree: OptionalString
})
export const Drag = BrowserTarget.extend({
from: requiredString('Missing required --from and --to element refs'),
to: requiredString('Missing required --from and --to element refs')
})
export const Upload = BrowserTarget.extend({
element: requiredString('Missing required --element and --files'),
files: z.custom<string[]>(
(v) => Array.isArray(v) && v.length > 0 && v.every((f) => typeof f === 'string'),
{ message: 'Missing required --element and --files' }
)
})
export const Wait = BrowserTarget.extend({
selector: OptionalPlainString,
timeout: z
.unknown()
.transform((v) => (typeof v === 'number' && v > 0 ? v : undefined))
.pipe(z.number().optional()),
text: OptionalPlainString,
url: OptionalPlainString,
load: OptionalPlainString,
fn: OptionalPlainString,
state: OptionalPlainString
})
export const Check = BrowserTarget.extend({
element: requiredString('Missing required --element'),
checked: z
.unknown()
.transform((v) => v !== false)
.pipe(z.boolean())
})
export const Keypress = BrowserTarget.extend({
key: requiredString('Missing required --key')
})
export const SelectorPath = BrowserTarget.extend({
selector: requiredString('Missing required --selector and --path'),
path: requiredString('Missing required --selector and --path')
})
export const Highlight = BrowserTarget.extend({
selector: requiredString('Missing required --selector')
})
export const Exec = BrowserTarget.extend({
command: requiredString('Missing required --command')
})
export const Get = BrowserTarget.extend({
what: requiredString('Missing required --what'),
selector: OptionalString
})
export const Is = BrowserTarget.extend({
what: z.custom<string>((v) => typeof v === 'string' && v.length > 0, {
message: 'Missing required --what and --element'
}),
selector: z.custom<string>((v) => typeof v === 'string' && v.length > 0, {
message: 'Missing required --what and --element'
})
})
export const KeyboardInsert = BrowserTarget.extend({
text: requiredString('Missing required --text')
})
export const LimitParam = BrowserTarget.extend({
limit: OptionalFiniteNumber
})
export const Find = BrowserTarget.extend({
locator: requiredString('Missing required --locator, --value, and --action'),
value: requiredString('Missing required --locator, --value, and --action'),
action: requiredString('Missing required --locator, --value, and --action'),
text: OptionalString
})
export const CookieGet = BrowserTarget.extend({
url: OptionalPlainString
})
export const CookieSet = BrowserTarget.extend({
name: z.custom<string>((v) => typeof v === 'string' && v.length > 0, {
message: 'Missing name or value'
}),
value: z.custom<string>((v) => typeof v === 'string', {
message: 'Missing name or value'
}),
domain: OptionalPlainString,
path: OptionalPlainString,
secure: OptionalBoolean,
httpOnly: OptionalBoolean,
sameSite: OptionalPlainString,
expires: OptionalFiniteNumber
})
export const CookieDelete = BrowserTarget.extend({
name: requiredString('Missing cookie name'),
domain: OptionalPlainString,
url: OptionalPlainString
})
export const Viewport = BrowserTarget.extend({
width: z.custom<number>((v) => typeof v === 'number' && v > 0, {
message: 'Width and height must be positive numbers'
}),
height: z.custom<number>((v) => typeof v === 'number' && v > 0, {
message: 'Width and height must be positive numbers'
}),
deviceScaleFactor: OptionalFiniteNumber,
mobile: OptionalBoolean
})
export const Geolocation = BrowserTarget.extend({
latitude: z.custom<number>((v) => typeof v === 'number', {
message: 'Missing latitude or longitude'
}),
longitude: z.custom<number>((v) => typeof v === 'number', {
message: 'Missing latitude or longitude'
}),
accuracy: OptionalFiniteNumber
})
export const InterceptEnable = BrowserTarget.extend({
patterns: z
.unknown()
.transform((v) => (Array.isArray(v) ? (v as string[]) : undefined))
.pipe(z.array(z.string()).optional())
})
export const MouseXY = BrowserTarget.extend({
x: z.custom<number>((v) => typeof v === 'number', {
message: 'Missing required x and y coordinates'
}),
y: z.custom<number>((v) => typeof v === 'number', {
message: 'Missing required x and y coordinates'
})
})
export const MouseButton = BrowserTarget.extend({
button: OptionalPlainString
})
export const MouseWheel = BrowserTarget.extend({
dy: z.custom<number>((v) => typeof v === 'number', {
message: 'Missing required --dy'
}),
dx: OptionalFiniteNumber
})
export const SetDevice = BrowserTarget.extend({
name: requiredString('Missing required --name')
})
export const SetOffline = BrowserTarget.extend({
state: OptionalPlainString
})
export const SetHeaders = BrowserTarget.extend({
headers: requiredString('Missing required --headers (JSON string)')
})
export const SetCredentials = BrowserTarget.extend({
user: z.custom<string>((v) => typeof v === 'string' && v.length > 0, {
message: 'Missing required --user and --pass'
}),
pass: z.custom<string>((v) => typeof v === 'string', {
message: 'Missing required --user and --pass'
})
})
export const SetMedia = BrowserTarget.extend({
colorScheme: OptionalPlainString,
reducedMotion: OptionalPlainString
})
export const ClipboardWrite = BrowserTarget.extend({
text: requiredString('Missing required --text')
})
export const DialogAccept = BrowserTarget.extend({
text: OptionalPlainString
})
export const StorageKey = BrowserTarget.extend({
key: requiredString('Missing required --key')
})
export const StorageKeyValue = BrowserTarget.extend({
key: z.custom<string>((v) => typeof v === 'string' && v.length > 0, {
message: 'Missing required --key and --value'
}),
value: z.custom<string>((v) => typeof v === 'string', {
message: 'Missing required --key and --value'
})
})
+19
View File
@@ -0,0 +1,19 @@
import type { RpcMethod } from '../core'
import { STATUS_METHODS } from './status'
import { REPO_METHODS } from './repo'
import { WORKTREE_METHODS } from './worktree'
import { TERMINAL_METHODS } from './terminal'
import { BROWSER_CORE_METHODS } from './browser-core'
import { BROWSER_EXTRA_METHODS } from './browser-extras'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
// auditing the security boundary or wiring new CLI commands.
export const ALL_RPC_METHODS: readonly RpcMethod[] = [
...STATUS_METHODS,
...REPO_METHODS,
...WORKTREE_METHODS,
...TERMINAL_METHODS,
...BROWSER_CORE_METHODS,
...BROWSER_EXTRA_METHODS
]
+56
View File
@@ -0,0 +1,56 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, requiredString } from '../schemas'
const RepoSelector = z.object({
repo: requiredString('Missing repo selector')
})
const RepoPath = z.object({
path: requiredString('Missing repo path')
})
const RepoSetBaseRef = z.object({
repo: requiredString('Missing repo selector'),
ref: requiredString('Missing base ref')
})
const RepoSearchRefs = z.object({
repo: requiredString('Missing repo selector'),
query: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : undefined))
.pipe(z.string({ message: 'Missing query' })),
limit: OptionalFiniteNumber
})
export const REPO_METHODS: RpcMethod[] = [
defineMethod({
name: 'repo.list',
params: null,
handler: (_params, { runtime }) => ({ repos: runtime.listRepos() })
}),
defineMethod({
name: 'repo.add',
params: RepoPath,
handler: async (params, { runtime }) => ({ repo: await runtime.addRepo(params.path) })
}),
defineMethod({
name: 'repo.show',
params: RepoSelector,
handler: async (params, { runtime }) => ({ repo: await runtime.showRepo(params.repo) })
}),
defineMethod({
name: 'repo.setBaseRef',
params: RepoSetBaseRef,
handler: async (params, { runtime }) => ({
repo: await runtime.setRepoBaseRef(params.repo, params.ref)
})
}),
defineMethod({
name: 'repo.searchRefs',
params: RepoSearchRefs,
handler: async (params, { runtime }) =>
runtime.searchRepoRefs(params.repo, params.query, params.limit)
})
]
+9
View File
@@ -0,0 +1,9 @@
import { defineMethod, type RpcMethod } from '../core'
export const STATUS_METHODS: RpcMethod[] = [
defineMethod({
name: 'status.get',
params: null,
handler: (_params, { runtime }) => runtime.getStatus()
})
]
+174
View File
@@ -0,0 +1,174 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
const TerminalHandle = z.object({
terminal: requiredString('Missing terminal handle')
})
const TerminalListParams = z.object({
worktree: OptionalString,
limit: OptionalFiniteNumber
})
const TerminalResolveActive = z.object({
worktree: OptionalString
})
const TerminalRead = TerminalHandle.extend({
cursor: z
.unknown()
.transform((value) => {
if (value === undefined) {
return undefined
}
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
return Number.NaN
}
return value
})
.pipe(
z
.number()
.optional()
.refine((v) => v === undefined || Number.isFinite(v), {
message: 'Cursor must be a non-negative integer'
})
)
})
// Why: the legacy handler allowed `title: string | null` and rejected every
// other shape (including `undefined`) with a specific message, which is how
// the CLI signals an intentional "reset". Preserve that distinction exactly.
const TerminalRename = TerminalHandle.extend({
title: z.custom<string | null>((value) => value === null || typeof value === 'string', {
message: 'Missing --title (pass empty string or null to reset)'
})
})
const TerminalSend = TerminalHandle.extend({
text: OptionalString,
enter: z.unknown().optional(),
interrupt: z.unknown().optional()
})
const TerminalWait = TerminalHandle.extend({
for: z.custom<'exit' | 'tui-idle'>((value) => value === 'exit' || value === 'tui-idle', {
message: 'Invalid --for value. Supported: exit, tui-idle'
}),
timeoutMs: OptionalFiniteNumber
})
const TerminalCreateParams = z.object({
worktree: OptionalString,
command: OptionalString,
title: OptionalString
})
const TerminalSplit = TerminalHandle.extend({
direction: z
.unknown()
.transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined))
.pipe(z.enum(['vertical', 'horizontal']).optional()),
command: OptionalString
})
const TerminalStop = z.object({
worktree: requiredString('Missing worktree selector')
})
export const TERMINAL_METHODS: RpcMethod[] = [
defineMethod({
name: 'terminal.list',
params: TerminalListParams,
handler: async (params, { runtime }) => runtime.listTerminals(params.worktree, params.limit)
}),
defineMethod({
name: 'terminal.resolveActive',
params: TerminalResolveActive,
handler: async (params, { runtime }) => ({
handle: await runtime.resolveActiveTerminal(params.worktree)
})
}),
defineMethod({
name: 'terminal.show',
params: TerminalHandle,
handler: async (params, { runtime }) => ({
terminal: await runtime.showTerminal(params.terminal)
})
}),
defineMethod({
name: 'terminal.read',
params: TerminalRead,
handler: async (params, { runtime }) => ({
terminal: await runtime.readTerminal(params.terminal, { cursor: params.cursor })
})
}),
defineMethod({
name: 'terminal.rename',
params: TerminalRename,
handler: async (params, { runtime }) => ({
rename: await runtime.renameTerminal(params.terminal, params.title || null)
})
}),
defineMethod({
name: 'terminal.send',
params: TerminalSend,
handler: async (params, { runtime }) => ({
send: await runtime.sendTerminal(params.terminal, {
text: params.text,
enter: params.enter === true,
interrupt: params.interrupt === true
})
})
}),
defineMethod({
name: 'terminal.wait',
params: TerminalWait,
handler: async (params, { runtime }) => ({
wait: await runtime.waitForTerminal(params.terminal, {
condition: params.for,
timeoutMs: params.timeoutMs
})
})
}),
defineMethod({
name: 'terminal.create',
params: TerminalCreateParams,
handler: async (params, { runtime }) => ({
terminal: await runtime.createTerminal(params.worktree, {
command: params.command,
title: params.title
})
})
}),
defineMethod({
name: 'terminal.split',
params: TerminalSplit,
handler: async (params, { runtime }) => ({
split: await runtime.splitTerminal(params.terminal, {
direction: params.direction,
command: params.command
})
})
}),
defineMethod({
name: 'terminal.stop',
params: TerminalStop,
handler: async (params, { runtime }) => runtime.stopTerminalsForWorktree(params.worktree)
}),
defineMethod({
name: 'terminal.focus',
params: TerminalHandle,
handler: async (params, { runtime }) => ({
focus: await runtime.focusTerminal(params.terminal)
})
}),
defineMethod({
name: 'terminal.close',
params: TerminalHandle,
handler: async (params, { runtime }) => ({
close: await runtime.closeTerminal(params.terminal)
})
})
]
+99
View File
@@ -0,0 +1,99 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import {
OptionalBoolean,
OptionalFiniteNumber,
OptionalString,
TriStateLinkedIssue
} from '../schemas'
const WorktreeListParams = z.object({
repo: OptionalString,
limit: OptionalFiniteNumber
})
const WorktreePsParams = z.object({
limit: OptionalFiniteNumber
})
const WorktreeSelector = z.object({
worktree: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing worktree selector'))
})
const WorktreeCreate = z.object({
repo: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing repo selector')),
name: z
.unknown()
.transform((v) => (typeof v === 'string' ? v : ''))
.pipe(z.string().min(1, 'Missing worktree name')),
baseBranch: OptionalString,
linkedIssue: TriStateLinkedIssue,
comment: OptionalString
})
const WorktreeSet = WorktreeSelector.extend({
displayName: OptionalString,
linkedIssue: TriStateLinkedIssue,
comment: OptionalString
})
const WorktreeRemove = WorktreeSelector.extend({
force: OptionalBoolean
})
export const WORKTREE_METHODS: RpcMethod[] = [
defineMethod({
name: 'worktree.ps',
params: WorktreePsParams,
handler: async (params, { runtime }) => runtime.getWorktreePs(params.limit)
}),
defineMethod({
name: 'worktree.list',
params: WorktreeListParams,
handler: async (params, { runtime }) => runtime.listManagedWorktrees(params.repo, params.limit)
}),
defineMethod({
name: 'worktree.show',
params: WorktreeSelector,
handler: async (params, { runtime }) => ({
worktree: await runtime.showManagedWorktree(params.worktree)
})
}),
defineMethod({
name: 'worktree.create',
params: WorktreeCreate,
handler: async (params, { runtime }) =>
runtime.createManagedWorktree({
repoSelector: params.repo,
name: params.name,
baseBranch: params.baseBranch,
linkedIssue: params.linkedIssue,
comment: params.comment
})
}),
defineMethod({
name: 'worktree.set',
params: WorktreeSet,
handler: async (params, { runtime }) => ({
worktree: await runtime.updateManagedWorktreeMeta(params.worktree, {
displayName: params.displayName,
linkedIssue: params.linkedIssue,
comment: params.comment
})
})
}),
defineMethod({
name: 'worktree.rm',
params: WorktreeRemove,
handler: async (params, { runtime }) => {
await runtime.removeManagedWorktree(params.worktree, params.force === true)
return { removed: true }
}
})
]
+79
View File
@@ -0,0 +1,79 @@
// Why: the RPC boundary ingests loosely-typed JSON from a CLI that grew
// organically, so these reusable pieces capture the validation shapes that
// recur across domains (optional worktree selector, bounded limit, browser
// target envelope, etc.). Methods compose these to declare their real
// contract without repeating the same `typeof` gymnastics 90 times.
import { z } from 'zod'
// Why: the original handlers treated non-numeric/NaN limit values as "no
// limit" rather than as errors. Preserve that forgiving behavior so CLI
// callers passing stringified numbers or Infinity still reach the runtime.
export const OptionalFiniteNumber = z
.unknown()
.transform((value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined))
.pipe(z.number().optional())
export const OptionalPositiveInt = z
.unknown()
.transform((value) =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
)
.pipe(z.number().optional())
export const OptionalString = z
.unknown()
.transform((value) => (typeof value === 'string' && value.length > 0 ? value : undefined))
.pipe(z.string().optional())
export const OptionalPlainString = z
.unknown()
.transform((value) => (typeof value === 'string' ? value : undefined))
.pipe(z.string().optional())
export const OptionalBoolean = z
.unknown()
.transform((value) => (typeof value === 'boolean' ? value : undefined))
.pipe(z.boolean().optional())
// Why: runtime handlers accept `linkedIssue: number | null | undefined` with
// distinct meanings — undefined means "no update", null means "clear", number
// means "set". The ambient JSON decode produces all three shapes as-is.
export const TriStateLinkedIssue = z
.unknown()
.transform((value) => {
if (value === null) {
return null
}
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
return undefined
})
.pipe(z.union([z.number(), z.null(), z.undefined()]))
// Why: the legacy extractBrowserTarget treated worktree as a plain-string
// passthrough (empty string preserved) but `page` as non-empty-string. The
// browser bridge uses worktree-as-empty-string to mean "any worktree", so
// keep that asymmetry intact to avoid widening scope unexpectedly.
export const BrowserTarget = z.object({
worktree: OptionalPlainString,
page: OptionalString
})
export type BrowserTargetInput = z.infer<typeof BrowserTarget>
export function requiredString(message: string) {
return z
.unknown()
.transform((value) => (typeof value === 'string' ? value : ''))
.pipe(z.string().min(1, message))
}
export function requiredNumber(message: string) {
return z
.unknown()
.transform((value) =>
typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN
)
.pipe(z.number().refine((v) => Number.isFinite(v), { message }))
}
File diff suppressed because it is too large Load Diff