Files
paseo/packages/cli/src/utils/client.ts
T

147 lines
4.1 KiB
TypeScript

import { DaemonClient } from '@getpaseo/server'
import WebSocket from 'ws'
import { getOrCreateCliClientId } from './client-id.js'
export interface ConnectOptions {
host?: string
timeout?: number
}
const DEFAULT_HOST = 'localhost:6767'
const DEFAULT_TIMEOUT = 5000
/**
* Get the daemon host from environment or options
*/
export function getDaemonHost(options?: ConnectOptions): string {
return options?.host ?? process.env.PASEO_HOST ?? DEFAULT_HOST
}
/**
* Create a WebSocket factory that works in Node.js
*/
function createNodeWebSocketFactory() {
return (url: string, options?: { headers?: Record<string, string> }) => {
return new WebSocket(url, { headers: options?.headers }) as unknown as {
readyState: number
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
binaryType?: string
on: (event: string, listener: (...args: unknown[]) => void) => void
off: (event: string, listener: (...args: unknown[]) => void) => void
}
}
}
/**
* Create and connect a daemon client
* Returns the connected client or throws if connection fails
*/
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const host = getDaemonHost(options)
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const clientId = await getOrCreateCliClientId()
const url = `ws://${host}/ws`
const client = new DaemonClient(
{
url,
clientId,
clientType: 'cli',
webSocketFactory: createNodeWebSocketFactory(),
reconnect: { enabled: false },
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
)
// Connect with timeout
const connectPromise = client.connect()
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Connection timeout after ${timeout}ms`))
}, timeout)
})
try {
await Promise.race([connectPromise, timeoutPromise])
// Clear the timeout so it doesn't keep the event loop alive
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
return client
} catch (err) {
// Clear the timeout on error too
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
await client.close().catch(() => {})
throw err
}
}
/**
* Try to connect to the daemon, returns null if connection fails
*/
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
try {
return await connectToDaemon(options)
} catch {
return null
}
}
/** Minimal agent type for ID resolution */
interface AgentLike {
id: string
title?: string | null
}
/**
* Resolve an agent ID from a partial ID or name.
* Supports:
* - Full ID match
* - Prefix match (first N characters)
* - Title/name match (case-insensitive)
*
* Returns the full agent ID if found, null otherwise.
*/
export function resolveAgentId(idOrName: string, agents: AgentLike[]): string | null {
if (!idOrName || agents.length === 0) {
return null
}
const query = idOrName.toLowerCase()
// Try exact ID match first
const exactMatch = agents.find((a) => a.id === idOrName)
if (exactMatch) {
return exactMatch.id
}
// Try ID prefix match
const prefixMatches = agents.filter((a) => a.id.toLowerCase().startsWith(query))
if (prefixMatches.length === 1 && prefixMatches[0]) {
return prefixMatches[0].id
}
// Try title/name match (case-insensitive)
const titleMatches = agents.filter((a) => a.title?.toLowerCase() === query)
if (titleMatches.length === 1 && titleMatches[0]) {
return titleMatches[0].id
}
// Try partial title match
const partialTitleMatches = agents.filter((a) => a.title?.toLowerCase().includes(query))
if (partialTitleMatches.length === 1 && partialTitleMatches[0]) {
return partialTitleMatches[0].id
}
// If we have multiple prefix matches and no unique title match, return first prefix match
const firstPrefixMatch = prefixMatches[0]
if (firstPrefixMatch) {
return firstPrefixMatch.id
}
return null
}