import { parseNodeList, parsePairingList } from "../../shared/node-list-parse.js"; import type { NodeListNode } from "../../shared/node-list-types.js"; import { resolveNodeFromNodeList, resolveNodeIdFromNodeList } from "../../shared/node-resolve.js"; import { callGatewayTool, type GatewayCallOptions } from "./gateway.js"; export type { NodeListNode }; type DefaultNodeFallback = "none" | "first"; type DefaultNodeSelectionOptions = { capability?: string; fallback?: DefaultNodeFallback; preferLocalMac?: boolean; }; function messageFromError(error: unknown): string { if (error instanceof Error) { return error.message; } if (typeof error === "string") { return error; } if ( typeof error === "object" && error !== null && "message" in error && typeof (error as { message?: unknown }).message === "string" ) { return (error as { message: string }).message; } if (typeof error === "object" && error !== null) { try { return JSON.stringify(error); } catch { return ""; } } return ""; } function shouldFallbackToPairList(error: unknown): boolean { const message = messageFromError(error).toLowerCase(); if (!message.includes("node.list")) { return false; } return ( message.includes("unknown method") || message.includes("method not found") || message.includes("not implemented") || message.includes("unsupported") ); } async function loadNodes(opts: GatewayCallOptions): Promise { try { const res = await callGatewayTool("node.list", opts, {}); return parseNodeList(res); } catch (error) { if (!shouldFallbackToPairList(error)) { throw error; } const res = await callGatewayTool("node.pair.list", opts, {}); const { paired } = parsePairingList(res); return paired.map((n) => ({ nodeId: n.nodeId, displayName: n.displayName, platform: n.platform, remoteIp: n.remoteIp, })); } } function isLocalMacNode(node: NodeListNode): boolean { return ( node.platform?.toLowerCase().startsWith("mac") === true && typeof node.nodeId === "string" && node.nodeId.startsWith("mac-") ); } function compareDefaultNodeOrder(a: NodeListNode, b: NodeListNode): number { const aConnectedAt = Number.isFinite(a.connectedAtMs) ? (a.connectedAtMs ?? 0) : -1; const bConnectedAt = Number.isFinite(b.connectedAtMs) ? (b.connectedAtMs ?? 0) : -1; if (aConnectedAt !== bConnectedAt) { return bConnectedAt - aConnectedAt; } return a.nodeId.localeCompare(b.nodeId); } export function selectDefaultNodeFromList( nodes: NodeListNode[], options: DefaultNodeSelectionOptions = {}, ): NodeListNode | null { const capability = options.capability?.trim(); const withCapability = capability ? nodes.filter((n) => (Array.isArray(n.caps) ? n.caps.includes(capability) : true)) : nodes; if (withCapability.length === 0) { return null; } const connected = withCapability.filter((n) => n.connected); const candidates = connected.length > 0 ? connected : withCapability; if (candidates.length === 1) { return candidates[0]; } const preferLocalMac = options.preferLocalMac ?? true; if (preferLocalMac) { const local = candidates.filter(isLocalMacNode); if (local.length === 1) { return local[0]; } } const fallback = options.fallback ?? "none"; if (fallback === "none") { return null; } const ordered = [...candidates].toSorted(compareDefaultNodeOrder); // Multiple candidates — pick the first connected canvas-capable node. // For A2UI and other canvas operations, any node works since multi-node // setups broadcast surfaces across devices. return ordered[0] ?? null; } function pickDefaultNode(nodes: NodeListNode[]): NodeListNode | null { return selectDefaultNodeFromList(nodes, { capability: "canvas", fallback: "first", preferLocalMac: true, }); } export async function listNodes(opts: GatewayCallOptions): Promise { return loadNodes(opts); } export function resolveNodeIdFromList( nodes: NodeListNode[], query?: string, allowDefault = false, ): string { return resolveNodeIdFromNodeList(nodes, query, { allowDefault, pickDefaultNode: pickDefaultNode, }); } export async function resolveNodeId( opts: GatewayCallOptions, query?: string, allowDefault = false, ) { return (await resolveNode(opts, query, allowDefault)).nodeId; } export async function resolveNode( opts: GatewayCallOptions, query?: string, allowDefault = false, ): Promise { const nodes = await loadNodes(opts); return resolveNodeFromNodeList(nodes, query, { allowDefault, pickDefaultNode: pickDefaultNode, }); }