diff --git a/packages/extension/src/entrypoints/hub/App.tsx b/packages/extension/src/entrypoints/hub/App.tsx new file mode 100644 index 0000000..5aa4e46 --- /dev/null +++ b/packages/extension/src/entrypoints/hub/App.tsx @@ -0,0 +1,152 @@ +import { Plug, PlugZap, Square, Unplug } from 'lucide-react' +import { useEffect, useRef } from 'react' + +import { useAgent } from '@/agent/useAgent' +import { ActivityCard, EventCard } from '@/components/cards' +import { Logo, MotionOverlay, StatusDot } from '@/components/misc' +import { Button } from '@/components/ui/button' + +import { useHubWs } from './hub-ws' + +export default function App() { + const { status, history, activity, currentTask, config, execute, stop, configure } = useAgent() + const { wsState } = useHubWs(execute, stop, configure, config) + + const historyRef = useRef(null) + + useEffect(() => { + if (historyRef.current) { + historyRef.current.scrollTop = historyRef.current.scrollHeight + } + }, [history, activity]) + + const isRunning = status === 'running' + const WsIcon = wsState === 'connected' ? PlugZap : wsState === 'connecting' ? Plug : Unplug + const wsLabel = { + connected: 'Connected', + connecting: 'Connecting…', + disconnected: new URLSearchParams(location.search).get('ws') ? 'Disconnected' : 'Standalone', + }[wsState] + + return ( +
+ + + {/* Left — Protocol docs */} + + + {/* Right — Live session */} +
+ {/* Header bar */} +
+
+ + {wsLabel} +
+
+ + {isRunning && ( + + )} +
+
+ + {/* Task banner */} + {currentTask && ( +
+
+ Current Task +
+
+ {currentTask} +
+
+ )} + + {/* Event stream */} +
+ {!currentTask && history.length === 0 && !isRunning && ( +
+ +

+ {wsState === 'connected' + ? 'Waiting for task from external caller…' + : 'No active session'} +

+
+ )} + + {history.map((event, index) => ( + // eslint-disable-next-line react-x/no-array-index-key + + ))} + + {activity && } +
+
+
+ ) +} + +function ProtocolDocs() { + return ( +
+
+

+ Caller → Hub +

+
+					{`{ type: "execute", task: string, config?: object }
+{ type: "stop" }`}
+				
+
+ +
+

+ Hub → Caller +

+
+					{`{ type: "ready" }
+{ type: "result", success: boolean, data: string }
+{ type: "error", message: string }`}
+				
+
+ +
+

+ Flow +

+
    +
  1. Hub opens WS to caller's server
  2. +
  3. + Sends ready +
  4. +
  5. + Caller sends execute with task +
  6. +
  7. Hub runs agent, streams events
  8. +
  9. + Hub sends result or{' '} + error +
  10. +
+
+
+ ) +} diff --git a/packages/extension/src/entrypoints/hub/hub-ws.ts b/packages/extension/src/entrypoints/hub/hub-ws.ts new file mode 100644 index 0000000..e76f6d4 --- /dev/null +++ b/packages/extension/src/entrypoints/hub/hub-ws.ts @@ -0,0 +1,219 @@ +/** + * Hub WebSocket Protocol + * + * Hub connects as WS client to `ws://localhost:{port}`. + * All messages are JSON. One task at a time. + * + * Inbound (Caller → Hub): + * { type: "execute", task: string, config?: object } + * { type: "stop" } + * + * Outbound (Hub → Caller): + * { type: "ready" } + * { type: "result", success: boolean, data: string } + * { type: "error", message: string } + */ +import type { ExecutionResult } from '@page-agent/core' +import { useEffect, useRef, useState } from 'react' + +import type { ExtConfig } from '@/agent/useAgent' + +// --- Protocol types --- + +interface ExecuteMessage { + type: 'execute' + task: string + config?: Record +} + +interface StopMessage { + type: 'stop' +} + +type InboundMessage = ExecuteMessage | StopMessage + +interface ReadyMessage { + type: 'ready' +} + +interface ResultMessage { + type: 'result' + success: boolean + data: string +} + +interface ErrorMessage { + type: 'error' + message: string +} + +type OutboundMessage = ReadyMessage | ResultMessage | ErrorMessage + +export type HubWsState = 'connecting' | 'connected' | 'disconnected' + +// --- HubWs class --- + +export interface HubWsHandlers { + onExecute: ( + task: string, + config?: Record + ) => Promise<{ success: boolean; data: string }> + onStop: () => void +} + +/** + * Framework-agnostic WebSocket client for Hub. + * Connects to an external WS server, receives tasks, dispatches to handlers, + * and sends results back. No React, no DOM. + */ +export class HubWs { + #ws: WebSocket | null = null + #state: HubWsState = 'disconnected' + #busy = false + #handlers: HubWsHandlers + #port: number + #onStateChange: (state: HubWsState) => void + + constructor(port: number, handlers: HubWsHandlers, onStateChange: (state: HubWsState) => void) { + this.#port = port + this.#handlers = handlers + this.#onStateChange = onStateChange + } + + get state() { + return this.#state + } + + get busy() { + return this.#busy + } + + connect() { + if (this.#ws) return + this.#setState('connecting') + + const ws = new WebSocket(`ws://localhost:${this.#port}`) + this.#ws = ws + + ws.addEventListener('open', () => { + this.#setState('connected') + this.#send({ type: 'ready' }) + }) + + ws.addEventListener('close', () => { + this.#ws = null + this.#busy = false + this.#setState('disconnected') + }) + + ws.addEventListener('message', (event) => { + this.#handleMessage(event.data as string) + }) + } + + disconnect() { + this.#ws?.close() + this.#ws = null + this.#busy = false + this.#setState('disconnected') + } + + #setState(state: HubWsState) { + if (this.#state === state) return + this.#state = state + this.#onStateChange(state) + } + + #send(msg: OutboundMessage) { + if (this.#ws?.readyState === WebSocket.OPEN) { + this.#ws.send(JSON.stringify(msg)) + } + } + + #handleMessage(raw: string) { + let msg: InboundMessage + try { + msg = JSON.parse(raw) + } catch { + return + } + + switch (msg.type) { + case 'execute': + this.#handleExecute(msg) + break + case 'stop': + this.#handlers.onStop() + break + } + } + + async #handleExecute(msg: ExecuteMessage) { + if (this.#busy) { + this.#send({ type: 'error', message: 'Hub is busy with another task' }) + return + } + + this.#busy = true + try { + const result = await this.#handlers.onExecute(msg.task, msg.config) + this.#send({ type: 'result', success: result.success, data: result.data }) + } catch (err) { + this.#send({ type: 'error', message: err instanceof Error ? err.message : String(err) }) + } finally { + this.#busy = false + } + } +} + +// --- React hook --- + +/** + * React hook that bridges HubWs to the agent's execute/stop/configure. + * Handles the config-before-execute dance internally. + */ +export function useHubWs( + execute: (task: string) => Promise, + stop: () => void, + configure: (config: ExtConfig) => Promise, + config: ExtConfig | null +): { wsState: HubWsState } { + const wsPort = new URLSearchParams(location.search).get('ws') + const [wsState, setWsState] = useState(() => (wsPort ? 'connecting' : 'disconnected')) + const hubWsRef = useRef(null) + + const latest = useRef({ execute, stop, configure, config }) + useEffect(() => { + latest.current = { execute, stop, configure, config } + }) + + useEffect(() => { + if (!wsPort) return + + const hubWs = new HubWs( + Number(wsPort), + { + onExecute: async (task, incomingConfig) => { + const { execute, configure, config } = latest.current + if (incomingConfig) { + await configure({ ...config, ...incomingConfig } as ExtConfig) + } + const result = await execute(task) + return { success: result.success, data: result.data } + }, + onStop: () => latest.current.stop(), + }, + setWsState + ) + + hubWs.connect() + hubWsRef.current = hubWs + + return () => { + hubWs.disconnect() + hubWsRef.current = null + } + }, [wsPort]) + + return { wsState } +} diff --git a/packages/extension/src/entrypoints/hub/index.html b/packages/extension/src/entrypoints/hub/index.html new file mode 100644 index 0000000..d564cb0 --- /dev/null +++ b/packages/extension/src/entrypoints/hub/index.html @@ -0,0 +1,13 @@ + + + + + + + Page Agent Hub + + +
+ + + diff --git a/packages/extension/src/entrypoints/hub/main.tsx b/packages/extension/src/entrypoints/hub/main.tsx new file mode 100644 index 0000000..05504da --- /dev/null +++ b/packages/extension/src/entrypoints/hub/main.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' + +import { ErrorBoundary } from '@/components/ErrorBoundary' + +import App from './App' + +import '@/assets/index.css' + +const syncDarkMode = () => { + document.documentElement.classList.toggle( + 'dark', + matchMedia('(prefers-color-scheme: dark)').matches + ) +} +syncDarkMode() +matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncDarkMode) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +)