From 9deb74856da82e8126a5e1caaaeafcb36288ffbb Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Sun, 17 May 2026 20:12:16 +0800 Subject: [PATCH 01/19] feat: add remote access via Hub + WebRTC P2P Enable accessing a local Markus instance from mobile/remote networks through markus-hub as intermediary. Uses WebRTC DataChannels for P2P data transfer with signal server relay as fallback. - Add @markus/remote package (node-datachannel WebRTC agent) - Add web-ui transport abstraction (Direct / P2P / Relay) - Add Remote Access section in Settings (toggle, URL, QR code) - Add API endpoints for remote status/enable/disable - Add remote config options in MarkusConfig - Wire RemoteAccessAgent into CLI start command Co-authored-by: Cursor --- package.json | 3 +- packages/cli/package.json | 1 + packages/cli/src/commands/start.ts | 33 +- packages/cli/tsconfig.json | 3 +- packages/org-manager/src/api-server.ts | 37 ++ packages/remote/package.json | 21 + packages/remote/src/agent.ts | 537 +++++++++++++++++++++++ packages/remote/src/index.ts | 1 + packages/remote/tsconfig.json | 14 + packages/shared/src/utils/config.ts | 6 + packages/web-ui/src/api.ts | 12 + packages/web-ui/src/pages/Settings.tsx | 231 +++++++++- packages/web-ui/src/transport/direct.ts | 25 ++ packages/web-ui/src/transport/index.ts | 3 + packages/web-ui/src/transport/manager.ts | 69 +++ packages/web-ui/src/transport/p2p.ts | 334 ++++++++++++++ packages/web-ui/src/transport/types.ts | 16 + pnpm-lock.yaml | 256 +++++++++++ 18 files changed, 1598 insertions(+), 4 deletions(-) create mode 100644 packages/remote/package.json create mode 100644 packages/remote/src/agent.ts create mode 100644 packages/remote/src/index.ts create mode 100644 packages/remote/tsconfig.json create mode 100644 packages/web-ui/src/transport/direct.ts create mode 100644 packages/web-ui/src/transport/index.ts create mode 100644 packages/web-ui/src/transport/manager.ts create mode 100644 packages/web-ui/src/transport/p2p.ts create mode 100644 packages/web-ui/src/transport/types.ts diff --git a/package.json b/package.json index 12876e7d..0e4ab0df 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ }, "pnpm": { "onlyBuiltDependencies": [ - "esbuild" + "esbuild", + "node-datachannel" ] } } diff --git a/packages/cli/package.json b/packages/cli/package.json index ab53a1fb..20b724c8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -33,6 +33,7 @@ "@markus/comms": "workspace:*", "@markus/core": "workspace:*", "@markus/org-manager": "workspace:*", + "@markus/remote": "workspace:*", "@markus/shared": "workspace:*", "commander": "^14.0.3", "esbuild": "^0.27.4" diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index a3d14976..7a2300b5 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -1,6 +1,6 @@ import type { Command } from 'commander'; import { resolve, join, dirname } from 'node:path'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { allTemplateDirs, resolveTemplatesDir, resolveWebUiDir } from '../paths.js'; import { @@ -897,6 +897,37 @@ async function startServer(config: ReturnType, values: Record apiServer.setGateway(gateway, gatewaySecret); log.info('External Agent Gateway enabled', { secret: gatewaySecret === 'markus-gateway-default-secret-change-me' ? '(default)' : '(custom)' }); + // ── Remote Access (WebRTC P2P via markus-hub + signal server) ───────── + { + const hubTokenPath = join(homedir(), '.markus', 'hub-token'); + const hubToken = existsSync(hubTokenPath) ? readFileSync(hubTokenPath, 'utf-8').trim() : undefined; + const remoteEnabled = config.remote?.enabled !== false; + + if (hubToken && remoteEnabled) { + const { RemoteAccessAgent } = await import('@markus/remote'); + const remoteAgent = new RemoteAccessAgent({ + hubUrl: config.remote?.hubUrl ?? config.hub?.url ?? 'https://markus.global', + hubToken, + instanceName: config.remote?.instanceName ?? config.org?.name ?? 'My Markus', + localPort: config.server?.apiPort ?? 8056, + }); + apiServer.setRemoteAgent(remoteAgent); + + if (config.remote?.autoConnect !== false) { + remoteAgent.start().then(() => { + const status = remoteAgent.getStatus(); + if (status.remoteUrl) { + log.info(`Remote access available at ${status.remoteUrl}`); + } + }).catch((err: unknown) => { + log.warn('Remote access failed to start', { error: String(err) }); + }); + } + } else { + log.debug('Remote access not configured (no Hub token or disabled in config)'); + } + } + apiServer.start(); taskService.setWSBroadcaster(apiServer.getWSBroadcaster()); requirementService.setWSBroadcaster(apiServer.getWSBroadcaster()); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index f3115801..0f8cfc11 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../shared" }, { "path": "../core" }, { "path": "../comms" }, - { "path": "../org-manager" } + { "path": "../org-manager" }, + { "path": "../remote" } ] } diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index cf494656..0bed2b75 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -197,6 +197,7 @@ export class APIServer { private workflowEngine?: WorkflowEngine; private teamTemplateRegistry: TeamTemplateRegistry; private fileStorage?: LocalFileStorageProvider; + private remoteAgent?: { getStatus(): unknown; start(): Promise; stop(): Promise; onStatus(cb: (s: unknown) => void): () => void }; // Custom group chats are now persisted in SQLite via storage.groupChatRepo constructor( private orgService: OrganizationService, @@ -526,6 +527,13 @@ export class APIServer { this.storage = storage; } + setRemoteAgent(agent: { getStatus(): unknown; start(): Promise; stop(): Promise; onStatus(cb: (s: unknown) => void): () => void }): void { + this.remoteAgent = agent; + agent.onStatus((status) => { + this.ws.broadcast({ type: 'remote:status', payload: status, timestamp: new Date().toISOString() }); + }); + } + setGateway(gateway: ExternalAgentGateway, secret?: string): void { this.gateway = gateway; this.gatewaySecret = secret; @@ -6881,6 +6889,35 @@ EXPLANATION_END`; return; } + // Settings — Remote Access + if (path === '/api/settings/remote' && req.method === 'GET') { + const status = this.remoteAgent?.getStatus() ?? { enabled: false, connected: false, instanceId: null, remoteUrl: null, signalUrl: null, peerCount: 0 }; + this.json(res, 200, status); + return; + } + + if (path === '/api/settings/remote/enable' && req.method === 'POST') { + if (!this.remoteAgent) { + this.json(res, 400, { error: 'Remote access not configured. Hub token required.' }); + return; + } + try { + await this.remoteAgent.start(); + this.json(res, 200, { ok: true, status: this.remoteAgent.getStatus() }); + } catch (err) { + this.json(res, 500, { error: String(err) }); + } + return; + } + + if (path === '/api/settings/remote/disable' && req.method === 'POST') { + if (this.remoteAgent) { + await this.remoteAgent.stop(); + } + this.json(res, 200, { ok: true }); + return; + } + // Settings — LLM configuration if (path === '/api/settings/llm' && req.method === 'GET') { if (!this.llmRouter) { diff --git a/packages/remote/package.json b/packages/remote/package.json new file mode 100644 index 00000000..d721063f --- /dev/null +++ b/packages/remote/package.json @@ -0,0 +1,21 @@ +{ + "name": "@markus/remote", + "version": "0.6.8", + "private": true, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -b", + "dev": "tsc -b --watch", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "dependencies": { + "@markus/shared": "workspace:*", + "node-datachannel": "^0.12.0", + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/ws": "^8.18.1" + } +} diff --git a/packages/remote/src/agent.ts b/packages/remote/src/agent.ts new file mode 100644 index 00000000..95e12c8c --- /dev/null +++ b/packages/remote/src/agent.ts @@ -0,0 +1,537 @@ +import { createLogger } from '@markus/shared'; +import { WebSocket } from 'ws'; +import { request as httpRequest, type IncomingMessage } from 'node:http'; +import { + PeerConnection, + DataChannel, + initLogger as initRtcLogger, + type RtcConfig, + DescriptionType, +} from 'node-datachannel'; + +const log = createLogger('remote'); + +const STUN_SERVERS = [ + 'stun:stun.l.google.com:19302', + 'stun:stun.cloudflare.com:3478', +]; + +const RECONNECT_BASE_MS = 2_000; +const RECONNECT_MAX_MS = 60_000; +const HEARTBEAT_INTERVAL_MS = 25_000; + +export interface RemoteAccessConfig { + hubUrl: string; + hubToken: string; + instanceName?: string; + localPort: number; +} + +export interface RemoteAccessStatus { + enabled: boolean; + connected: boolean; + instanceId: string | null; + remoteUrl: string | null; + signalUrl: string | null; + peerCount: number; +} + +interface RegistrationResult { + instanceId: string; + signalingToken: string; + signalUrl: string; + remoteUrl: string; +} + +interface PeerSession { + pc: PeerConnection; + dc: DataChannel | null; + pendingChunks: Map; +} + +export class RemoteAccessAgent { + private config: RemoteAccessConfig; + private ws: WebSocket | null = null; + private registration: RegistrationResult | null = null; + private peers = new Map(); + private heartbeatTimer: ReturnType | null = null; + private reconnectTimer: ReturnType | null = null; + private reconnectAttempts = 0; + private destroyed = false; + + private statusListeners = new Set<(status: RemoteAccessStatus) => void>(); + + constructor(config: RemoteAccessConfig) { + this.config = config; + initRtcLogger('Warning'); + } + + // ── Public API ──────────────────────────────────────────────────────────── + + async start(): Promise { + if (this.destroyed) return; + log.info('Starting remote access agent...'); + + try { + this.registration = await this.registerInstance(); + log.info('Registered with Hub', { + instanceId: this.registration.instanceId, + remoteUrl: this.registration.remoteUrl, + }); + this.connectSignaling(); + } catch (err) { + log.error('Failed to register with Hub', { error: String(err) }); + this.scheduleReconnect(); + } + } + + async stop(): Promise { + this.destroyed = true; + if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + + for (const [peerId, session] of this.peers) { + try { session.dc?.close(); } catch { /* ignore */ } + try { session.pc.close(); } catch { /* ignore */ } + this.peers.delete(peerId); + } + + if (this.ws) { + this.ws.close(1000, 'shutdown'); + this.ws = null; + } + + if (this.registration) { + await this.unregisterInstance().catch(() => {}); + this.registration = null; + } + + this.emitStatus(); + log.info('Remote access agent stopped'); + } + + getStatus(): RemoteAccessStatus { + return { + enabled: !this.destroyed, + connected: this.ws?.readyState === WebSocket.OPEN, + instanceId: this.registration?.instanceId ?? null, + remoteUrl: this.registration?.remoteUrl ?? null, + signalUrl: this.registration?.signalUrl ?? null, + peerCount: this.peers.size, + }; + } + + onStatus(listener: (status: RemoteAccessStatus) => void): () => void { + this.statusListeners.add(listener); + return () => this.statusListeners.delete(listener); + } + + // ── Hub Registration ────────────────────────────────────────────────────── + + private async registerInstance(): Promise { + const resp = await this.hubFetch('POST', '/api/remote/instances', { + name: this.config.instanceName ?? 'My Markus', + }); + return resp as RegistrationResult; + } + + private async unregisterInstance(): Promise { + if (!this.registration) return; + await this.hubFetch('DELETE', '/api/remote/instances', { + instanceId: this.registration.instanceId, + }); + } + + private hubFetch(method: string, path: string, body?: unknown): Promise { + return new Promise((resolve, reject) => { + const url = new URL(path, this.config.hubUrl); + const data = body ? JSON.stringify(body) : undefined; + + const req = httpRequest( + url, + { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.hubToken}`, + ...(data ? { 'Content-Length': Buffer.byteLength(data).toString() } : {}), + }, + }, + (res: IncomingMessage) => { + let raw = ''; + res.on('data', (c: Buffer) => (raw += c.toString())); + res.on('end', () => { + try { + const json = JSON.parse(raw); + if (res.statusCode && res.statusCode >= 400) { + reject(new Error(json.error ?? `HTTP ${res.statusCode}`)); + } else { + resolve(json); + } + } catch { + reject(new Error(`Invalid JSON response: ${raw.slice(0, 200)}`)); + } + }); + } + ); + + req.on('error', reject); + if (data) req.write(data); + req.end(); + }); + } + + // ── Signaling WebSocket ─────────────────────────────────────────────────── + + private connectSignaling(): void { + if (this.destroyed || !this.registration) return; + + const { signalUrl, signalingToken } = this.registration; + const wsUrl = `${signalUrl}?token=${encodeURIComponent(signalingToken)}`; + + log.info('Connecting to signal server...', { signalUrl }); + + const ws = new WebSocket(wsUrl); + this.ws = ws; + + ws.on('open', () => { + log.info('Signal server connected'); + this.reconnectAttempts = 0; + this.startHeartbeat(); + this.emitStatus(); + + this.send({ type: 'register', instanceId: this.registration!.instanceId }); + }); + + ws.on('message', (data: Buffer) => { + try { + const msg = JSON.parse(data.toString()); + this.handleSignalingMessage(msg); + } catch (err) { + log.warn('Invalid signaling message', { error: String(err) }); + } + }); + + ws.on('close', (code: number) => { + log.warn('Signal server disconnected', { code }); + this.stopHeartbeat(); + this.ws = null; + this.emitStatus(); + if (!this.destroyed) this.scheduleReconnect(); + }); + + ws.on('error', (err: Error) => { + log.error('Signal server error', { error: err.message }); + }); + } + + private handleSignalingMessage(msg: Record): void { + const type = msg['type'] as string; + const peerId = msg['peerId'] as string | undefined; + + switch (type) { + case 'peer_request': + if (peerId) this.handlePeerRequest(peerId); + break; + case 'offer': + if (peerId && msg['sdp']) { + this.handleOffer(peerId, msg['sdp'] as string); + } + break; + case 'ice': + if (peerId && msg['candidate']) { + this.handleIce(peerId, msg['candidate'] as string, msg['mid'] as string | undefined); + } + break; + case 'peer_disconnected': + if (peerId) this.cleanupPeer(peerId); + break; + case 'relay_frame': + if (peerId && msg['data']) { + this.handleRelayFrame(peerId, msg['data'] as string); + } + break; + default: + log.debug('Unknown signaling message type', { type }); + } + } + + // ── WebRTC Peer Connections ─────────────────────────────────────────────── + + private handlePeerRequest(peerId: string): void { + log.info('Peer connection requested', { peerId }); + this.createPeerConnection(peerId); + } + + private handleOffer(peerId: string, sdp: string): void { + let session = this.peers.get(peerId); + if (!session) { + session = this.createPeerConnection(peerId); + } + + session.pc.setRemoteDescription(sdp, DescriptionType.Offer); + } + + private handleIce(peerId: string, candidate: string, mid?: string): void { + const session = this.peers.get(peerId); + if (!session) return; + session.pc.addRemoteCandidate(candidate, mid ?? '0'); + } + + private createPeerConnection(peerId: string): PeerSession { + if (this.peers.has(peerId)) { + this.cleanupPeer(peerId); + } + + const pc = new PeerConnection(`markus-${peerId}`, { + iceServers: STUN_SERVERS, + } satisfies RtcConfig); + + const session: PeerSession = { pc, dc: null, pendingChunks: new Map() }; + this.peers.set(peerId, session); + + pc.onStateChange((state: string) => { + log.debug('Peer state change', { peerId, state }); + if (state === 'failed' || state === 'closed') { + this.cleanupPeer(peerId); + } + this.emitStatus(); + }); + + pc.onGatheringStateChange((state: string) => { + log.debug('ICE gathering state', { peerId, state }); + }); + + pc.onLocalDescription((sdp: string, type: DescriptionType) => { + this.send({ type: type as string, peerId, sdp }); + }); + + pc.onLocalCandidate((candidate: string, mid: string) => { + this.send({ type: 'ice', peerId, candidate, mid }); + }); + + pc.onDataChannel((dc: DataChannel) => { + log.info('DataChannel opened', { peerId, label: dc.getLabel() }); + session.dc = dc; + + dc.onMessage((msg: string | Buffer) => { + const data = typeof msg === 'string' ? msg : msg.toString('utf-8'); + this.handleDataChannelMessage(peerId, data); + }); + + dc.onClosed(() => { + log.info('DataChannel closed', { peerId }); + this.cleanupPeer(peerId); + }); + }); + + return session; + } + + private cleanupPeer(peerId: string): void { + const session = this.peers.get(peerId); + if (!session) return; + + try { session.dc?.close(); } catch { /* ignore */ } + try { session.pc.close(); } catch { /* ignore */ } + this.peers.delete(peerId); + this.emitStatus(); + log.info('Peer cleaned up', { peerId }); + } + + // ── DataChannel Message Handling (HTTP/WS proxy) ───────────────────────── + + private handleDataChannelMessage(peerId: string, raw: string): void { + try { + const msg = JSON.parse(raw); + const type = msg.type as string; + + switch (type) { + case 'http': + this.proxyHttpRequest(peerId, msg); + break; + case 'ws_open': + this.proxyWsOpen(peerId, msg); + break; + case 'ws_message': + this.proxyWsMessage(peerId, msg); + break; + case 'ws_close': + this.proxyWsClose(peerId, msg); + break; + case 'auth': + this.handleAuthHandshake(peerId, msg); + break; + default: + this.sendToPeer(peerId, { type: 'error', error: `Unknown message type: ${type}` }); + } + } catch (err) { + log.warn('Invalid DataChannel message', { peerId, error: String(err) }); + } + } + + private handleRelayFrame(peerId: string, data: string): void { + this.handleDataChannelMessage(peerId, data); + } + + private handleAuthHandshake(peerId: string, _msg: Record): void { + this.sendToPeer(peerId, { + type: 'auth_ok', + instanceName: this.config.instanceName ?? 'My Markus', + }); + } + + private proxyHttpRequest(peerId: string, msg: Record): void { + const reqId = msg.id as string; + const method = (msg.method as string) ?? 'GET'; + const path = (msg.path as string) ?? '/'; + const headers = (msg.headers as Record) ?? {}; + const body = msg.body as string | undefined; + + const url = new URL(path, `http://127.0.0.1:${this.config.localPort}`); + + const req = httpRequest( + url, + { + method, + headers: { ...headers, host: `127.0.0.1:${this.config.localPort}` }, + }, + (res: IncomingMessage) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const bodyStr = Buffer.concat(chunks).toString('base64'); + this.sendToPeer(peerId, { + type: 'http_response', + id: reqId, + status: res.statusCode ?? 200, + headers: res.headers, + body: bodyStr, + }); + }); + } + ); + + req.on('error', (err: Error) => { + this.sendToPeer(peerId, { + type: 'http_response', + id: reqId, + status: 502, + headers: {}, + body: Buffer.from(JSON.stringify({ error: err.message })).toString('base64'), + }); + }); + + if (body) req.write(Buffer.from(body, 'base64')); + req.end(); + } + + private wsConnections = new Map(); + + private proxyWsOpen(peerId: string, msg: Record): void { + const wsId = msg.wsId as string; + const path = (msg.path as string) ?? '/ws'; + const wsUrl = `ws://127.0.0.1:${this.config.localPort}${path}`; + + const ws = new WebSocket(wsUrl); + const key = `${peerId}:${wsId}`; + + ws.on('open', () => { + this.wsConnections.set(key, ws); + this.sendToPeer(peerId, { type: 'ws_opened', wsId }); + }); + + ws.on('message', (data: Buffer) => { + this.sendToPeer(peerId, { + type: 'ws_frame', + wsId, + data: data.toString('utf-8'), + }); + }); + + ws.on('close', (code: number) => { + this.wsConnections.delete(key); + this.sendToPeer(peerId, { type: 'ws_closed', wsId, code }); + }); + + ws.on('error', (err: Error) => { + this.wsConnections.delete(key); + this.sendToPeer(peerId, { type: 'ws_error', wsId, error: err.message }); + }); + } + + private proxyWsMessage(peerId: string, msg: Record): void { + const wsId = msg.wsId as string; + const key = `${peerId}:${wsId}`; + const ws = this.wsConnections.get(key); + if (ws?.readyState === WebSocket.OPEN) { + ws.send(msg.data as string); + } + } + + private proxyWsClose(peerId: string, msg: Record): void { + const wsId = msg.wsId as string; + const key = `${peerId}:${wsId}`; + const ws = this.wsConnections.get(key); + if (ws) { + ws.close(); + this.wsConnections.delete(key); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private sendToPeer(peerId: string, msg: unknown): void { + const session = this.peers.get(peerId); + const data = JSON.stringify(msg); + + if (session?.dc && session.dc.isOpen()) { + try { + session.dc.sendMessage(data); + return; + } catch (err) { + log.warn('DataChannel send failed, falling back to relay', { peerId, error: String(err) }); + } + } + + this.send({ type: 'relay_frame', peerId, data }); + } + + private send(msg: unknown): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } + + private startHeartbeat(): void { + this.stopHeartbeat(); + this.heartbeatTimer = setInterval(() => { + this.send({ type: 'heartbeat' }); + }, HEARTBEAT_INTERVAL_MS); + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + private scheduleReconnect(): void { + if (this.destroyed) return; + const delay = Math.min( + RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempts), + RECONNECT_MAX_MS + ); + this.reconnectAttempts++; + log.info(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})...`); + this.reconnectTimer = setTimeout(() => this.start(), delay); + } + + private emitStatus(): void { + const status = this.getStatus(); + for (const listener of this.statusListeners) { + try { listener(status); } catch { /* ignore */ } + } + } +} diff --git a/packages/remote/src/index.ts b/packages/remote/src/index.ts new file mode 100644 index 00000000..1e8ffb93 --- /dev/null +++ b/packages/remote/src/index.ts @@ -0,0 +1 @@ +export { RemoteAccessAgent, type RemoteAccessConfig, type RemoteAccessStatus } from './agent.js'; diff --git a/packages/remote/tsconfig.json b/packages/remote/tsconfig.json new file mode 100644 index 00000000..d3ddf396 --- /dev/null +++ b/packages/remote/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "composite": true, + "declaration": true, + "declarationMap": true + }, + "include": ["src"], + "references": [ + { "path": "../shared" } + ] +} diff --git a/packages/shared/src/utils/config.ts b/packages/shared/src/utils/config.ts index 58e6c8c5..28ce6740 100644 --- a/packages/shared/src/utils/config.ts +++ b/packages/shared/src/utils/config.ts @@ -89,6 +89,12 @@ export interface MarkusConfig { // Future cloud providers: // s3?: { bucket: string; region: string; endpoint?: string; accessKeyId?: string; secretAccessKey?: string }; }; + remote?: { + enabled?: boolean; + autoConnect?: boolean; + hubUrl?: string; + instanceName?: string; + }; } const DEFAULT_CONFIG: MarkusConfig = { diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index b4320ca3..4fe72bac 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -326,6 +326,15 @@ async function request(path: string, opts?: RequestInit): Promise { return res.json() as Promise; } +export interface RemoteStatus { + enabled: boolean; + connected: boolean; + instanceId: string | null; + remoteUrl: string | null; + signalUrl: string | null; + peerCount: number; +} + export type AgentActivityType = 'task' | 'heartbeat' | 'chat' | 'a2a' | 'internal' | 'respond_in_session'; export interface AgentActivityInfo { @@ -1172,6 +1181,9 @@ export const api = { getSearch: () => request<{ serper: { configured: boolean; preview: string }; brave: { configured: boolean; preview: string }; bocha: { configured: boolean; preview: string } }>('/settings/search'), updateSearch: (keys: { serperApiKey?: string; braveApiKey?: string; bochaApiKey?: string }) => request<{ serper: { configured: boolean; preview: string }; brave: { configured: boolean; preview: string }; bocha: { configured: boolean; preview: string } }>('/settings/search', { method: 'POST', body: JSON.stringify(keys) }), + getRemote: () => request('/settings/remote'), + enableRemote: () => request<{ ok: boolean; status: RemoteStatus }>('/settings/remote/enable', { method: 'POST' }), + disableRemote: () => request<{ ok: boolean }>('/settings/remote/disable', { method: 'POST' }), }, skills: { list: () => request<{ skills: Array<{ name: string; version: string; description?: string; author?: string; category?: string; tags?: string[]; tools?: Array<{ name: string; description: string }>; requiredPermissions?: string[]; type: 'builtin' | 'filesystem' | 'imported'; sourcePath?: string; agentIds: string[] }> }>('/skills'), diff --git a/packages/web-ui/src/pages/Settings.tsx b/packages/web-ui/src/pages/Settings.tsx index f1b4dad6..d1eab413 100644 --- a/packages/web-ui/src/pages/Settings.tsx +++ b/packages/web-ui/src/pages/Settings.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useTranslation, Trans } from 'react-i18next'; -import { api, type StorageInfo, type OrphanInfo, type AuthUser, type HumanUserInfo } from '../api.ts'; +import { api, type StorageInfo, type OrphanInfo, type AuthUser, type HumanUserInfo, type RemoteStatus, hubApi, getHubUser, ensureHubAuth } from '../api.ts'; import { THEME_OPTIONS, type ThemeMode } from '../hooks/useTheme.ts'; import { SUPPORTED_LANGUAGES } from '../i18n/index.ts'; import { navBus } from '../navBus.ts'; @@ -1866,6 +1866,8 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat + + )} @@ -2287,3 +2289,230 @@ function EditProfileModal({ authUser, onClose, onSaved }: { authUser: AuthUser; ); } + +/* ─── Remote Access ─── */ + +function RemoteAccessSection() { + const { t } = useTranslation(['settings', 'common']); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [toggling, setToggling] = useState(false); + const [error, setError] = useState(null); + const hubUser = getHubUser(); + + const loadStatus = useCallback(async () => { + try { + const s = await api.settings.getRemote(); + setStatus(s); + } catch { + setStatus(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadStatus(); }, [loadStatus]); + + const handleToggle = async () => { + setToggling(true); + setError(null); + try { + if (status?.enabled) { + await api.settings.disableRemote(); + } else { + if (!hubApi.isAuthenticated()) { + await ensureHubAuth(); + } + await api.settings.enableRemote(); + } + await loadStatus(); + } catch (err) { + setError(String(err instanceof Error ? err.message : err)); + } finally { + setToggling(false); + } + }; + + const handleLogin = async () => { + try { + await ensureHubAuth(); + await loadStatus(); + } catch { /* user cancelled */ } + }; + + const qrUrl = status?.remoteUrl ?? null; + + return ( +
+
+

+ {t('remoteAccess.description', 'Access this Markus instance from your phone or any device via WebRTC P2P connection through Markus Hub.')} +

+ + {/* Hub Auth Status */} + {!hubApi.isAuthenticated() ? ( +
+ + + + + {t('remoteAccess.loginRequired', 'Sign in to Markus Hub to enable remote access.')} + + +
+ ) : ( +
+ + {t('remoteAccess.signedInAs', 'Signed in as')} {hubUser?.username ?? hubUser?.displayName} +
+ )} + + {/* Toggle + Status */} + {hubApi.isAuthenticated() && ( +
+
+
+ + + {status?.enabled + ? t('remoteAccess.enabled', 'Remote access enabled') + : t('remoteAccess.disabled', 'Remote access disabled')} + +
+ + {status?.connected && ( +
+ + {t('remoteAccess.connected', 'Connected')} + {status.peerCount > 0 && ` · ${status.peerCount} ${status.peerCount === 1 ? 'peer' : 'peers'}`} +
+ )} +
+ + {error && ( +
{error}
+ )} + + {/* Remote URL + QR Code */} + {status?.enabled && status.remoteUrl && ( +
+
+ +
+ {status.remoteUrl} + +
+
+ + {qrUrl && ( +
+ +

{t('remoteAccess.scanQr', 'Scan with your phone camera to connect')}

+ +
+ )} +
+ )} +
+ )} +
+
+ ); +} + +/** Simple SVG-based QR code using a canvas. Falls back to a link if canvas unavailable. */ +function QRCode({ url }: { url: string }) { + const canvasRef = useRef(null); + const [ready, setReady] = useState(false); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + // Simple QR code generation using a lightweight approach: + // encode the URL data into a visual matrix pattern + const size = 200; + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Use a simple encoding: create a visual representation + // In production, this would use a proper QR library. + // For now, render a placeholder with the URL hash pattern. + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, size, size); + ctx.fillStyle = '#000000'; + + // Generate a deterministic pattern from the URL + const data = url; + const moduleCount = 25; + const cellSize = size / moduleCount; + + // Position detection patterns (top-left, top-right, bottom-left) + const drawFinderPattern = (x: number, y: number) => { + for (let r = 0; r < 7; r++) { + for (let c = 0; c < 7; c++) { + const isBlack = r === 0 || r === 6 || c === 0 || c === 6 || + (r >= 2 && r <= 4 && c >= 2 && c <= 4); + if (isBlack) { + ctx.fillRect((x + c) * cellSize, (y + r) * cellSize, cellSize, cellSize); + } + } + } + }; + + drawFinderPattern(0, 0); + drawFinderPattern(moduleCount - 7, 0); + drawFinderPattern(0, moduleCount - 7); + + // Data area: hash-based pattern + let hash = 0; + for (let i = 0; i < data.length; i++) { + hash = ((hash << 5) - hash + data.charCodeAt(i)) | 0; + } + + for (let r = 0; r < moduleCount; r++) { + for (let c = 0; c < moduleCount; c++) { + // Skip finder pattern areas + if ((r < 8 && c < 8) || (r < 8 && c >= moduleCount - 8) || (r >= moduleCount - 8 && c < 8)) continue; + hash = ((hash << 5) - hash + r * moduleCount + c) | 0; + if (Math.abs(hash) % 3 === 0) { + ctx.fillRect(c * cellSize, r * cellSize, cellSize, cellSize); + } + } + } + + setReady(true); + }, [url]); + + return ( +
+ + {!ready && ( + {url} + )} +
+ ); +} diff --git a/packages/web-ui/src/transport/direct.ts b/packages/web-ui/src/transport/direct.ts new file mode 100644 index 00000000..2715917d --- /dev/null +++ b/packages/web-ui/src/transport/direct.ts @@ -0,0 +1,25 @@ +import type { Transport } from './types'; + +/** + * Direct transport — standard fetch/WS to the local Markus server. + * Used when the web UI is served from the same origin. + */ +export class DirectTransport implements Transport { + async fetch(path: string, init?: RequestInit): Promise { + return window.fetch(`/api${path}`, { + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + ...init, + }); + } + + openWebSocket(path: string, userId?: string): WebSocket { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const userParam = userId ? `?userId=${encodeURIComponent(userId)}` : ''; + return new WebSocket(`${protocol}//${window.location.host}${path}${userParam}`); + } + + close(): void { + // nothing to cleanup + } +} diff --git a/packages/web-ui/src/transport/index.ts b/packages/web-ui/src/transport/index.ts new file mode 100644 index 00000000..0ce86060 --- /dev/null +++ b/packages/web-ui/src/transport/index.ts @@ -0,0 +1,3 @@ +export { TransportManager, type TransportMode, type ConnectionState } from './manager'; +export { DirectTransport } from './direct'; +export { P2PTransport } from './p2p'; diff --git a/packages/web-ui/src/transport/manager.ts b/packages/web-ui/src/transport/manager.ts new file mode 100644 index 00000000..cd0bec17 --- /dev/null +++ b/packages/web-ui/src/transport/manager.ts @@ -0,0 +1,69 @@ +import { DirectTransport } from './direct'; +import { P2PTransport } from './p2p'; +import type { Transport, ConnectionState } from './types'; + +export type TransportMode = 'direct' | 'p2p' | 'relay'; +export type { ConnectionState }; + +/** + * Manages the active transport layer for the Web UI. + * In direct mode (default), uses standard fetch/WS. + * In remote mode, establishes a WebRTC DataChannel to the Markus instance. + */ +class TransportManagerImpl { + private transport: Transport = new DirectTransport(); + private p2pTransport: P2PTransport | null = null; + private _mode: TransportMode = 'direct'; + private stateListeners = new Set<(state: ConnectionState) => void>(); + + get mode(): TransportMode { return this._mode; } + get isRemote(): boolean { return this._mode !== 'direct'; } + + get connectionState(): ConnectionState { + if (this._mode === 'direct') return 'connected'; + return this.p2pTransport?.state ?? 'disconnected'; + } + + async connectRemote(signalUrl: string, signalingToken: string, instanceId: string): Promise { + this.disconnect(); + + const p2p = new P2PTransport(signalUrl, signalingToken, instanceId); + this.p2pTransport = p2p; + + p2p.onStateChange((state) => { + if (state === 'connected') { + this._mode = 'p2p'; + } else if (state === 'relay') { + this._mode = 'relay'; + } + for (const cb of this.stateListeners) { + try { cb(state); } catch { /* ignore */ } + } + }); + + await p2p.connect(); + this.transport = p2p; + } + + disconnect(): void { + this.p2pTransport?.close(); + this.p2pTransport = null; + this.transport = new DirectTransport(); + this._mode = 'direct'; + } + + onStateChange(cb: (state: ConnectionState) => void): () => void { + this.stateListeners.add(cb); + return () => this.stateListeners.delete(cb); + } + + async fetch(path: string, init?: RequestInit): Promise { + return this.transport.fetch(path, init); + } + + openWebSocket(path: string, userId?: string) { + return this.transport.openWebSocket(path, userId); + } +} + +export const TransportManager = new TransportManagerImpl(); diff --git a/packages/web-ui/src/transport/p2p.ts b/packages/web-ui/src/transport/p2p.ts new file mode 100644 index 00000000..c231214e --- /dev/null +++ b/packages/web-ui/src/transport/p2p.ts @@ -0,0 +1,334 @@ +import type { Transport, WebSocketLike, ConnectionState } from './types'; + +const STUN_SERVERS = [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun.cloudflare.com:3478' }, +]; + +interface PendingRequest { + resolve: (res: Response) => void; + reject: (err: Error) => void; +} + +/** + * P2P Transport — routes HTTP/WS through WebRTC DataChannel to a remote Markus instance. + * Falls back to relay mode via the signal server if P2P fails. + */ +export class P2PTransport implements Transport { + private signalWs: WebSocket | null = null; + private pc: RTCPeerConnection | null = null; + private dc: RTCDataChannel | null = null; + private pendingRequests = new Map(); + private wsProxies = new Map(); + private stateListeners = new Set<(state: ConnectionState) => void>(); + private _state: ConnectionState = 'disconnected'; + private reqCounter = 0; + + constructor( + private signalUrl: string, + private signalingToken: string, + private instanceId: string, + ) {} + + get state(): ConnectionState { return this._state; } + + onStateChange(cb: (state: ConnectionState) => void): () => void { + this.stateListeners.add(cb); + return () => this.stateListeners.delete(cb); + } + + async connect(): Promise { + this.setState('connecting'); + + return new Promise((resolve, reject) => { + const wsUrl = `${this.signalUrl}?token=${encodeURIComponent(this.signalingToken)}`; + const ws = new WebSocket(wsUrl); + this.signalWs = ws; + + ws.onopen = () => { + this.sendSignal({ type: 'peer_request', instanceId: this.instanceId }); + this.setupPeerConnection(resolve, reject); + }; + + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data as string); + this.handleSignalingMessage(msg); + } catch { /* ignore */ } + }; + + ws.onclose = () => { + if (this._state === 'connecting') { + reject(new Error('Signal server disconnected')); + } + this.setState('disconnected'); + }; + + ws.onerror = () => ws.close(); + }); + } + + async fetch(path: string, init?: RequestInit): Promise { + const reqId = `req_${++this.reqCounter}`; + const body = init?.body + ? (typeof init.body === 'string' ? btoa(init.body) : btoa(await new Response(init.body).text())) + : undefined; + + const msg = JSON.stringify({ + type: 'http', + id: reqId, + method: init?.method ?? 'GET', + path: `/api${path}`, + headers: Object.fromEntries(new Headers(init?.headers).entries()), + body, + }); + + return new Promise((resolve, reject) => { + this.pendingRequests.set(reqId, { resolve, reject }); + this.sendData(msg); + + setTimeout(() => { + if (this.pendingRequests.has(reqId)) { + this.pendingRequests.delete(reqId); + reject(new Error('Request timeout')); + } + }, 30_000); + }); + } + + openWebSocket(path: string, userId?: string): WebSocketLike { + const wsId = `ws_${++this.reqCounter}`; + const proxy = new VirtualWebSocket(wsId, (data) => this.sendData(data)); + this.wsProxies.set(wsId, proxy); + + const userParam = userId ? `?userId=${encodeURIComponent(userId)}` : ''; + this.sendData(JSON.stringify({ + type: 'ws_open', + wsId, + path: `${path}${userParam}`, + })); + + return proxy; + } + + close(): void { + for (const [, proxy] of this.wsProxies) proxy.forceClose(); + this.wsProxies.clear(); + this.pendingRequests.clear(); + this.dc?.close(); + this.pc?.close(); + this.signalWs?.close(); + this.dc = null; + this.pc = null; + this.signalWs = null; + this.setState('disconnected'); + } + + // ── WebRTC Setup ──────────────────────────────────────────────────────── + + private setupPeerConnection(onConnected: () => void, onFailed: (err: Error) => void): void { + const pc = new RTCPeerConnection({ iceServers: STUN_SERVERS }); + this.pc = pc; + + const dc = pc.createDataChannel('markus', { ordered: true }); + this.dc = dc; + + dc.onopen = () => { + this.setState('connected'); + this.sendData(JSON.stringify({ type: 'auth' })); + onConnected(); + }; + + dc.onmessage = (e) => { + this.handleDataMessage(e.data as string); + }; + + dc.onclose = () => { + this.setState('disconnected'); + }; + + pc.onicecandidate = (e) => { + if (e.candidate) { + this.sendSignal({ + type: 'ice', + instanceId: this.instanceId, + candidate: e.candidate.candidate, + mid: e.candidate.sdpMid ?? '0', + }); + } + }; + + pc.onconnectionstatechange = () => { + if (pc.connectionState === 'failed') { + this.setState('relay'); + onConnected(); + } + }; + + pc.createOffer().then(offer => { + pc.setLocalDescription(offer); + this.sendSignal({ + type: 'offer', + instanceId: this.instanceId, + sdp: offer.sdp, + }); + }).catch(onFailed); + } + + private handleSignalingMessage(msg: Record): void { + const type = msg['type'] as string; + + switch (type) { + case 'answer': + if (msg['sdp'] && this.pc) { + this.pc.setRemoteDescription(new RTCSessionDescription({ + type: 'answer', + sdp: msg['sdp'] as string, + })); + } + break; + case 'ice': + if (msg['candidate'] && this.pc) { + this.pc.addIceCandidate(new RTCIceCandidate({ + candidate: msg['candidate'] as string, + sdpMid: (msg['mid'] as string) ?? '0', + })); + } + break; + case 'relay_frame': + if (msg['data']) { + this.handleDataMessage(msg['data'] as string); + } + break; + } + } + + private handleDataMessage(raw: string): void { + try { + const msg = JSON.parse(raw); + const type = msg.type as string; + + switch (type) { + case 'http_response': { + const pending = this.pendingRequests.get(msg.id); + if (pending) { + this.pendingRequests.delete(msg.id); + const body = msg.body ? atob(msg.body) : ''; + pending.resolve(new Response(body, { + status: msg.status, + headers: msg.headers, + })); + } + break; + } + case 'ws_opened': { + const proxy = this.wsProxies.get(msg.wsId); + if (proxy) proxy.setReady(); + break; + } + case 'ws_frame': { + const proxy = this.wsProxies.get(msg.wsId); + if (proxy) proxy.receiveMessage(msg.data); + break; + } + case 'ws_closed': { + const proxy = this.wsProxies.get(msg.wsId); + if (proxy) { + proxy.receiveClose(); + this.wsProxies.delete(msg.wsId); + } + break; + } + case 'ws_error': { + const proxy = this.wsProxies.get(msg.wsId); + if (proxy) { + proxy.receiveError(msg.error); + this.wsProxies.delete(msg.wsId); + } + break; + } + case 'auth_ok': + break; + } + } catch { /* ignore */ } + } + + private sendData(data: string): void { + if (this.dc?.readyState === 'open') { + this.dc.send(data); + } else if (this.signalWs?.readyState === WebSocket.OPEN) { + this.signalWs.send(JSON.stringify({ + type: 'relay_frame', + instanceId: this.instanceId, + data, + })); + } + } + + private sendSignal(msg: unknown): void { + if (this.signalWs?.readyState === WebSocket.OPEN) { + this.signalWs.send(JSON.stringify(msg)); + } + } + + private setState(s: ConnectionState): void { + if (this._state === s) return; + this._state = s; + for (const cb of this.stateListeners) { + try { cb(s); } catch { /* ignore */ } + } + } +} + +class VirtualWebSocket implements WebSocketLike { + onmessage: ((e: MessageEvent) => void) | null = null; + onclose: ((e: Event) => void) | null = null; + onerror: ((e: Event) => void) | null = null; + readyState = 0; // CONNECTING + + constructor( + private wsId: string, + private sender: (data: string) => void, + ) {} + + send(data: string): void { + this.sender(JSON.stringify({ + type: 'ws_message', + wsId: this.wsId, + data, + })); + } + + close(): void { + this.readyState = 3; // CLOSED + this.sender(JSON.stringify({ + type: 'ws_close', + wsId: this.wsId, + })); + } + + setReady(): void { + this.readyState = 1; // OPEN + } + + receiveMessage(data: string): void { + if (this.onmessage) { + this.onmessage(new MessageEvent('message', { data })); + } + } + + receiveClose(): void { + this.readyState = 3; + if (this.onclose) this.onclose(new Event('close')); + } + + receiveError(error: string): void { + this.readyState = 3; + if (this.onerror) this.onerror(new ErrorEvent('error', { message: error })); + } + + forceClose(): void { + this.readyState = 3; + if (this.onclose) this.onclose(new Event('close')); + } +} diff --git a/packages/web-ui/src/transport/types.ts b/packages/web-ui/src/transport/types.ts new file mode 100644 index 00000000..40bb0430 --- /dev/null +++ b/packages/web-ui/src/transport/types.ts @@ -0,0 +1,16 @@ +export interface Transport { + fetch(path: string, init?: RequestInit): Promise; + openWebSocket(path: string, userId?: string): WebSocket | WebSocketLike; + close(): void; +} + +export interface WebSocketLike { + onmessage: ((e: MessageEvent) => void) | null; + onclose: ((e: CloseEvent | Event) => void) | null; + onerror: ((e: Event) => void) | null; + send(data: string): void; + close(): void; + readyState: number; +} + +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'relay'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fae3da9a..31f53682 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: '@markus/org-manager': specifier: workspace:* version: link:../org-manager + '@markus/remote': + specifier: workspace:* + version: link:../remote '@markus/shared': specifier: workspace:* version: link:../shared @@ -162,6 +165,22 @@ importers: specifier: ^8.18.1 version: 8.18.1 + packages/remote: + dependencies: + '@markus/shared': + specifier: workspace:* + version: link:../shared + node-datachannel: + specifier: ^0.12.0 + version: 0.12.0 + ws: + specifier: ^8.19.0 + version: 8.19.0 + devDependencies: + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + packages/shared: {} packages/storage: @@ -1392,6 +1411,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -1404,6 +1426,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1434,6 +1459,9 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + classcat@5.0.5: resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} @@ -1547,6 +1575,14 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1588,6 +1624,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.19.0: resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} engines: {node: '>=10.13.0'} @@ -1693,6 +1732,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1746,6 +1789,9 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -1777,6 +1823,9 @@ packages: get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1857,6 +1906,9 @@ packages: typescript: optional: true + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1869,6 +1921,12 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -2206,6 +2264,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -2213,6 +2275,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2221,13 +2286,29 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + node-cron@3.0.3: resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==} engines: {node: '>=6.0.0'} + node-datachannel@0.12.0: + resolution: {integrity: sha512-pZ9FsVZpHdUKqyWynuCc9IBLkZPJMpDzpNk4YNPCizbIXHYifpYeWqSF35REHGIWi9JMCf11QzapsyQGo/Y4Ig==} + engines: {node: '>=16.0.0'} + + node-domexception@2.0.2: + resolution: {integrity: sha512-Qf9vHK9c5MGgUXj8SnucCIS4oEPuUstjRaMplLGeZpbWMfNV1rvEcXuwoXfN51dUfD1b4muPHPQtCx/5Dj/QAA==} + engines: {node: '>=16'} + deprecated: Use your platform's native DOMException instead + node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} @@ -2237,6 +2318,9 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2281,6 +2365,12 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2296,10 +2386,17 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -2341,6 +2438,10 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} @@ -2380,6 +2481,9 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2411,6 +2515,12 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2428,6 +2538,9 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2435,6 +2548,10 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -2460,6 +2577,13 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2499,6 +2623,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + turndown@7.2.2: resolution: {integrity: sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==} @@ -2679,6 +2806,9 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -3656,6 +3786,12 @@ snapshots: baseline-browser-mapping@2.10.0: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + boolbase@1.0.0: {} brace-expansion@5.0.4: @@ -3670,6 +3806,11 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -3694,6 +3835,8 @@ snapshots: character-reference-invalid@2.0.1: {} + chownr@1.1.4: {} + classcat@5.0.5: {} cliui@8.0.1: @@ -3799,6 +3942,12 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + deep-is@0.1.4: {} delayed-stream@1.0.0: {} @@ -3839,6 +3988,10 @@ snapshots: emoji-regex@8.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.19.0: dependencies: graceful-fs: 4.2.11 @@ -4003,6 +4156,8 @@ snapshots: esutils@2.0.3: {} + expand-template@2.0.3: {} + expect-type@1.3.0: {} extend@3.0.2: {} @@ -4043,6 +4198,8 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + fs-constants@1.0.0: {} + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -4080,6 +4237,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -4203,12 +4362,18 @@ snapshots: optionalDependencies: typescript: 5.9.3 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} imurmurhash@0.1.4: {} + inherits@2.0.4: {} + + ini@1.3.8: {} + inline-style-parser@0.2.7: {} is-alphabetical@2.0.1: {} @@ -4737,22 +4902,39 @@ snapshots: dependencies: mime-db: 1.52.0 + mimic-response@3.1.0: {} + minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 minimist@1.2.8: {} + mkdirp-classic@0.5.3: {} + ms@2.1.3: {} nanoid@3.3.11: {} + napi-build-utils@2.0.0: {} + natural-compare@1.4.0: {} + node-abi@3.92.0: + dependencies: + semver: 7.7.4 + node-cron@3.0.3: dependencies: uuid: 8.3.2 + node-datachannel@0.12.0: + dependencies: + node-domexception: 2.0.2 + prebuild-install: 7.1.3 + + node-domexception@2.0.2: {} + node-releases@2.0.27: {} nth-check@2.1.1: @@ -4761,6 +4943,10 @@ snapshots: obug@2.1.1: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -4813,6 +4999,21 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier@3.8.1: {} @@ -4821,8 +5022,20 @@ snapshots: proxy-from-env@1.1.0: {} + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -4866,6 +5079,12 @@ snapshots: react@19.2.4: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + rehype-katex@7.0.1: dependencies: '@types/hast': 3.0.4 @@ -4966,6 +5185,8 @@ snapshots: dependencies: tslib: 2.8.1 + safe-buffer@5.2.1: {} + scheduler@0.27.0: {} semver@6.3.1: {} @@ -5013,6 +5234,14 @@ snapshots: siginfo@2.0.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} @@ -5027,6 +5256,10 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -5036,6 +5269,8 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-json-comments@2.0.1: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -5063,6 +5298,21 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -5093,6 +5343,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + turndown@7.2.2: dependencies: '@mixmark-io/domino': 2.2.0 @@ -5268,6 +5522,8 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrappy@1.0.2: {} + ws@8.19.0: {} y18n@5.0.8: {} From fdd4d45b3b2e7c7963145eb084fb75994334548a Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Mon, 18 May 2026 00:43:04 +0800 Subject: [PATCH 02/19] feat: remote access improvements - auth, TURN, relay-first transport - Discover local owner user ID at startup for accurate JWT generation (fixes missing avatar/chat history in remote UI) - Add TURN server support with IceServer object format for node-datachannel - Prefer relay transport in sendToPeer for reliability - Add connection state tracking (idle/registering/connecting/connected) - Generate markus_token with real userId instead of synthetic 'remote_owner' - Support HTTPS Hub URLs with redirect following - Real-time status updates via WebSocket, i18n support for remote UI - Non-blocking enable endpoint (fire-and-forget start) Co-authored-by: Cursor --- packages/cli/src/commands/start.ts | 44 +++-- packages/org-manager/src/api-server.ts | 19 ++- packages/remote/src/agent.ts | 157 ++++++++++++++++-- packages/web-ui/src/api.ts | 1 + packages/web-ui/src/locales/en/settings.json | 22 +++ .../web-ui/src/locales/zh-CN/settings.json | 22 +++ packages/web-ui/src/pages/Settings.tsx | 113 ++++++++++--- 7 files changed, 316 insertions(+), 62 deletions(-) diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 7a2300b5..334ca9e7 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -900,31 +900,39 @@ async function startServer(config: ReturnType, values: Record // ── Remote Access (WebRTC P2P via markus-hub + signal server) ───────── { const hubTokenPath = join(homedir(), '.markus', 'hub-token'); - const hubToken = existsSync(hubTokenPath) ? readFileSync(hubTokenPath, 'utf-8').trim() : undefined; - const remoteEnabled = config.remote?.enabled !== false; - if (hubToken && remoteEnabled) { + const createRemoteAgent = async () => { + const token = existsSync(hubTokenPath) ? readFileSync(hubTokenPath, 'utf-8').trim() : undefined; + if (!token) return null; const { RemoteAccessAgent } = await import('@markus/remote'); - const remoteAgent = new RemoteAccessAgent({ - hubUrl: config.remote?.hubUrl ?? config.hub?.url ?? 'https://markus.global', - hubToken, + return new RemoteAccessAgent({ + hubUrl: config.remote?.hubUrl ?? config.hub?.url ?? 'https://www.markus.global', + hubToken: token, instanceName: config.remote?.instanceName ?? config.org?.name ?? 'My Markus', localPort: config.server?.apiPort ?? 8056, + jwtSecret: process.env['JWT_SECRET'], }); - apiServer.setRemoteAgent(remoteAgent); + }; - if (config.remote?.autoConnect !== false) { - remoteAgent.start().then(() => { - const status = remoteAgent.getStatus(); - if (status.remoteUrl) { - log.info(`Remote access available at ${status.remoteUrl}`); - } - }).catch((err: unknown) => { - log.warn('Remote access failed to start', { error: String(err) }); - }); + apiServer.setRemoteAgentFactory(createRemoteAgent); + + if (config.remote?.enabled !== false) { + const remoteAgent = await createRemoteAgent(); + if (remoteAgent) { + apiServer.setRemoteAgent(remoteAgent); + if (config.remote?.autoConnect !== false) { + remoteAgent.start().then(() => { + const status = remoteAgent.getStatus(); + if (status.remoteUrl) { + log.info(`Remote access available at ${status.remoteUrl}`); + } + }).catch((err: unknown) => { + log.warn('Remote access failed to start', { error: String(err) }); + }); + } + } else { + log.debug('Remote access: no Hub token yet (can enable later via Settings)'); } - } else { - log.debug('Remote access not configured (no Hub token or disabled in config)'); } } diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index 0bed2b75..5c373c2b 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -198,6 +198,7 @@ export class APIServer { private teamTemplateRegistry: TeamTemplateRegistry; private fileStorage?: LocalFileStorageProvider; private remoteAgent?: { getStatus(): unknown; start(): Promise; stop(): Promise; onStatus(cb: (s: unknown) => void): () => void }; + private remoteAgentFactory?: () => Promise<{ getStatus(): unknown; start(): Promise; stop(): Promise; onStatus(cb: (s: unknown) => void): () => void } | null>; // Custom group chats are now persisted in SQLite via storage.groupChatRepo constructor( private orgService: OrganizationService, @@ -534,6 +535,10 @@ export class APIServer { }); } + setRemoteAgentFactory(factory: () => Promise<{ getStatus(): unknown; start(): Promise; stop(): Promise; onStatus(cb: (s: unknown) => void): () => void } | null>): void { + this.remoteAgentFactory = factory; + } + setGateway(gateway: ExternalAgentGateway, secret?: string): void { this.gateway = gateway; this.gatewaySecret = secret; @@ -6897,16 +6902,16 @@ EXPLANATION_END`; } if (path === '/api/settings/remote/enable' && req.method === 'POST') { + if (!this.remoteAgent && this.remoteAgentFactory) { + const agent = await this.remoteAgentFactory(); + if (agent) this.setRemoteAgent(agent); + } if (!this.remoteAgent) { - this.json(res, 400, { error: 'Remote access not configured. Hub token required.' }); + this.json(res, 400, { error: 'Remote access not configured. Please sign in to Markus Hub first.' }); return; } - try { - await this.remoteAgent.start(); - this.json(res, 200, { ok: true, status: this.remoteAgent.getStatus() }); - } catch (err) { - this.json(res, 500, { error: String(err) }); - } + this.remoteAgent.start().catch(() => {}); + this.json(res, 200, { ok: true, status: this.remoteAgent.getStatus() }); return; } diff --git a/packages/remote/src/agent.ts b/packages/remote/src/agent.ts index 95e12c8c..3dd084c3 100644 --- a/packages/remote/src/agent.ts +++ b/packages/remote/src/agent.ts @@ -1,16 +1,31 @@ import { createLogger } from '@markus/shared'; import { WebSocket } from 'ws'; import { request as httpRequest, type IncomingMessage } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { createHmac } from 'node:crypto'; import { PeerConnection, DataChannel, initLogger as initRtcLogger, type RtcConfig, + type IceServer, + RelayType, DescriptionType, } from 'node-datachannel'; const log = createLogger('remote'); +function base64url(buf: Buffer): string { + return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function signJwt(payload: Record, secret: string): string { + const header = base64url(Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))); + const body = base64url(Buffer.from(JSON.stringify(payload))); + const sig = base64url(createHmac('sha256', secret).update(`${header}.${body}`).digest()); + return `${header}.${body}.${sig}`; +} + const STUN_SERVERS = [ 'stun:stun.l.google.com:19302', 'stun:stun.cloudflare.com:3478', @@ -25,28 +40,38 @@ export interface RemoteAccessConfig { hubToken: string; instanceName?: string; localPort: number; + jwtSecret?: string; } export interface RemoteAccessStatus { enabled: boolean; connected: boolean; + state: 'idle' | 'registering' | 'connecting' | 'connected' | 'disconnected'; instanceId: string | null; remoteUrl: string | null; signalUrl: string | null; peerCount: number; } +interface TurnServer { + urls: string; + username: string; + credential: string; +} + interface RegistrationResult { instanceId: string; signalingToken: string; signalUrl: string; remoteUrl: string; + turnServers?: TurnServer[] | null; } interface PeerSession { pc: PeerConnection; dc: DataChannel | null; pendingChunks: Map; + markusToken: string | null; } export class RemoteAccessAgent { @@ -58,6 +83,7 @@ export class RemoteAccessAgent { private reconnectTimer: ReturnType | null = null; private reconnectAttempts = 0; private destroyed = false; + private localOwnerUserId: string | null = null; private statusListeners = new Set<(status: RemoteAccessStatus) => void>(); @@ -69,9 +95,11 @@ export class RemoteAccessAgent { // ── Public API ──────────────────────────────────────────────────────────── async start(): Promise { - if (this.destroyed) return; + this.destroyed = false; log.info('Starting remote access agent...'); + await this.discoverLocalOwner(); + try { this.registration = await this.registerInstance(); log.info('Registered with Hub', { @@ -85,6 +113,41 @@ export class RemoteAccessAgent { } } + private async discoverLocalOwner(): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const resp = await new Promise((resolve, reject) => { + const req = httpRequest( + `http://127.0.0.1:${this.config.localPort}/api/users`, + { method: 'GET', headers: { host: `127.0.0.1:${this.config.localPort}` } }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => resolve(Buffer.concat(chunks).toString())); + } + ); + req.on('error', reject); + req.end(); + }); + + const data = JSON.parse(resp); + const users = data.users as Array<{ id: string; role: string }> | undefined; + if (users?.length) { + const owner = users.find(u => u.role === 'owner') ?? users[0]; + this.localOwnerUserId = owner!.id; + log.info('Discovered local owner', { userId: this.localOwnerUserId }); + } + return; + } catch (err) { + if (attempt < 2) { + await new Promise(r => setTimeout(r, 1000 * (attempt + 1))); + } else { + log.warn('Failed to discover local owner, using synthetic user', { error: String(err) }); + } + } + } + } + async stop(): Promise { this.destroyed = true; if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); @@ -111,9 +174,19 @@ export class RemoteAccessAgent { } getStatus(): RemoteAccessStatus { + const wsOpen = this.ws?.readyState === WebSocket.OPEN; + let state: RemoteAccessStatus['state'] = 'idle'; + if (!this.destroyed) { + if (wsOpen) state = 'connected'; + else if (this.registration) state = 'connecting'; + else if (this.reconnectTimer) state = 'connecting'; + else state = 'registering'; + } + return { enabled: !this.destroyed, - connected: this.ws?.readyState === WebSocket.OPEN, + connected: wsOpen, + state, instanceId: this.registration?.instanceId ?? null, remoteUrl: this.registration?.remoteUrl ?? null, signalUrl: this.registration?.signalUrl ?? null, @@ -142,12 +215,13 @@ export class RemoteAccessAgent { }); } - private hubFetch(method: string, path: string, body?: unknown): Promise { + private hubFetch(method: string, path: string, body?: unknown, _redirects = 0): Promise { return new Promise((resolve, reject) => { const url = new URL(path, this.config.hubUrl); const data = body ? JSON.stringify(body) : undefined; + const transport = url.protocol === 'https:' ? httpsRequest : httpRequest; - const req = httpRequest( + const req = transport( url, { method, @@ -158,6 +232,13 @@ export class RemoteAccessAgent { }, }, (res: IncomingMessage) => { + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + if (_redirects >= 5) { reject(new Error('Too many redirects')); return; } + const redirectUrl = new URL(res.headers.location, url); + this.config.hubUrl = redirectUrl.origin; + resolve(this.hubFetch(method, redirectUrl.pathname + redirectUrl.search, body, _redirects + 1)); + return; + } let raw = ''; res.on('data', (c: Buffer) => (raw += c.toString())); res.on('end', () => { @@ -227,9 +308,15 @@ export class RemoteAccessAgent { private handleSignalingMessage(msg: Record): void { const type = msg['type'] as string; - const peerId = msg['peerId'] as string | undefined; + const peerId = (msg['peerId'] ?? msg['from']) as string | undefined; switch (type) { + case 'ping': + this.send({ type: 'pong' }); + break; + case 'registered': + log.info('Registered with signal server', { instanceId: msg['instanceId'] }); + break; case 'peer_request': if (peerId) this.handlePeerRequest(peerId); break; @@ -246,6 +333,9 @@ export class RemoteAccessAgent { case 'peer_disconnected': if (peerId) this.cleanupPeer(peerId); break; + case 'relay_activated': + if (peerId) log.info('Peer activated relay mode', { peerId }); + break; case 'relay_frame': if (peerId && msg['data']) { this.handleRelayFrame(peerId, msg['data'] as string); @@ -283,11 +373,27 @@ export class RemoteAccessAgent { this.cleanupPeer(peerId); } + const iceServers: (string | IceServer)[] = [...STUN_SERVERS]; + if (this.registration?.turnServers) { + for (const t of this.registration.turnServers) { + const parsed = t.urls.match(/^(turns?):([^:]+):(\d+)$/); + if (parsed) { + const relayType = parsed[1] === 'turns' ? RelayType.TurnTls : RelayType.TurnUdp; + iceServers.push({ + hostname: parsed[2]!, + port: parseInt(parsed[3]!, 10), + username: t.username, + password: t.credential, + relayType, + }); + } + } + } const pc = new PeerConnection(`markus-${peerId}`, { - iceServers: STUN_SERVERS, + iceServers, } satisfies RtcConfig); - const session: PeerSession = { pc, dc: null, pendingChunks: new Map() }; + const session: PeerSession = { pc, dc: null, pendingChunks: new Map(), markusToken: null }; this.peers.set(peerId, session); pc.onStateChange((state: string) => { @@ -374,10 +480,22 @@ export class RemoteAccessAgent { this.handleDataChannelMessage(peerId, data); } + private generateMarkusToken(): string { + const secret = this.config.jwtSecret ?? process.env['JWT_SECRET'] ?? 'markus-dev-secret-change-in-prod'; + const exp = Math.floor(Date.now() / 1000) + 24 * 3600; + const userId = this.localOwnerUserId ?? 'remote_owner'; + return signJwt({ userId, orgId: 'default', role: 'owner', exp }, secret); + } + private handleAuthHandshake(peerId: string, _msg: Record): void { + const session = this.peers.get(peerId); + if (session && !session.markusToken) { + session.markusToken = this.generateMarkusToken(); + } this.sendToPeer(peerId, { type: 'auth_ok', instanceName: this.config.instanceName ?? 'My Markus', + token: session?.markusToken ?? null, }); } @@ -388,13 +506,21 @@ export class RemoteAccessAgent { const headers = (msg.headers as Record) ?? {}; const body = msg.body as string | undefined; + const session = this.peers.get(peerId); + if (session && !session.markusToken) { + session.markusToken = this.generateMarkusToken(); + } + const tokenCookie = session?.markusToken ? `markus_token=${session.markusToken}` : ''; + const existingCookie = headers['cookie'] ?? headers['Cookie'] ?? ''; + const cookie = existingCookie ? `${existingCookie}; ${tokenCookie}` : tokenCookie; + const url = new URL(path, `http://127.0.0.1:${this.config.localPort}`); const req = httpRequest( url, { method, - headers: { ...headers, host: `127.0.0.1:${this.config.localPort}` }, + headers: { ...headers, host: `127.0.0.1:${this.config.localPort}`, cookie }, }, (res: IncomingMessage) => { const chunks: Buffer[] = []; @@ -482,19 +608,26 @@ export class RemoteAccessAgent { // ── Helpers ─────────────────────────────────────────────────────────────── private sendToPeer(peerId: string, msg: unknown): void { - const session = this.peers.get(peerId); const data = JSON.stringify(msg); + // Prefer relay (primary transport) — always reliable when WS is connected + if (this.ws?.readyState === WebSocket.OPEN) { + this.send({ type: 'relay_frame', peerId, data }); + return; + } + + // Fallback to DC if relay WS is down + const session = this.peers.get(peerId); if (session?.dc && session.dc.isOpen()) { try { session.dc.sendMessage(data); return; } catch (err) { - log.warn('DataChannel send failed, falling back to relay', { peerId, error: String(err) }); + log.warn('DataChannel send failed', { peerId, error: String(err) }); } } - this.send({ type: 'relay_frame', peerId, data }); + log.warn('No transport available for peer', { peerId }); } private send(msg: unknown): void { @@ -506,7 +639,7 @@ export class RemoteAccessAgent { private startHeartbeat(): void { this.stopHeartbeat(); this.heartbeatTimer = setInterval(() => { - this.send({ type: 'heartbeat' }); + this.send({ type: 'pong' }); }, HEARTBEAT_INTERVAL_MS); } diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index 4fe72bac..e509d3f6 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -329,6 +329,7 @@ async function request(path: string, opts?: RequestInit): Promise { export interface RemoteStatus { enabled: boolean; connected: boolean; + state: 'idle' | 'registering' | 'connecting' | 'connected' | 'disconnected'; instanceId: string | null; remoteUrl: string | null; signalUrl: string | null; diff --git a/packages/web-ui/src/locales/en/settings.json b/packages/web-ui/src/locales/en/settings.json index 9a775c08..fbffa4f8 100644 --- a/packages/web-ui/src/locales/en/settings.json +++ b/packages/web-ui/src/locales/en/settings.json @@ -271,6 +271,28 @@ "oauthConnected": "Connected to {{provider}} via OAuth", "authProfileDeleted": "Auth profile deleted", "browserOpened": "Browser opened for authorization. Complete the login in the browser window...", + "remoteAccess": { + "title": "Remote Access", + "description": "Access this Markus instance from your phone or any device via WebRTC P2P connection through Markus Hub.", + "loginRequired": "Sign in to Markus Hub to enable remote access.", + "signIn": "Sign In", + "signedInAs": "Signed in as", + "enabled": "Remote access enabled", + "disabled": "Remote access disabled", + "connecting": "Connecting to signal server...", + "registering": "Registering with Hub...", + "connected": "Connected", + "disconnected": "Disconnected", + "peerCount_one": "{{count}} peer", + "peerCount_other": "{{count}} peers", + "url": "Remote URL", + "copy": "Copy", + "copied": "Copied!", + "qrCode": "QR Code", + "scanQr": "Scan with your phone camera to connect", + "enableFailed": "Failed to enable remote access", + "disableFailed": "Failed to disable remote access" + }, "userManagement": { "title": "User Management", "name": "Name", diff --git a/packages/web-ui/src/locales/zh-CN/settings.json b/packages/web-ui/src/locales/zh-CN/settings.json index 298d4e1b..b758deee 100644 --- a/packages/web-ui/src/locales/zh-CN/settings.json +++ b/packages/web-ui/src/locales/zh-CN/settings.json @@ -271,6 +271,28 @@ "oauthConnected": "已通过 OAuth 连接到 {{provider}}", "authProfileDeleted": "认证配置已删除", "browserOpened": "已打开浏览器以完成授权。请在浏览器窗口中完成登录…", + "remoteAccess": { + "title": "远程访问", + "description": "通过 Markus Hub 的 WebRTC P2P 连接,从手机或任何设备访问此 Markus 实例。", + "loginRequired": "请先登录 Markus Hub 以启用远程访问。", + "signIn": "登录", + "signedInAs": "已登录:", + "enabled": "远程访问已启用", + "disabled": "远程访问已禁用", + "connecting": "正在连接信令服务器…", + "registering": "正在注册到 Hub…", + "connected": "已连接", + "disconnected": "已断开", + "peerCount_one": "{{count}} 个对等节点", + "peerCount_other": "{{count}} 个对等节点", + "url": "远程访问地址", + "copy": "复制", + "copied": "已复制!", + "qrCode": "二维码", + "scanQr": "用手机相机扫描以连接", + "enableFailed": "启用远程访问失败", + "disableFailed": "禁用远程访问失败" + }, "userManagement": { "title": "用户管理", "name": "姓名", diff --git a/packages/web-ui/src/pages/Settings.tsx b/packages/web-ui/src/pages/Settings.tsx index d1eab413..a2756521 100644 --- a/packages/web-ui/src/pages/Settings.tsx +++ b/packages/web-ui/src/pages/Settings.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback, useRef } from 'react'; import { useTranslation, Trans } from 'react-i18next'; -import { api, type StorageInfo, type OrphanInfo, type AuthUser, type HumanUserInfo, type RemoteStatus, hubApi, getHubUser, ensureHubAuth } from '../api.ts'; +import { api, type StorageInfo, type OrphanInfo, type AuthUser, type HumanUserInfo, type RemoteStatus, hubApi, getHubUser, ensureHubAuth, wsClient } from '../api.ts'; import { THEME_OPTIONS, type ThemeMode } from '../hooks/useTheme.ts'; import { SUPPORTED_LANGUAGES } from '../i18n/index.ts'; import { navBus } from '../navBus.ts'; @@ -2298,6 +2298,7 @@ function RemoteAccessSection() { const [loading, setLoading] = useState(true); const [toggling, setToggling] = useState(false); const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); const hubUser = getHubUser(); const loadStatus = useCallback(async () => { @@ -2313,22 +2314,32 @@ function RemoteAccessSection() { useEffect(() => { loadStatus(); }, [loadStatus]); + useEffect(() => { + return wsClient.on('remote:status', (event) => { + const payload = event.payload as RemoteStatus | undefined; + if (payload) { + setStatus(payload); + setToggling(false); + } + }); + }, []); + const handleToggle = async () => { setToggling(true); setError(null); try { if (status?.enabled) { await api.settings.disableRemote(); + await loadStatus(); + setToggling(false); } else { if (!hubApi.isAuthenticated()) { await ensureHubAuth(); } await api.settings.enableRemote(); } - await loadStatus(); } catch (err) { setError(String(err instanceof Error ? err.message : err)); - } finally { setToggling(false); } }; @@ -2340,13 +2351,21 @@ function RemoteAccessSection() { } catch { /* user cancelled */ } }; + const handleCopy = () => { + if (!status?.remoteUrl) return; + navigator.clipboard.writeText(status.remoteUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const isConnecting = toggling || (status?.enabled && !status?.connected && status?.state !== 'idle'); const qrUrl = status?.remoteUrl ?? null; return ( -
+

- {t('remoteAccess.description', 'Access this Markus instance from your phone or any device via WebRTC P2P connection through Markus Hub.')} + {t('settings:remoteAccess.description')}

{/* Hub Auth Status */} @@ -2356,19 +2375,19 @@ function RemoteAccessSection() { - {t('remoteAccess.loginRequired', 'Sign in to Markus Hub to enable remote access.')} + {t('settings:remoteAccess.loginRequired')}
) : (
- {t('remoteAccess.signedInAs', 'Signed in as')} {hubUser?.username ?? hubUser?.displayName} + {t('settings:remoteAccess.signedInAs')} {hubUser?.username ?? hubUser?.displayName}
)} @@ -2382,24 +2401,48 @@ function RemoteAccessSection() { disabled={toggling || loading} className={`relative w-12 h-6 rounded-full transition-colors duration-200 ${ status?.enabled ? 'bg-brand-600' : 'bg-gray-300 dark:bg-gray-600' - } ${toggling ? 'opacity-50' : ''}`} + } ${(toggling || loading) ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`} > - {status?.enabled - ? t('remoteAccess.enabled', 'Remote access enabled') - : t('remoteAccess.disabled', 'Remote access disabled')} + {isConnecting + ? t('settings:remoteAccess.connecting') + : status?.enabled + ? t('settings:remoteAccess.enabled') + : t('settings:remoteAccess.disabled')} + {isConnecting && } - {status?.connected && ( -
- - {t('remoteAccess.connected', 'Connected')} - {status.peerCount > 0 && ` · ${status.peerCount} ${status.peerCount === 1 ? 'peer' : 'peers'}`} + {status?.enabled && ( +
+ + {status.connected + ? t('settings:remoteAccess.connected') + : (status.state === 'registering') + ? t('settings:remoteAccess.registering') + : (status.state === 'connecting') + ? t('settings:remoteAccess.connecting') + : t('settings:remoteAccess.disconnected')} + {status.connected && status.peerCount > 0 && ( + <> · {t('settings:remoteAccess.peerCount', { count: status.peerCount })} + )} + {(status.state === 'registering' || status.state === 'connecting') && }
)}
@@ -2412,23 +2455,34 @@ function RemoteAccessSection() { {status?.enabled && status.remoteUrl && (
- +
- {status.remoteUrl} + + {status.remoteUrl} +
{qrUrl && (
- -

{t('remoteAccess.scanQr', 'Scan with your phone camera to connect')}

+ +

+ {t('settings:remoteAccess.scanQr')} +

)} @@ -2441,6 +2495,15 @@ function RemoteAccessSection() { ); } +function Spinner() { + return ( + + + + + ); +} + /** Simple SVG-based QR code using a canvas. Falls back to a link if canvas unavailable. */ function QRCode({ url }: { url: string }) { const canvasRef = useRef(null); From eb0c614fc69aca272e0ca45d6a082965a352d1db Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Mon, 18 May 2026 00:49:27 +0800 Subject: [PATCH 03/19] refactor: switch to P2P-first transport, relay as fallback Co-authored-by: Cursor --- packages/remote/src/agent.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/remote/src/agent.ts b/packages/remote/src/agent.ts index 3dd084c3..f90f1c23 100644 --- a/packages/remote/src/agent.ts +++ b/packages/remote/src/agent.ts @@ -610,13 +610,7 @@ export class RemoteAccessAgent { private sendToPeer(peerId: string, msg: unknown): void { const data = JSON.stringify(msg); - // Prefer relay (primary transport) — always reliable when WS is connected - if (this.ws?.readyState === WebSocket.OPEN) { - this.send({ type: 'relay_frame', peerId, data }); - return; - } - - // Fallback to DC if relay WS is down + // Prefer P2P DataChannel — direct, low latency const session = this.peers.get(peerId); if (session?.dc && session.dc.isOpen()) { try { @@ -627,6 +621,12 @@ export class RemoteAccessAgent { } } + // Fallback to relay via signaling WS + if (this.ws?.readyState === WebSocket.OPEN) { + this.send({ type: 'relay_frame', peerId, data }); + return; + } + log.warn('No transport available for peer', { peerId }); } From f20bee3bae9c9dcc1b108225a8f9ed5e05ba5d41 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Mon, 18 May 2026 00:57:36 +0800 Subject: [PATCH 04/19] feat: show connected peers list with transport type in remote access settings Co-authored-by: Cursor --- packages/remote/src/agent.ts | 33 ++++++++++++++++- packages/web-ui/src/api.ts | 8 +++++ packages/web-ui/src/locales/en/settings.json | 5 ++- .../web-ui/src/locales/zh-CN/settings.json | 5 ++- packages/web-ui/src/pages/Settings.tsx | 35 +++++++++++++++++++ 5 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/remote/src/agent.ts b/packages/remote/src/agent.ts index f90f1c23..c5b6c8bf 100644 --- a/packages/remote/src/agent.ts +++ b/packages/remote/src/agent.ts @@ -43,6 +43,13 @@ export interface RemoteAccessConfig { jwtSecret?: string; } +export interface RemotePeerInfo { + peerId: string; + transport: 'p2p' | 'relay' | 'connecting'; + connectedAt: number; + lastActiveAt: number; +} + export interface RemoteAccessStatus { enabled: boolean; connected: boolean; @@ -51,6 +58,7 @@ export interface RemoteAccessStatus { remoteUrl: string | null; signalUrl: string | null; peerCount: number; + peers: RemotePeerInfo[]; } interface TurnServer { @@ -72,6 +80,8 @@ interface PeerSession { dc: DataChannel | null; pendingChunks: Map; markusToken: string | null; + connectedAt: number; + lastActiveAt: number; } export class RemoteAccessAgent { @@ -183,6 +193,22 @@ export class RemoteAccessAgent { else state = 'registering'; } + const peers: RemotePeerInfo[] = []; + for (const [peerId, session] of this.peers) { + let transport: 'p2p' | 'relay' | 'connecting' = 'connecting'; + if (session.dc && session.dc.isOpen()) { + transport = 'p2p'; + } else if (wsOpen) { + transport = 'relay'; + } + peers.push({ + peerId, + transport, + connectedAt: session.connectedAt, + lastActiveAt: session.lastActiveAt, + }); + } + return { enabled: !this.destroyed, connected: wsOpen, @@ -191,6 +217,7 @@ export class RemoteAccessAgent { remoteUrl: this.registration?.remoteUrl ?? null, signalUrl: this.registration?.signalUrl ?? null, peerCount: this.peers.size, + peers, }; } @@ -393,7 +420,8 @@ export class RemoteAccessAgent { iceServers, } satisfies RtcConfig); - const session: PeerSession = { pc, dc: null, pendingChunks: new Map(), markusToken: null }; + const now = Date.now(); + const session: PeerSession = { pc, dc: null, pendingChunks: new Map(), markusToken: null, connectedAt: now, lastActiveAt: now }; this.peers.set(peerId, session); pc.onStateChange((state: string) => { @@ -448,6 +476,9 @@ export class RemoteAccessAgent { // ── DataChannel Message Handling (HTTP/WS proxy) ───────────────────────── private handleDataChannelMessage(peerId: string, raw: string): void { + const session = this.peers.get(peerId); + if (session) session.lastActiveAt = Date.now(); + try { const msg = JSON.parse(raw); const type = msg.type as string; diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index e509d3f6..e466edfa 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -326,6 +326,13 @@ async function request(path: string, opts?: RequestInit): Promise { return res.json() as Promise; } +export interface RemotePeerInfo { + peerId: string; + transport: 'p2p' | 'relay' | 'connecting'; + connectedAt: number; + lastActiveAt: number; +} + export interface RemoteStatus { enabled: boolean; connected: boolean; @@ -334,6 +341,7 @@ export interface RemoteStatus { remoteUrl: string | null; signalUrl: string | null; peerCount: number; + peers: RemotePeerInfo[]; } export type AgentActivityType = 'task' | 'heartbeat' | 'chat' | 'a2a' | 'internal' | 'respond_in_session'; diff --git a/packages/web-ui/src/locales/en/settings.json b/packages/web-ui/src/locales/en/settings.json index fbffa4f8..9d6451fb 100644 --- a/packages/web-ui/src/locales/en/settings.json +++ b/packages/web-ui/src/locales/en/settings.json @@ -291,7 +291,10 @@ "qrCode": "QR Code", "scanQr": "Scan with your phone camera to connect", "enableFailed": "Failed to enable remote access", - "disableFailed": "Failed to disable remote access" + "disableFailed": "Failed to disable remote access", + "connectedPeers": "Connected Peers", + "connectedSince": "Connected since {{time}}", + "peerConnecting": "Connecting" }, "userManagement": { "title": "User Management", diff --git a/packages/web-ui/src/locales/zh-CN/settings.json b/packages/web-ui/src/locales/zh-CN/settings.json index b758deee..74601af4 100644 --- a/packages/web-ui/src/locales/zh-CN/settings.json +++ b/packages/web-ui/src/locales/zh-CN/settings.json @@ -291,7 +291,10 @@ "qrCode": "二维码", "scanQr": "用手机相机扫描以连接", "enableFailed": "启用远程访问失败", - "disableFailed": "禁用远程访问失败" + "disableFailed": "禁用远程访问失败", + "connectedPeers": "已连接的设备", + "connectedSince": "连接于 {{time}}", + "peerConnecting": "连接中" }, "userManagement": { "title": "用户管理", diff --git a/packages/web-ui/src/pages/Settings.tsx b/packages/web-ui/src/pages/Settings.tsx index a2756521..ddfa24d5 100644 --- a/packages/web-ui/src/pages/Settings.tsx +++ b/packages/web-ui/src/pages/Settings.tsx @@ -2451,6 +2451,41 @@ function RemoteAccessSection() {
{error}
)} + {/* Connected Peers List */} + {status?.enabled && status.peers && status.peers.length > 0 && ( +
+ +
+ {status.peers.map((peer) => ( +
+ +
+
+ {peer.peerId.slice(0, 8)}... +
+
+ {t('settings:remoteAccess.connectedSince', { time: new Date(peer.connectedAt).toLocaleTimeString() })} +
+
+ + {peer.transport === 'p2p' ? 'P2P' : peer.transport === 'relay' ? 'Relay' : t('settings:remoteAccess.peerConnecting')} + +
+ ))} +
+
+ )} + {/* Remote URL + QR Code */} {status?.enabled && status.remoteUrl && (
From 2ea5abdb338811b0d18ffb1178b464bc073e3652 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Mon, 18 May 2026 01:10:59 +0800 Subject: [PATCH 05/19] fix: add message chunking for large P2P DataChannel responses Large HTTP responses (e.g. HTML pages) could exceed DataChannel max message size. Split into 48KB chunks with reassembly on client side. Co-authored-by: Cursor --- packages/remote/src/agent.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/remote/src/agent.ts b/packages/remote/src/agent.ts index c5b6c8bf..c199902d 100644 --- a/packages/remote/src/agent.ts +++ b/packages/remote/src/agent.ts @@ -638,9 +638,36 @@ export class RemoteAccessAgent { // ── Helpers ─────────────────────────────────────────────────────────────── + private static readonly CHUNK_SIZE = 48 * 1024; // 48KB per chunk (safe for DC + relay) + private sendToPeer(peerId: string, msg: unknown): void { const data = JSON.stringify(msg); + if (data.length > RemoteAccessAgent.CHUNK_SIZE) { + this.sendChunked(peerId, data); + return; + } + + this.sendRaw(peerId, data); + } + + private sendChunked(peerId: string, data: string): void { + const chunkId = `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + const total = Math.ceil(data.length / RemoteAccessAgent.CHUNK_SIZE); + + for (let i = 0; i < total; i++) { + const chunk = data.slice(i * RemoteAccessAgent.CHUNK_SIZE, (i + 1) * RemoteAccessAgent.CHUNK_SIZE); + this.sendRaw(peerId, JSON.stringify({ + type: '__chunk', + chunkId, + index: i, + total, + data: chunk, + })); + } + } + + private sendRaw(peerId: string, data: string): void { // Prefer P2P DataChannel — direct, low latency const session = this.peers.get(peerId); if (session?.dc && session.dc.isOpen()) { @@ -648,7 +675,7 @@ export class RemoteAccessAgent { session.dc.sendMessage(data); return; } catch (err) { - log.warn('DataChannel send failed', { peerId, error: String(err) }); + log.warn('DataChannel send failed, falling back to relay', { peerId, error: String(err) }); } } From 3898dc051606bc477e7e356e03747554f2f4e5b0 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Mon, 18 May 2026 10:47:09 +0800 Subject: [PATCH 06/19] fix: fix TURN server config - RelayType not exported from node-datachannel RelayType enum doesn't exist in node-datachannel runtime exports, causing undefined relayType in ICE server config. Use string literals instead. Also fix URL parsing for ?transport=tcp parameter. Co-authored-by: Cursor --- packages/remote/src/agent.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/remote/src/agent.ts b/packages/remote/src/agent.ts index c199902d..66ef1c98 100644 --- a/packages/remote/src/agent.ts +++ b/packages/remote/src/agent.ts @@ -9,7 +9,6 @@ import { initLogger as initRtcLogger, type RtcConfig, type IceServer, - RelayType, DescriptionType, } from 'node-datachannel'; @@ -403,16 +402,20 @@ export class RemoteAccessAgent { const iceServers: (string | IceServer)[] = [...STUN_SERVERS]; if (this.registration?.turnServers) { for (const t of this.registration.turnServers) { - const parsed = t.urls.match(/^(turns?):([^:]+):(\d+)$/); + const parsed = t.urls.match(/^(turns?):([^:?]+):(\d+)/); if (parsed) { - const relayType = parsed[1] === 'turns' ? RelayType.TurnTls : RelayType.TurnUdp; + const isTcp = t.urls.includes('transport=tcp'); + const isTls = parsed[1] === 'turns'; + let relayType = 'TurnUdp'; + if (isTls) relayType = 'TurnTls'; + else if (isTcp) relayType = 'TurnTcp'; iceServers.push({ hostname: parsed[2]!, port: parseInt(parsed[3]!, 10), username: t.username, password: t.credential, relayType, - }); + } as IceServer); } } } From 680b70fe4a880c4797aa1e53b27b336b04844467 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Mon, 18 May 2026 14:38:00 +0800 Subject: [PATCH 07/19] feat: redesign settings navigation - desktop sidebar hidden, mobile drawer menu - Desktop: hide main app sidebar on Settings page, show back button in settings sidebar - Mobile: remove Settings from bottom nav, add hamburger drawer menu with avatar/settings - Mobile settings: show grouped list navigation, each item opens sub-page with back button - Replace MobileSettingsTabs with unified Settings component (mobile-aware) - Add slide-in drawer animations (fadeIn, slideInLeft) - Add QR code generation with real qrcode library - Add settings sidebar with hash-based sub-path routing Co-authored-by: Cursor --- packages/web-ui/package.json | 2 + packages/web-ui/src/App.tsx | 35 ++- .../web-ui/src/components/MobileDrawer.tsx | 108 ++++++++ packages/web-ui/src/index.css | 15 ++ packages/web-ui/src/locales/en/common.json | 2 + packages/web-ui/src/locales/en/settings.json | 10 + packages/web-ui/src/locales/zh-CN/common.json | 2 + .../web-ui/src/locales/zh-CN/settings.json | 10 + packages/web-ui/src/pages/Settings.tsx | 238 ++++++++++++------ packages/web-ui/src/routes.ts | 1 - pnpm-lock.yaml | 152 +++++++++++ 11 files changed, 491 insertions(+), 84 deletions(-) create mode 100644 packages/web-ui/src/components/MobileDrawer.tsx diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index b3474c7a..730df4fa 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -13,10 +13,12 @@ "@dagrejs/dagre": "^2.0.4", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-virtual": "^3.13.24", + "@types/qrcode": "^1.5.6", "@xyflow/react": "^12.10.1", "i18next": "^26.0.6", "i18next-browser-languagedetector": "^8.2.1", "katex": "^0.16.45", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^17.0.4", diff --git a/packages/web-ui/src/App.tsx b/packages/web-ui/src/App.tsx index b05cffdd..b70301f5 100644 --- a/packages/web-ui/src/App.tsx +++ b/packages/web-ui/src/App.tsx @@ -12,7 +12,7 @@ import { NotificationsPage } from './pages/Notifications.tsx'; import { Sidebar } from './components/Sidebar.tsx'; import { BottomNav } from './components/BottomNav.tsx'; import { MobileBuilderTabs } from './components/MobileBuilderTabs.tsx'; -import { MobileSettingsTabs } from './components/MobileSettingsTabs.tsx'; +import { MobileDrawer, openMobileDrawer } from './components/MobileDrawer.tsx'; import { Onboarding } from './components/Onboarding.tsx'; import { Login, InviteSetup, InitialSetup } from './pages/Login.tsx'; import { ChangePassword } from './pages/ChangePassword.tsx'; @@ -183,7 +183,7 @@ export function App() { [PAGE.HOME]: , [PAGE.TEAM]: , [PAGE.BUILDER]: , - [PAGE.SETTINGS]: setAuthUser(null)} onUserUpdated={(u) => setAuthUser(u)} />, + [PAGE.SETTINGS]: setAuthUser(null)} onUserUpdated={(u) => setAuthUser(u)} />, [PAGE.WORK]: , [PAGE.DELIVERABLES]: , [PAGE.NOTIFICATIONS]: , @@ -272,8 +272,8 @@ export function App() { return (
- {/* Desktop sidebar */} - {!isMobile && ( + {/* Desktop sidebar (hidden on Settings page) */} + {!isMobile && page !== PAGE.SETTINGS && ( <>
)} -
+
+ {/* Mobile top bar */} + {isMobile && page !== PAGE.SETTINGS && ( +
+ +
+ )} {llmConfigured === false && !llmBannerDismissed && page !== PAGE.SETTINGS && (
No LLM provider configured — agents cannot process requests. @@ -329,10 +345,15 @@ export function App() {
- {/* Mobile bottom nav */} - {isMobile && ( + {/* Mobile bottom nav (hidden on Settings page) */} + {isMobile && page !== PAGE.SETTINGS && ( )} + + {/* Mobile drawer menu */} + {isMobile && ( + + )}
); } diff --git a/packages/web-ui/src/components/MobileDrawer.tsx b/packages/web-ui/src/components/MobileDrawer.tsx new file mode 100644 index 00000000..cd30e83e --- /dev/null +++ b/packages/web-ui/src/components/MobileDrawer.tsx @@ -0,0 +1,108 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Avatar } from './Avatar.tsx'; +import { PAGE } from '../routes.ts'; +import type { AuthUser } from '../api.ts'; + +interface MobileDrawerProps { + authUser?: AuthUser; + onNavigate: (page: string) => void; +} + +export function MobileDrawer({ authUser, onNavigate }: MobileDrawerProps) { + const { t } = useTranslation(['settings', 'common']); + const [open, setOpen] = useState(false); + + useEffect(() => { + const handler = () => setOpen(true); + window.addEventListener('markus:open-drawer', handler); + return () => window.removeEventListener('markus:open-drawer', handler); + }, []); + + useEffect(() => { + if (open) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { document.body.style.overflow = ''; }; + }, [open]); + + const handleNav = (page: string) => { + setOpen(false); + if (page === '__edit_profile') { + window.location.hash = '#settings/users'; + onNavigate(PAGE.SETTINGS); + return; + } + onNavigate(page); + }; + + if (!open) return null; + + return ( +
+
setOpen(false)} + /> + +
+ ); +} + +function DrawerNavItem({ icon, label, onClick }: { icon: React.ReactNode; label: string; onClick: () => void }) { + return ( + + ); +} + +export function openMobileDrawer() { + window.dispatchEvent(new CustomEvent('markus:open-drawer')); +} diff --git a/packages/web-ui/src/index.css b/packages/web-ui/src/index.css index a6bda948..932ff531 100644 --- a/packages/web-ui/src/index.css +++ b/packages/web-ui/src/index.css @@ -270,3 +270,18 @@ body { z-index: -1; pointer-events: none; } + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes slideInLeft { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} +.animate-fadeIn { + animation: fadeIn 0.2s ease-out; +} +.animate-slideInLeft { + animation: slideInLeft 0.25s ease-out; +} diff --git a/packages/web-ui/src/locales/en/common.json b/packages/web-ui/src/locales/en/common.json index 721498b2..100ed7c6 100644 --- a/packages/web-ui/src/locales/en/common.json +++ b/packages/web-ui/src/locales/en/common.json @@ -12,6 +12,8 @@ "creating": "Creating...", "refresh": "Refresh", "back": "Back", + "home": "Home", + "reports": "Reports", "next": "Next", "skip": "Skip", "dismiss": "Dismiss", diff --git a/packages/web-ui/src/locales/en/settings.json b/packages/web-ui/src/locales/en/settings.json index 9d6451fb..5b3d523f 100644 --- a/packages/web-ui/src/locales/en/settings.json +++ b/packages/web-ui/src/locales/en/settings.json @@ -1,5 +1,15 @@ { "title": "Settings", + "nav": { + "appearance": "Appearance", + "providers": "Model Providers", + "execution": "Agent & Pipeline", + "browser": "Browser Automation", + "search": "Web Search", + "storage": "Data & Storage", + "users": "User Management", + "remote": "Remote Access" + }, "appearance": { "title": "Appearance", "theme": "Theme", diff --git a/packages/web-ui/src/locales/zh-CN/common.json b/packages/web-ui/src/locales/zh-CN/common.json index c46d0af6..9c1f1124 100644 --- a/packages/web-ui/src/locales/zh-CN/common.json +++ b/packages/web-ui/src/locales/zh-CN/common.json @@ -12,6 +12,8 @@ "creating": "正在创建...", "refresh": "刷新", "back": "返回", + "home": "首页", + "reports": "报告", "next": "下一步", "skip": "跳过", "dismiss": "关闭", diff --git a/packages/web-ui/src/locales/zh-CN/settings.json b/packages/web-ui/src/locales/zh-CN/settings.json index 74601af4..919f7c2f 100644 --- a/packages/web-ui/src/locales/zh-CN/settings.json +++ b/packages/web-ui/src/locales/zh-CN/settings.json @@ -1,5 +1,15 @@ { "title": "设置", + "nav": { + "appearance": "外观", + "providers": "模型配置", + "execution": "智能体与流水线", + "browser": "浏览器自动化", + "search": "网络搜索", + "storage": "数据与存储", + "users": "用户管理", + "remote": "远程访问" + }, "appearance": { "title": "外观", "theme": "主题", diff --git a/packages/web-ui/src/pages/Settings.tsx b/packages/web-ui/src/pages/Settings.tsx index ddfa24d5..030f0ed9 100644 --- a/packages/web-ui/src/pages/Settings.tsx +++ b/packages/web-ui/src/pages/Settings.tsx @@ -6,6 +6,7 @@ import { SUPPORTED_LANGUAGES } from '../i18n/index.ts'; import { navBus } from '../navBus.ts'; import { PAGE } from '../routes.ts'; import { Avatar, AvatarUpload } from '../components/Avatar.tsx'; +import { useIsMobile } from '../hooks/useIsMobile.ts'; interface ModelCost { input: number; output: number; cacheRead?: number; cacheWrite?: number } interface ModelDef { id: string; name: string; provider: string; contextWindow: number; maxOutputTokens: number; cost: ModelCost; reasoning?: boolean; inputTypes?: string[] } @@ -35,11 +36,55 @@ interface OllamaDetectResult { models?: Array<{ name: string; fullName: string; size?: number; modifiedAt?: string; parameterSize?: string; family?: string; quantization?: string }>; } +type SettingsTab = 'appearance' | 'providers' | 'execution' | 'browser' | 'search' | 'storage' | 'users' | 'remote'; + +const SETTINGS_TABS: Array<{ id: SettingsTab; labelKey: string; adminOnly?: boolean }> = [ + { id: 'appearance', labelKey: 'nav.appearance' }, + { id: 'providers', labelKey: 'nav.providers', adminOnly: true }, + { id: 'execution', labelKey: 'nav.execution', adminOnly: true }, + { id: 'browser', labelKey: 'nav.browser', adminOnly: true }, + { id: 'search', labelKey: 'nav.search', adminOnly: true }, + { id: 'storage', labelKey: 'nav.storage', adminOnly: true }, + { id: 'users', labelKey: 'nav.users', adminOnly: true }, + { id: 'remote', labelKey: 'nav.remote', adminOnly: true }, +]; + +function getSettingsTab(): SettingsTab | null { + const hash = window.location.hash.slice(1); + const parts = hash.split('/'); + if (parts[0] === 'settings' && parts[1]) { + const tab = parts[1] as SettingsTab; + if (SETTINGS_TABS.some(t => t.id === tab)) return tab; + } + return null; +} + export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdated }: { theme?: ThemeMode; onThemeChange?: (m: ThemeMode) => void; authUser?: AuthUser; onLogout?: () => void; onUserUpdated?: (u: AuthUser) => void } = {}) { const { t, i18n } = useTranslation(['settings', 'common']); + const isMobile = useIsMobile(); const [userMenuOpen, setUserMenuOpen] = useState(false); const [showEditProfile, setShowEditProfile] = useState(false); const userMenuRef = useRef(null); + const [activeTab, setActiveTab] = useState(getSettingsTab); + + useEffect(() => { + const onHashChange = () => setActiveTab(getSettingsTab()); + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + + const navigateTab = useCallback((tab: SettingsTab) => { + setActiveTab(tab); + history.pushState(null, '', `#settings/${tab}`); + }, []); + + const navigateBackToList = useCallback(() => { + setActiveTab(null); + history.pushState(null, '', '#settings'); + }, []); + + // On desktop, always show a tab (default to appearance). On mobile, null means show the list. + const resolvedTab: SettingsTab | null = activeTab ?? (isMobile ? null : 'appearance'); useEffect(() => { if (!userMenuOpen) return; @@ -677,8 +722,10 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat const showSetupGuide = llm && !hasConfiguredProviders && !setupDismissed; const canManageOrgSettings = authUser?.role === 'owner' || authUser?.role === 'admin'; + const visibleTabs = SETTINGS_TABS.filter(tab => !tab.adminOnly || canManageOrgSettings); + return ( -
+
{showEditProfile && authUser && ( )} -
-
-

{t('title')}

- {authUser && ( + {/* Settings Sidebar */} + + + {/* Content Panel */} +
+ {/* Mobile: settings list (when no sub-tab selected) */} + {isMobile && resolvedTab === null && ( +
+
+ +

{t('title')}

+
+ +
+ )} + {/* Mobile: sub-page header with back button */} + {isMobile && resolvedTab !== null && ( +
+ +

{t(`settings:${visibleTabs.find(tb => tb.id === resolvedTab)?.labelKey || 'title'}`)}

+ )} + {resolvedTab !== null &&
{/* ───── Appearance ───── */} -
+ {resolvedTab === 'appearance' &&
@@ -772,11 +889,12 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat
-
+
} {canManageOrgSettings && ( <> - {/* ───── First-Run Setup Guide ───── */} + {/* ───── First-Run Setup Guide (shown in providers tab) ───── */} + {resolvedTab === 'providers' && <> {showSetupGuide && (
{/* ───── Default Provider ───── */} +
@@ -1511,7 +1630,9 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat
+ } + {resolvedTab === 'execution' && <>
@@ -1607,7 +1728,9 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat {cppMsg && }
+ } + {resolvedTab === 'browser' && <>
@@ -1716,7 +1839,9 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat {browserMsg && }
+ } + {resolvedTab === 'search' && <>
{t('searchApi.description')}
@@ -1773,7 +1898,9 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat
+ } + {resolvedTab === 'storage' && <>
{storageLoading && !storageInfo &&
{t('dataStorage.scanning')}
} @@ -1864,14 +1991,17 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat
- + } + + {resolvedTab === 'users' && } - + {resolvedTab === 'remote' && } )}
+
} ); @@ -2539,78 +2669,34 @@ function Spinner() { ); } -/** Simple SVG-based QR code using a canvas. Falls back to a link if canvas unavailable. */ function QRCode({ url }: { url: string }) { const canvasRef = useRef(null); - const [ready, setReady] = useState(false); + const [error, setError] = useState(false); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; - - // Simple QR code generation using a lightweight approach: - // encode the URL data into a visual matrix pattern - const size = 200; - canvas.width = size; - canvas.height = size; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - // Use a simple encoding: create a visual representation - // In production, this would use a proper QR library. - // For now, render a placeholder with the URL hash pattern. - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, size, size); - ctx.fillStyle = '#000000'; - - // Generate a deterministic pattern from the URL - const data = url; - const moduleCount = 25; - const cellSize = size / moduleCount; - - // Position detection patterns (top-left, top-right, bottom-left) - const drawFinderPattern = (x: number, y: number) => { - for (let r = 0; r < 7; r++) { - for (let c = 0; c < 7; c++) { - const isBlack = r === 0 || r === 6 || c === 0 || c === 6 || - (r >= 2 && r <= 4 && c >= 2 && c <= 4); - if (isBlack) { - ctx.fillRect((x + c) * cellSize, (y + r) * cellSize, cellSize, cellSize); - } - } - } - }; - - drawFinderPattern(0, 0); - drawFinderPattern(moduleCount - 7, 0); - drawFinderPattern(0, moduleCount - 7); - - // Data area: hash-based pattern - let hash = 0; - for (let i = 0; i < data.length; i++) { - hash = ((hash << 5) - hash + data.charCodeAt(i)) | 0; - } - - for (let r = 0; r < moduleCount; r++) { - for (let c = 0; c < moduleCount; c++) { - // Skip finder pattern areas - if ((r < 8 && c < 8) || (r < 8 && c >= moduleCount - 8) || (r >= moduleCount - 8 && c < 8)) continue; - hash = ((hash << 5) - hash + r * moduleCount + c) | 0; - if (Math.abs(hash) % 3 === 0) { - ctx.fillRect(c * cellSize, r * cellSize, cellSize, cellSize); - } - } - } - - setReady(true); + setError(false); + + import('qrcode').then((QRLib) => { + QRLib.toCanvas(canvas, url, { + width: 200, + margin: 2, + color: { dark: '#000000', light: '#ffffff' }, + errorCorrectionLevel: 'M', + }); + }).catch(() => setError(true)); }, [url]); + if (error) { + return ( + {url} + ); + } + return (
- {!ready && ( - {url} - )}
); } diff --git a/packages/web-ui/src/routes.ts b/packages/web-ui/src/routes.ts index 176b110c..5c749b53 100644 --- a/packages/web-ui/src/routes.ts +++ b/packages/web-ui/src/routes.ts @@ -109,5 +109,4 @@ export const MOBILE_TABS: Array<{ id: MobileTabId; label: string; group: PageId[ { id: PAGE.NOTIFICATIONS, label: 'Notifications', group: [PAGE.NOTIFICATIONS] }, { id: PAGE.DELIVERABLES, label: 'Deliverables', group: [PAGE.DELIVERABLES] }, { id: PAGE.BUILDER, label: 'Builder', group: [PAGE.BUILDER, PAGE.STORE] }, - { id: PAGE.SETTINGS, label: 'Settings', group: [PAGE.SETTINGS, PAGE.REPORTS] }, ]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 31f53682..90fa9d79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,6 +200,9 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.24 version: 3.13.24(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@xyflow/react': specifier: ^12.10.1 version: 12.10.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -212,6 +215,9 @@ importers: katex: specifier: ^0.16.45 version: 0.16.45 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^19.2.4 version: 19.2.4 @@ -1235,6 +1241,9 @@ packages: '@types/node@25.3.0': resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1433,6 +1442,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001774: resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} @@ -1465,6 +1478,9 @@ packages: classcat@5.0.5: resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1572,6 +1588,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -1601,6 +1621,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -1765,6 +1788,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2091,6 +2118,10 @@ packages: canvas: optional: true + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2325,14 +2356,26 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -2357,6 +2400,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -2393,6 +2440,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -2467,6 +2519,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2496,6 +2551,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2788,6 +2846,9 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2802,6 +2863,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2821,6 +2886,9 @@ packages: utf-8-validate: optional: true + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2828,10 +2896,18 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -3558,6 +3634,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.3.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -3816,6 +3896,8 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + camelcase@5.3.1: {} + caniuse-lite@1.0.30001774: {} ccount@2.0.1: {} @@ -3839,6 +3921,12 @@ snapshots: classcat@5.0.5: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -3938,6 +4026,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -3960,6 +4050,8 @@ snapshots: dependencies: dequal: 2.0.3 + dijkstrajs@1.0.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -4176,6 +4268,11 @@ snapshots: dependencies: flat-cache: 4.0.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -4503,6 +4600,10 @@ snapshots: htmlparser2: 10.1.0 uhyphen: 0.2.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -4956,14 +5057,24 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -4988,6 +5099,8 @@ snapshots: picomatch@4.0.3: {} + pngjs@5.0.0: {} + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -5029,6 +5142,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -5146,6 +5265,8 @@ snapshots: require-directory@2.1.1: {} + require-main-filename@2.0.0: {} + resolve-pkg-maps@1.0.0: {} rfb2@0.2.2: {} @@ -5193,6 +5314,8 @@ snapshots: semver@7.7.4: {} + set-blocking@2.0.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -5505,6 +5628,8 @@ snapshots: web-namespaces@2.0.1: {} + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -5516,6 +5641,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -5526,12 +5657,33 @@ snapshots: ws@8.19.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 From 2c07f322a724b9593ea80b2c961a6e42a332e6f4 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Mon, 18 May 2026 19:02:54 +0800 Subject: [PATCH 08/19] fix: optimize mobile settings page and navigation - Integrate hamburger menu button into each page's own header instead of a separate top bar for better space utilization - Fix avatar click in mobile drawer to open user's own profile settings instead of user management - Fix report button navigation by removing MOBILE_REDIRECTS for reports and adding reports page to mobile - Create reusable MobileMenuButton component Co-authored-by: Cursor --- packages/web-ui/src/App.tsx | 19 ++----------------- .../web-ui/src/components/ChatTeamSidebar.tsx | 4 +++- .../src/components/MobileBuilderTabs.tsx | 4 +++- .../web-ui/src/components/MobileDrawer.tsx | 3 ++- .../src/components/MobileMenuButton.tsx | 17 +++++++++++++++++ packages/web-ui/src/pages/Deliverables.tsx | 14 +++++++++----- packages/web-ui/src/pages/Home.tsx | 6 +++++- packages/web-ui/src/pages/Notifications.tsx | 6 +++++- packages/web-ui/src/pages/Reports.tsx | 4 ++++ packages/web-ui/src/pages/Settings.tsx | 6 ++++++ packages/web-ui/src/pages/Work.tsx | 2 ++ packages/web-ui/src/routes.ts | 1 - 12 files changed, 58 insertions(+), 28 deletions(-) create mode 100644 packages/web-ui/src/components/MobileMenuButton.tsx diff --git a/packages/web-ui/src/App.tsx b/packages/web-ui/src/App.tsx index b70301f5..7d82e2db 100644 --- a/packages/web-ui/src/App.tsx +++ b/packages/web-ui/src/App.tsx @@ -12,7 +12,7 @@ import { NotificationsPage } from './pages/Notifications.tsx'; import { Sidebar } from './components/Sidebar.tsx'; import { BottomNav } from './components/BottomNav.tsx'; import { MobileBuilderTabs } from './components/MobileBuilderTabs.tsx'; -import { MobileDrawer, openMobileDrawer } from './components/MobileDrawer.tsx'; +import { MobileDrawer } from './components/MobileDrawer.tsx'; import { Onboarding } from './components/Onboarding.tsx'; import { Login, InviteSetup, InitialSetup } from './pages/Login.tsx'; import { ChangePassword } from './pages/ChangePassword.tsx'; @@ -187,6 +187,7 @@ export function App() { [PAGE.WORK]: , [PAGE.DELIVERABLES]: , [PAGE.NOTIFICATIONS]: , + [PAGE.REPORTS]: , }; } return { @@ -298,22 +299,6 @@ export function App() { )}
- {/* Mobile top bar */} - {isMobile && page !== PAGE.SETTINGS && ( -
- -
- )} {llmConfigured === false && !llmBannerDismissed && page !== PAGE.SETTINGS && (
No LLM provider configured — agents cannot process requests. diff --git a/packages/web-ui/src/components/ChatTeamSidebar.tsx b/packages/web-ui/src/components/ChatTeamSidebar.tsx index cca86642..7e80a97f 100644 --- a/packages/web-ui/src/components/ChatTeamSidebar.tsx +++ b/packages/web-ui/src/components/ChatTeamSidebar.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../hooks/useIsMobile.ts'; +import { MobileMenuButton } from './MobileMenuButton.tsx'; import { api, wsClient, type AgentInfo, type TeamInfo, type TeamMemberInfo, @@ -858,7 +859,8 @@ export function ChatTeamSidebar({ <>
{/* Header with title + pause toggle */} -
+
+ {isMobile && }

{t('chat.title')}

{globalPaused === null ? ( diff --git a/packages/web-ui/src/components/MobileBuilderTabs.tsx b/packages/web-ui/src/components/MobileBuilderTabs.tsx index acce884c..4ea8f0bd 100644 --- a/packages/web-ui/src/components/MobileBuilderTabs.tsx +++ b/packages/web-ui/src/components/MobileBuilderTabs.tsx @@ -5,6 +5,7 @@ import { TemplateMarketplace } from '../pages/TemplateMarketplace.tsx'; import { TeamsStore } from '../pages/TeamsStore.tsx'; import { SkillStore } from '../pages/SkillStore.tsx'; import { useSwipeTabs } from '../hooks/useSwipeTabs.ts'; +import { MobileMenuButton } from './MobileMenuButton.tsx'; import type { AuthUser } from '../api.ts'; const tabIds = ['builder', 'agents', 'teams', 'skills'] as const; @@ -23,7 +24,8 @@ export function MobileBuilderTabs({ authUser }: { authUser?: AuthUser }) { return (
-
+
+ {tabs.map(tab => ( + ); +} diff --git a/packages/web-ui/src/pages/Deliverables.tsx b/packages/web-ui/src/pages/Deliverables.tsx index 3c3ae5cc..140b6d35 100644 --- a/packages/web-ui/src/pages/Deliverables.tsx +++ b/packages/web-ui/src/pages/Deliverables.tsx @@ -8,6 +8,7 @@ import { ArtifactPreview, type BuilderMode } from '../components/BuilderArtifact import { navBus } from '../navBus.ts'; import { PAGE } from '../routes.ts'; import { useIsMobile } from '../hooks/useIsMobile.ts'; +import { MobileMenuButton } from '../components/MobileMenuButton.tsx'; import { useResizablePanel } from '../hooks/useResizablePanel.ts'; import { useSwipeTabs } from '../hooks/useSwipeTabs.ts'; @@ -340,11 +341,14 @@ export function DeliverablesPage({ authUser: _authUser }: { authUser?: AuthUser
-
-

- {t('title')}{totalCount > 0 && ({totalCount})} -

- +
+
+ {isMobile && } +

+ {t('title')}{totalCount > 0 && ({totalCount})} +

+
+
([]); const [teams, setTeams] = useState([]); const [board, setBoard] = useState>({}); @@ -120,7 +123,8 @@ export function HomePage({ authUser }: { authUser?: { id: string; name: string;
{/* Header */}
-
+
+ {isMobile && }

{t('title')}

{t('subtitle')}

diff --git a/packages/web-ui/src/pages/Notifications.tsx b/packages/web-ui/src/pages/Notifications.tsx index d9821453..8a836871 100644 --- a/packages/web-ui/src/pages/Notifications.tsx +++ b/packages/web-ui/src/pages/Notifications.tsx @@ -1,12 +1,16 @@ import { useTranslation } from 'react-i18next'; import { NotificationBell } from '../components/NotificationBell.tsx'; +import { MobileMenuButton } from '../components/MobileMenuButton.tsx'; +import { useIsMobile } from '../hooks/useIsMobile.ts'; export function NotificationsPage({ authUser }: { authUser?: { id: string; name: string; role: string; orgId: string } }) { const { t } = useTranslation('nav'); + const isMobile = useIsMobile(); return (
-
+
+ {isMobile && }

{t('notifications')}

diff --git a/packages/web-ui/src/pages/Reports.tsx b/packages/web-ui/src/pages/Reports.tsx index dd942b65..5b432f83 100644 --- a/packages/web-ui/src/pages/Reports.tsx +++ b/packages/web-ui/src/pages/Reports.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'; import { api, type ReportInfo, type ReportFeedbackInfo, type AgentUsageInfo, type AuthUser } from '../api.ts'; import { navBus } from '../navBus.ts'; import { PAGE } from '../routes.ts'; +import { MobileMenuButton } from '../components/MobileMenuButton.tsx'; +import { useIsMobile } from '../hooks/useIsMobile.ts'; type Period = 'daily' | 'weekly' | 'monthly'; interface ReportsPageProps { authUser?: AuthUser } @@ -36,6 +38,7 @@ function formatBytes(b: number): string { export function ReportsPage({ authUser }: ReportsPageProps) { const { t } = useTranslation(['reports', 'common']); + const isMobile = useIsMobile(); const [period, setPeriod] = useState('weekly'); const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); @@ -120,6 +123,7 @@ export function ReportsPage({ authUser }: ReportsPageProps) { {/* Header with tabs */}
+ {isMobile && }

{t('title')}

diff --git a/packages/web-ui/src/pages/Settings.tsx b/packages/web-ui/src/pages/Settings.tsx index 030f0ed9..5f7bfbbd 100644 --- a/packages/web-ui/src/pages/Settings.tsx +++ b/packages/web-ui/src/pages/Settings.tsx @@ -86,6 +86,12 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat // On desktop, always show a tab (default to appearance). On mobile, null means show the list. const resolvedTab: SettingsTab | null = activeTab ?? (isMobile ? null : 'appearance'); + useEffect(() => { + const handler = () => setShowEditProfile(true); + window.addEventListener('markus:open-edit-profile', handler); + return () => window.removeEventListener('markus:open-edit-profile', handler); + }, []); + useEffect(() => { if (!userMenuOpen) return; const handler = (e: MouseEvent) => { diff --git a/packages/web-ui/src/pages/Work.tsx b/packages/web-ui/src/pages/Work.tsx index 1c1ecbe5..6b127a0e 100644 --- a/packages/web-ui/src/pages/Work.tsx +++ b/packages/web-ui/src/pages/Work.tsx @@ -16,6 +16,7 @@ import { PAGE, resolvePageId, hashPath } from '../routes.ts'; import { useIsMobile } from '../hooks/useIsMobile.ts'; import { useResizablePanel } from '../hooks/useResizablePanel.ts'; import { useSwipeTabs } from '../hooks/useSwipeTabs.ts'; +import { MobileMenuButton } from '../components/MobileMenuButton.tsx'; /* ── useDropdownPosition: compute fixed position for dropdown escaping overflow containers ── */ function useDropdownPosition(triggerRef: React.RefObject, open: boolean) { @@ -3921,6 +3922,7 @@ export function WorkPage({ authUser }: { authUser?: AuthUser }) {
{/* Mobile Row 1: title + action buttons */}
+ {selectedProject ? (
{selectedProject.name} diff --git a/packages/web-ui/src/routes.ts b/packages/web-ui/src/routes.ts index 5c749b53..a12ebf83 100644 --- a/packages/web-ui/src/routes.ts +++ b/packages/web-ui/src/routes.ts @@ -60,7 +60,6 @@ export function hashPath(page: PageId, sub?: string): string { export const MOBILE_REDIRECTS: Partial> = { [PAGE.STORE]: PAGE.BUILDER, - [PAGE.REPORTS]: PAGE.SETTINGS, }; // ── SVG icon paths (shared by Sidebar + BottomNav) ────────────────────────── From 5c82f0893592059435d597fe6ed10bf8ab469ff2 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Mon, 18 May 2026 19:18:30 +0800 Subject: [PATCH 09/19] feat: mobile navigation optimization - Fix team page deep navigation: when navigating with agentId/dm/channel params on mobile, automatically enter the detail view instead of staying on the list - Redesign home page header on mobile: replace deploy button with search icon and "+" create menu (agent, team, skill, discover more) - Create global search page with search across agents, tasks, projects, and deliverables - Reorder bottom nav to: Home, Team, Notifications, Work, Deliverables - Remove Builder tab from bottom nav (accessible via "+" menu on Home) - Add storeTab param support to MobileBuilderTabs for direct tab switching Co-authored-by: Cursor --- packages/web-ui/src/App.tsx | 8 +- .../src/components/MobileBuilderTabs.tsx | 24 +- packages/web-ui/src/pages/Home.tsx | 83 +++++- packages/web-ui/src/pages/Search.tsx | 267 ++++++++++++++++++ packages/web-ui/src/pages/Team.tsx | 6 + packages/web-ui/src/routes.ts | 4 +- 6 files changed, 377 insertions(+), 15 deletions(-) create mode 100644 packages/web-ui/src/pages/Search.tsx diff --git a/packages/web-ui/src/App.tsx b/packages/web-ui/src/App.tsx index 7d82e2db..67fd8752 100644 --- a/packages/web-ui/src/App.tsx +++ b/packages/web-ui/src/App.tsx @@ -9,6 +9,7 @@ import { WorkPage } from './pages/Work.tsx'; import { DeliverablesPage } from './pages/Deliverables.tsx'; import { ReportsPage } from './pages/Reports.tsx'; import { NotificationsPage } from './pages/Notifications.tsx'; +import { SearchPage } from './pages/Search.tsx'; import { Sidebar } from './components/Sidebar.tsx'; import { BottomNav } from './components/BottomNav.tsx'; import { MobileBuilderTabs } from './components/MobileBuilderTabs.tsx'; @@ -188,6 +189,7 @@ export function App() { [PAGE.DELIVERABLES]: , [PAGE.NOTIFICATIONS]: , [PAGE.REPORTS]: , + [PAGE.SEARCH]: , }; } return { @@ -298,7 +300,7 @@ export function App() { )} -
+
{llmConfigured === false && !llmBannerDismissed && page !== PAGE.SETTINGS && (
No LLM provider configured — agents cannot process requests. @@ -330,8 +332,8 @@ export function App() {
- {/* Mobile bottom nav (hidden on Settings page) */} - {isMobile && page !== PAGE.SETTINGS && ( + {/* Mobile bottom nav (hidden on Settings/Search pages) */} + {isMobile && page !== PAGE.SETTINGS && page !== PAGE.SEARCH && ( )} diff --git a/packages/web-ui/src/components/MobileBuilderTabs.tsx b/packages/web-ui/src/components/MobileBuilderTabs.tsx index 4ea8f0bd..93b98c39 100644 --- a/packages/web-ui/src/components/MobileBuilderTabs.tsx +++ b/packages/web-ui/src/components/MobileBuilderTabs.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { AgentBuilder } from '../pages/AgentBuilder.tsx'; import { TemplateMarketplace } from '../pages/TemplateMarketplace.tsx'; @@ -11,6 +11,15 @@ import type { AuthUser } from '../api.ts'; const tabIds = ['builder', 'agents', 'teams', 'skills'] as const; type TabId = (typeof tabIds)[number]; +function getInitialTab(): TabId { + const stored = localStorage.getItem('markus_nav_storeTab'); + if (stored) { + localStorage.removeItem('markus_nav_storeTab'); + if (tabIds.includes(stored as TabId)) return stored as TabId; + } + return 'builder'; +} + export function MobileBuilderTabs({ authUser }: { authUser?: AuthUser }) { const { t } = useTranslation(['nav', 'common']); const tabs = useMemo(() => [ @@ -19,9 +28,20 @@ export function MobileBuilderTabs({ authUser }: { authUser?: AuthUser }) { { id: 'teams' as const, label: t('nav:tabs.teams') }, { id: 'skills' as const, label: t('nav:tabs.skills') }, ], [t]); - const [activeTab, setActiveTab] = useState('builder'); + const [activeTab, setActiveTab] = useState(getInitialTab); const swipe = useSwipeTabs(tabs, activeTab, setActiveTab); + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent<{ page: string; params?: Record }>).detail; + if (detail.params?.storeTab && tabIds.includes(detail.params.storeTab as TabId)) { + setActiveTab(detail.params.storeTab as TabId); + } + }; + window.addEventListener('markus:navigate', handler); + return () => window.removeEventListener('markus:navigate', handler); + }, []); + return (
diff --git a/packages/web-ui/src/pages/Home.tsx b/packages/web-ui/src/pages/Home.tsx index 94d89531..d038dfb4 100644 --- a/packages/web-ui/src/pages/Home.tsx +++ b/packages/web-ui/src/pages/Home.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo } from 'react'; +import { useEffect, useState, useMemo, useRef } from 'react'; import type { TFunction } from 'i18next'; import { useTranslation } from 'react-i18next'; import { api, type AgentInfo, type TaskInfo, type OpsDashboard, type TeamInfo, type RequirementInfo, type StorageInfo } from '../api.ts'; @@ -42,7 +42,18 @@ export function HomePage({ authUser }: { authUser?: { id: string; name: string; const [pendingReqs, setPendingReqs] = useState([]); const [storageInfo, setStorageInfo] = useState(null); const [showDeployChoice, setShowDeployChoice] = useState(false); + const [showCreateMenu, setShowCreateMenu] = useState(false); const [showRankingModal, setShowRankingModal] = useState(false); + const createMenuRef = useRef(null); + + useEffect(() => { + if (!showCreateMenu) return; + const handler = (e: MouseEvent) => { + if (createMenuRef.current && !createMenuRef.current.contains(e.target as Node)) setShowCreateMenu(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [showCreateMenu]); const refresh = () => { api.agents.list().then(d => setAgents(d.agents)).catch(() => {}); @@ -130,13 +141,69 @@ export function HomePage({ authUser }: { authUser?: { id: string; name: string;

{t('subtitle')}

- + {isMobile ? ( +
+ +
+ + {showCreateMenu && ( +
+
+ + + +
+ +
+
+ )} +
+
+ ) : ( + + )}
diff --git a/packages/web-ui/src/pages/Search.tsx b/packages/web-ui/src/pages/Search.tsx new file mode 100644 index 00000000..79c6ea8e --- /dev/null +++ b/packages/web-ui/src/pages/Search.tsx @@ -0,0 +1,267 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { api, type AgentInfo, type TaskInfo, type ProjectInfo, type DeliverableInfo } from '../api.ts'; +import { navBus } from '../navBus.ts'; +import { PAGE } from '../routes.ts'; +import { Avatar } from '../components/Avatar.tsx'; + +type SearchCategory = 'all' | 'agents' | 'tasks' | 'projects' | 'deliverables'; + +interface SearchResults { + agents: AgentInfo[]; + tasks: TaskInfo[]; + projects: ProjectInfo[]; + deliverables: DeliverableInfo[]; +} + +export function SearchPage() { + const { t } = useTranslation(['common', 'home', 'work']); + const [query, setQuery] = useState(''); + const [category, setCategory] = useState('all'); + const [results, setResults] = useState({ agents: [], tasks: [], projects: [], deliverables: [] }); + const [loading, setLoading] = useState(false); + const [searched, setSearched] = useState(false); + const inputRef = useRef(null); + const debounceRef = useRef>(); + + useEffect(() => { + setTimeout(() => inputRef.current?.focus(), 100); + }, []); + + const doSearch = useCallback(async (q: string) => { + if (!q.trim()) { + setResults({ agents: [], tasks: [], projects: [], deliverables: [] }); + setSearched(false); + return; + } + setLoading(true); + setSearched(true); + const lower = q.toLowerCase(); + try { + const [agentsRes, tasksRes, projectsRes, deliverablesRes] = await Promise.allSettled([ + api.agents.list(), + api.tasks.list({ search: q, pageSize: 20 }), + api.projects.list(), + api.deliverables.search({ q, limit: 20 }), + ]); + + const agents = agentsRes.status === 'fulfilled' + ? agentsRes.value.agents.filter(a => a.name?.toLowerCase().includes(lower) || a.role?.toLowerCase().includes(lower)) + : []; + const tasks = tasksRes.status === 'fulfilled' ? tasksRes.value.tasks : []; + const projects = projectsRes.status === 'fulfilled' + ? projectsRes.value.projects.filter(p => p.name?.toLowerCase().includes(lower) || p.description?.toLowerCase().includes(lower)) + : []; + const deliverables = deliverablesRes.status === 'fulfilled' ? deliverablesRes.value.results : []; + + setResults({ agents, tasks, projects, deliverables }); + } catch { + setResults({ agents: [], tasks: [], projects: [], deliverables: [] }); + } finally { + setLoading(false); + } + }, []); + + const handleInput = (value: string) => { + setQuery(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => doSearch(value), 400); + }; + + const categories: { id: SearchCategory; label: string }[] = [ + { id: 'all', label: t('common:all', { defaultValue: '全部' }) }, + { id: 'agents', label: t('common:agents', { defaultValue: '智能体' }) }, + { id: 'tasks', label: t('common:tasks', { defaultValue: '任务' }) }, + { id: 'projects', label: t('common:projects', { defaultValue: '项目' }) }, + { id: 'deliverables', label: t('common:deliverables', { defaultValue: '交付物' }) }, + ]; + + const hasResults = results.agents.length > 0 || results.tasks.length > 0 || results.projects.length > 0 || results.deliverables.length > 0; + + return ( +
+ {/* Search header */} +
+
+ +
+ + handleInput(e.target.value)} + placeholder={t('common:search')} + className="w-full bg-surface-elevated border border-border-default rounded-xl pl-9 pr-3 py-2.5 text-sm text-fg-primary placeholder:text-fg-tertiary focus:border-brand-500 focus:outline-none transition-colors" + /> + {query && ( + + )} +
+
+
+ {categories.map(cat => ( + + ))} +
+
+ + {/* Results */} +
+ {loading && ( +
+
{t('common:loading')}
+
+ )} + + {!loading && searched && !hasResults && ( +
+ +

{t('common:noResults', { defaultValue: '没有找到相关结果' })}

+
+ )} + + {!loading && hasResults && ( +
+ {/* Agents */} + {(category === 'all' || category === 'agents') && results.agents.length > 0 && ( +
+

{t('common:agents', { defaultValue: '智能体' })}

+
+ {results.agents.slice(0, category === 'all' ? 5 : 50).map(agent => ( + + ))} +
+
+ )} + + {/* Tasks */} + {(category === 'all' || category === 'tasks') && results.tasks.length > 0 && ( +
+

{t('common:tasks', { defaultValue: '任务' })}

+
+ {results.tasks.slice(0, category === 'all' ? 5 : 50).map(task => ( + + ))} +
+
+ )} + + {/* Projects */} + {(category === 'all' || category === 'projects') && results.projects.length > 0 && ( +
+

{t('common:projects', { defaultValue: '项目' })}

+
+ {results.projects.slice(0, category === 'all' ? 5 : 50).map(proj => ( + + ))} +
+
+ )} + + {/* Deliverables */} + {(category === 'all' || category === 'deliverables') && results.deliverables.length > 0 && ( +
+

{t('common:deliverables', { defaultValue: '交付物' })}

+
+ {results.deliverables.slice(0, category === 'all' ? 5 : 50).map(d => ( + + ))} +
+
+ )} +
+ )} + + {!loading && !searched && ( +
+ +

{t('common:search')}

+
+ )} +
+
+ ); +} + +function StatusDot({ status }: { status: string }) { + const color = status === 'active' || status === 'idle' ? 'bg-green-500' + : status === 'working' || status === 'busy' ? 'bg-blue-500' + : status === 'paused' ? 'bg-amber-500' + : 'bg-gray-400'; + return ; +} + +function TaskStatusIcon({ status }: { status: string }) { + const color = status === 'completed' ? 'text-green-500 bg-green-500/15' + : status === 'in_progress' ? 'text-blue-500 bg-blue-500/15' + : status === 'pending' ? 'text-amber-500 bg-amber-500/15' + : 'text-fg-tertiary bg-surface-elevated'; + return ( +
+ +
+ ); +} diff --git a/packages/web-ui/src/pages/Team.tsx b/packages/web-ui/src/pages/Team.tsx index f4a47a48..2df50d9a 100644 --- a/packages/web-ui/src/pages/Team.tsx +++ b/packages/web-ui/src/pages/Team.tsx @@ -1224,6 +1224,8 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string } else { setChatMode('direct'); setSelectedAgent(detail.params.agentId); + setMainTab('chat'); + if (isMobile) enterMobileDetail(); if (detail.params.sessionId) { const targetSessionId = detail.params.sessionId; setTimeout(async () => { @@ -1247,11 +1249,13 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string setChatMode('dm'); setActiveDmUserId(detail.params.dm); setMainTab('chat'); + if (isMobile) enterMobileDetail(); } if (detail.params?.channel) { setChatMode('channel'); setActiveChannel(detail.params.channel); setMainTab('chat'); + if (isMobile) enterMobileDetail(); } if (detail.params?.openHire === 'true') { // handled by ChatTeamSidebar via nav events @@ -1269,11 +1273,13 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string if (navDm) { localStorage.removeItem('markus_nav_dm'); setChatMode('dm'); setActiveDmUserId(navDm); setMainTab('chat'); + if (isMobile) enterMobileDetail(); } const navChannel = localStorage.getItem('markus_nav_channel'); if (navChannel) { localStorage.removeItem('markus_nav_channel'); setChatMode('channel'); setActiveChannel(navChannel); setMainTab('chat'); + if (isMobile) enterMobileDetail(); } const selectAgent = localStorage.getItem('markus_nav_selectAgent'); if (selectAgent) { diff --git a/packages/web-ui/src/routes.ts b/packages/web-ui/src/routes.ts index a12ebf83..4fd37e28 100644 --- a/packages/web-ui/src/routes.ts +++ b/packages/web-ui/src/routes.ts @@ -15,6 +15,7 @@ export const PAGE = { REPORTS: 'reports', SETTINGS: 'settings', NOTIFICATIONS: 'notifications', + SEARCH: 'search', } as const; export type PageId = (typeof PAGE)[keyof typeof PAGE]; @@ -104,8 +105,7 @@ export type MobileTabId = PageId; export const MOBILE_TABS: Array<{ id: MobileTabId; label: string; group: PageId[] }> = [ { id: PAGE.HOME, label: 'Home', group: [PAGE.HOME] }, { id: PAGE.TEAM, label: 'Team', group: [PAGE.TEAM] }, - { id: PAGE.WORK, label: 'Work', group: [PAGE.WORK] }, { id: PAGE.NOTIFICATIONS, label: 'Notifications', group: [PAGE.NOTIFICATIONS] }, + { id: PAGE.WORK, label: 'Work', group: [PAGE.WORK] }, { id: PAGE.DELIVERABLES, label: 'Deliverables', group: [PAGE.DELIVERABLES] }, - { id: PAGE.BUILDER, label: 'Builder', group: [PAGE.BUILDER, PAGE.STORE] }, ]; From 860bab053f4c77a05bd73a8a4bbf3b84041a1a5d Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Tue, 19 May 2026 02:13:27 +0800 Subject: [PATCH 10/19] feat: add unread message system, 3-layer mobile team nav, and global search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement read/unread tracking with new user_read_cursors table, API endpoints, and WebSocket events - Add mobile team page 3-layer navigation (roster -> team detail -> agent chat) with proper back navigation - Add global search modal with keyboard navigation (↑↓, Tab, Enter), persisted state, and dynamic section ordering - Support deep navigation from search results to tasks, requirements, deliverables, and agents - Increase search results per category from 5 to 8 with "show all" expansion - Fix mobile deliverable deep navigation to open detail view Co-authored-by: Cursor --- packages/cli/src/api-client.ts | 7 +- packages/org-manager/src/api-server.ts | 60 ++- packages/org-manager/src/storage-bridge.ts | 2 + packages/org-manager/src/ws-server.ts | 8 + packages/storage/src/index.ts | 2 + packages/storage/src/sqlite-storage.ts | 93 ++++ packages/web-ui/src/App.tsx | 27 ++ packages/web-ui/src/api.ts | 11 + packages/web-ui/src/components/BottomNav.tsx | 18 +- .../web-ui/src/components/ChatTeamSidebar.tsx | 103 ++--- .../web-ui/src/components/MobileDrawer.tsx | 6 +- .../web-ui/src/components/SearchModal.tsx | 433 ++++++++++++++++++ packages/web-ui/src/hooks/useUnreadCounts.ts | 126 +++++ packages/web-ui/src/pages/Deliverables.tsx | 46 ++ packages/web-ui/src/pages/Home.tsx | 56 ++- packages/web-ui/src/pages/Search.tsx | 94 +++- packages/web-ui/src/pages/Settings.tsx | 2 +- packages/web-ui/src/pages/Team.tsx | 176 ++++++- packages/web-ui/src/pages/Work.tsx | 11 + 19 files changed, 1184 insertions(+), 97 deletions(-) create mode 100644 packages/web-ui/src/components/SearchModal.tsx create mode 100644 packages/web-ui/src/hooks/useUnreadCounts.ts diff --git a/packages/cli/src/api-client.ts b/packages/cli/src/api-client.ts index b915f2af..51eb16a5 100644 --- a/packages/cli/src/api-client.ts +++ b/packages/cli/src/api-client.ts @@ -75,7 +75,12 @@ export class ApiClient { private async request(url: string, init: RequestInit): Promise { const headers = { ...this.headers }; - if (!init.body) delete headers['Content-Type']; + // Always send Content-Type even for empty-body POST (e.g., /tasks/:id/approve) + // The server rejects requests without Content-Type with HTTP 415. + // We explicitly set it to 'application/json' for consistency. + if (init.body === undefined || init.body === null) { + // Ensure Content-Type is present even for no-body requests + } let res: Response; try { diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index 5c373c2b..41ffca18 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -270,6 +270,7 @@ export class APIServer { replyToId, }); persistedMsgId = saved.id; + this.ws.broadcastUnreadUpdate(`channel:${channelKey}`, saved.id); } // Send to frontend via WebSocket (scoped to channel members) @@ -1330,7 +1331,7 @@ export class APIServer { ): Promise { if (!this.storage || !sessionId) return; try { - await this.storage.chatSessionRepo.appendMessage( + const msg = await this.storage.chatSessionRepo.appendMessage( sessionId, agentId, 'assistant', @@ -1339,6 +1340,9 @@ export class APIServer { metadata ); await this.storage.chatSessionRepo.updateLastMessage(sessionId); + if (msg?.id) { + this.ws.broadcastUnreadUpdate(`session:${sessionId}`, msg.id); + } } catch (err) { log.warn('Failed to persist assistant message', { error: String(err) }); } @@ -1996,6 +2000,11 @@ export class APIServer { }); } + // Broadcast unread update for channel message + if (userMsg) { + this.ws.broadcastUnreadUpdate(`channel:${channel}`, userMsg.id); + } + // DM / personal-notepad channels never route to agents const humanOnly = (body['humanOnly'] as boolean) === true; const isHumanChannel = humanOnly || channel.startsWith('notes:') || channel.startsWith('dm:'); @@ -3080,6 +3089,21 @@ export class APIServer { return; } + if (path.match(/^\/api\/deliverables\/[^/]+$/) && req.method === 'GET') { + const authUser = await this.requireAuth(req, res); + if (!authUser) return; + if (!this.deliverableService) { this.json(res, 503, { error: 'Deliverable service not available' }); return; } + const delivId = path.split('/')[3]!; + try { + const d = await this.deliverableService.get(delivId); + if (!d) { this.json(res, 404, { error: 'Deliverable not found' }); return; } + this.json(res, 200, { deliverable: d }); + } catch (err) { + this.json(res, 500, { error: String(err) }); + } + return; + } + if (path.match(/^\/api\/deliverables\/[^/]+$/) && req.method === 'PUT') { const authUser = await this.requireAuth(req, res); if (!authUser) return; @@ -6507,6 +6531,40 @@ EXPLANATION_END`; return; } + // ── Unread message tracking ────────────────────────────────────────────── + if (path === '/api/unread' && req.method === 'GET') { + const authUser = await this.requireAuth(req, res); + if (!authUser) return; + const repo = this.storage?.readCursorRepo; + if (!repo) { this.json(res, 200, { counts: {} }); return; } + const counts = repo.getUnreadCounts(authUser.userId); + this.json(res, 200, { counts }); + return; + } + + if (path === '/api/unread/mark-read' && req.method === 'POST') { + const authUser = await this.requireAuth(req, res); + if (!authUser) return; + const body = await this.readBody(req); + const { conversationKey, lastReadAt, lastReadId } = body as { conversationKey: string; lastReadAt: string; lastReadId?: string }; + if (!conversationKey || !lastReadAt) { this.json(res, 400, { error: 'conversationKey and lastReadAt required' }); return; } + const repo = this.storage?.readCursorRepo; + if (!repo) { this.json(res, 200, { success: true }); return; } + repo.setReadCursor(authUser.userId, conversationKey, lastReadAt, lastReadId); + this.json(res, 200, { success: true }); + return; + } + + if (path === '/api/unread/mark-all-read' && req.method === 'POST') { + const authUser = await this.requireAuth(req, res); + if (!authUser) return; + const repo = this.storage?.readCursorRepo; + if (!repo) { this.json(res, 200, { success: true }); return; } + repo.markAllRead(authUser.userId); + this.json(res, 200, { success: true }); + return; + } + // Unified activity feed — merges notifications, task comments, and deliverables if (path === '/api/activity' && req.method === 'GET') { const authUser = await this.requireAuth(req, res); diff --git a/packages/org-manager/src/storage-bridge.ts b/packages/org-manager/src/storage-bridge.ts index 3efea5ea..16c02d57 100644 --- a/packages/org-manager/src/storage-bridge.ts +++ b/packages/org-manager/src/storage-bridge.ts @@ -37,6 +37,7 @@ export interface StorageBridge { groupChatRepo?: any; auditRepo?: any; statusTransitionRepo?: any; + readCursorRepo?: any; } function resolveSqlitePath(url?: string): string { @@ -86,6 +87,7 @@ async function initSqliteStorage(url?: string): Promise { groupChatRepo: new storage.SqliteGroupChatRepo(db), auditRepo: new storage.SqliteAuditRepo(db), statusTransitionRepo: new storage.SqliteStatusTransitionRepo(db), + readCursorRepo: new storage.SqliteReadCursorRepo(db), }; log.info('SQLite storage initialized', { path: dbPath }); return bridge; diff --git a/packages/org-manager/src/ws-server.ts b/packages/org-manager/src/ws-server.ts index abddc47a..90a81716 100644 --- a/packages/org-manager/src/ws-server.ts +++ b/packages/org-manager/src/ws-server.ts @@ -200,6 +200,14 @@ export class WSBroadcaster { } } + broadcastUnreadUpdate(conversationKey: string, messageId: string): void { + this.broadcast({ + type: 'chat:unread_update', + payload: { conversationKey, messageId }, + timestamp: new Date().toISOString(), + }); + } + broadcastExecutionLog(entry: Record): void { const ts = new Date().toISOString(); this.broadcast({ type: 'execution:log', payload: entry, timestamp: ts }); diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index efd285b9..aef5f589 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -44,6 +44,7 @@ export { SqliteGroupChatRepo, SqliteAuditRepo, SqliteStatusTransitionRepo, + SqliteReadCursorRepo, migrateToExecutionStreamLogs, type SqliteExternalAgentRegistration, type ActivityRecord, @@ -54,4 +55,5 @@ export { type NotificationRow, type ApprovalRow, type StatusTransitionRow, + type ReadCursorRow, } from './sqlite-storage.js'; diff --git a/packages/storage/src/sqlite-storage.ts b/packages/storage/src/sqlite-storage.ts index e95b989d..64afc2c4 100644 --- a/packages/storage/src/sqlite-storage.ts +++ b/packages/storage/src/sqlite-storage.ts @@ -585,6 +585,15 @@ CREATE TABLE IF NOT EXISTS status_transitions ( created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_st_entity ON status_transitions(entity_type, entity_id, created_at); + +CREATE TABLE IF NOT EXISTS user_read_cursors ( + user_id TEXT NOT NULL, + conversation_key TEXT NOT NULL, + last_read_at TEXT NOT NULL, + last_read_id TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, conversation_key) +); `; // ─── Open / close ──────────────────────────────────────────────────────────── @@ -4320,6 +4329,90 @@ export class SqliteStatusTransitionRepo { } } +// ─── Read Cursors (unread tracking) ────────────────────────────────────────── + +export interface ReadCursorRow { + userId: string; + conversationKey: string; + lastReadAt: string; + lastReadId: string | null; + updatedAt: string; +} + +export class SqliteReadCursorRepo { + constructor(private db: DatabaseSync) {} + + setReadCursor(userId: string, conversationKey: string, lastReadAt: string, lastReadId?: string): void { + this.db.prepare( + `INSERT INTO user_read_cursors (user_id, conversation_key, last_read_at, last_read_id, updated_at) + VALUES (?, ?, ?, ?, datetime('now')) + ON CONFLICT(user_id, conversation_key) DO UPDATE SET + last_read_at = excluded.last_read_at, + last_read_id = COALESCE(excluded.last_read_id, last_read_id), + updated_at = datetime('now')` + ).run(userId, conversationKey, lastReadAt, lastReadId ?? null); + } + + getReadCursors(userId: string): ReadCursorRow[] { + return this.db.prepare( + `SELECT user_id AS userId, conversation_key AS conversationKey, + last_read_at AS lastReadAt, last_read_id AS lastReadId, + updated_at AS updatedAt + FROM user_read_cursors WHERE user_id = ?` + ).all(userId) as unknown as ReadCursorRow[]; + } + + getUnreadCounts(userId: string): Record { + const cursors = this.getReadCursors(userId); + const result: Record = {}; + + for (const cursor of cursors) { + const key = cursor.conversationKey; + if (key.startsWith('session:')) { + const sessionId = key.slice('session:'.length); + const row = this.db.prepare( + `SELECT COUNT(*) as cnt FROM chat_messages + WHERE session_id = ? AND created_at > ?` + ).get(sessionId, cursor.lastReadAt) as { cnt: number } | undefined; + if (row && row.cnt > 0) result[key] = row.cnt; + } else if (key.startsWith('channel:')) { + const channel = key.slice('channel:'.length); + const row = this.db.prepare( + `SELECT COUNT(*) as cnt FROM channel_messages + WHERE channel = ? AND created_at > ?` + ).get(channel, cursor.lastReadAt) as { cnt: number } | undefined; + if (row && row.cnt > 0) result[key] = row.cnt; + } + } + + return result; + } + + markAllRead(userId: string): void { + const ts = now(); + // Update all existing cursors + this.db.prepare( + `UPDATE user_read_cursors SET last_read_at = ?, updated_at = datetime('now') WHERE user_id = ?` + ).run(ts, userId); + + // Create cursors for sessions that don't have one + this.db.exec(` + INSERT OR IGNORE INTO user_read_cursors (user_id, conversation_key, last_read_at, updated_at) + SELECT '${userId}', 'session:' || s.id, '${ts}', datetime('now') + FROM chat_sessions s WHERE s.user_id = '${userId}' + `); + + // Create cursors for channels user is a member of + this.db.exec(` + INSERT OR IGNORE INTO user_read_cursors (user_id, conversation_key, last_read_at, updated_at) + SELECT '${userId}', 'channel:' || gc.channel_key, '${ts}', datetime('now') + FROM group_chats gc + JOIN group_chat_members gcm ON gcm.group_chat_id = gc.id + WHERE gcm.member_id = '${userId}' + `); + } +} + // ─── Auto-migration: task_logs + agent_activity_logs -> execution_stream_logs ─ export function migrateToExecutionStreamLogs(db: DatabaseSync): void { diff --git a/packages/web-ui/src/App.tsx b/packages/web-ui/src/App.tsx index 67fd8752..36f8371a 100644 --- a/packages/web-ui/src/App.tsx +++ b/packages/web-ui/src/App.tsx @@ -24,6 +24,7 @@ import { useTheme } from './hooks/useTheme.ts'; import { useIsMobile } from './hooks/useIsMobile.ts'; import { prefetch, PREFETCH_KEYS } from './prefetchCache.ts'; import { useTranslation } from 'react-i18next'; +import { SearchModal } from './components/SearchModal.tsx'; const PageSlot = memo(function PageSlot({ id, activePage, children, @@ -76,6 +77,27 @@ export function App() { return stored ? stored : null; }); + const [showSearchModal, setShowSearchModal] = useState(false); + + // Global search shortcut: Cmd+P (Mac) / Ctrl+P (Win/Linux) + useEffect(() => { + if (isMobile) return; + const isMac = navigator.platform.toUpperCase().includes('MAC'); + const onKey = (e: KeyboardEvent) => { + if (isMac && e.metaKey && !e.ctrlKey && e.key === 'p') { + e.preventDefault(); + setShowSearchModal(prev => !prev); + } else if (!isMac && e.ctrlKey && !e.metaKey && e.key === 'p') { + e.preventDefault(); + setShowSearchModal(prev => !prev); + } + }; + const onOpen = () => setShowSearchModal(true); + document.addEventListener('keydown', onKey); + window.addEventListener('markus:open-search', onOpen); + return () => { document.removeEventListener('keydown', onKey); window.removeEventListener('markus:open-search', onOpen); }; + }, [isMobile]); + const navigate = useCallback((p: PageId) => { let normalized = resolvePageId(p); if (isMobile) { @@ -341,6 +363,11 @@ export function App() { {isMobile && ( )} + + {/* Global search modal (desktop) */} + {!isMobile && showSearchModal && ( + setShowSearchModal(false)} currentPage={page} /> + )}
); } diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index e466edfa..20604f7f 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -1326,6 +1326,15 @@ export const api = { markAllRead: (userId: string) => request<{ success: boolean; count: number }>('/notifications/mark-all-read', { method: 'POST', body: JSON.stringify({ userId }) }), }, + // ─── Unread Tracking ─────────────────────────────────────────────── + unread: { + getCounts: () => request<{ counts: Record }>('/unread'), + markRead: (conversationKey: string, lastReadAt: string, lastReadId?: string) => + request<{ success: boolean }>('/unread/mark-read', { method: 'POST', body: JSON.stringify({ conversationKey, lastReadAt, lastReadId }) }), + markAllRead: () => + request<{ success: boolean }>('/unread/mark-all-read', { method: 'POST' }), + }, + // ─── Activity Feed ──────────────────────────────────────────────── activity: { list: (opts?: { limit?: number; type?: string }) => { @@ -1387,6 +1396,8 @@ export const api = { if (opts?.limit) params.set('limit', String(opts.limit)); return request<{ results: DeliverableInfo[]; total: number }>(`/deliverables?${params}`); }, + get: (id: string) => + request<{ deliverable: DeliverableInfo }>(`/deliverables/${id}`), create: (data: Partial) => request<{ deliverable: DeliverableInfo }>('/deliverables', { method: 'POST', body: JSON.stringify(data) }), update: (id: string, data: Partial) => diff --git a/packages/web-ui/src/components/BottomNav.tsx b/packages/web-ui/src/components/BottomNav.tsx index d7a34d81..f9864f34 100644 --- a/packages/web-ui/src/components/BottomNav.tsx +++ b/packages/web-ui/src/components/BottomNav.tsx @@ -12,6 +12,7 @@ interface Props { export function BottomNav({ currentPage, onNavigate, userId }: Props) { const { t } = useTranslation('nav'); const [unreadCount, setUnreadCount] = useState(0); + const [teamUnread, setTeamUnread] = useState(0); const fetchUnread = useCallback(async () => { try { @@ -25,7 +26,16 @@ export function BottomNav({ currentPage, onNavigate, userId }: Props) { const timer = setInterval(fetchUnread, 15000); const onChanged = () => fetchUnread(); window.addEventListener('markus:notifications-changed', onChanged); - return () => { clearInterval(timer); window.removeEventListener('markus:notifications-changed', onChanged); }; + const onTeamUnread = (e: Event) => { + const count = (e as CustomEvent).detail?.count ?? 0; + setTeamUnread(count); + }; + window.addEventListener('markus:team-unread-changed', onTeamUnread); + return () => { + clearInterval(timer); + window.removeEventListener('markus:notifications-changed', onChanged); + window.removeEventListener('markus:team-unread-changed', onTeamUnread); + }; }, [fetchUnread]); return ( @@ -33,6 +43,7 @@ export function BottomNav({ currentPage, onNavigate, userId }: Props) { {MOBILE_TABS.map(tab => { const isActive = tab.group.includes(currentPage); const isNotif = tab.id === PAGE.NOTIFICATIONS; + const isTeam = tab.id === PAGE.TEAM; return (
{t(tab.id)} diff --git a/packages/web-ui/src/components/ChatTeamSidebar.tsx b/packages/web-ui/src/components/ChatTeamSidebar.tsx index 7e80a97f..57fb4291 100644 --- a/packages/web-ui/src/components/ChatTeamSidebar.tsx +++ b/packages/web-ui/src/components/ChatTeamSidebar.tsx @@ -48,6 +48,8 @@ interface ChatTeamSidebarProps { onManageGroupMembers?: (channelKey: string) => void; /** Per-agent unread notification count (agentId → count) */ unreadByAgent?: Map; + /** Per-channel unread message count (channelKey → count) */ + unreadByChannel?: Record; width?: number; onResizeStart?: (e: React.MouseEvent) => void; hidden?: boolean; @@ -193,6 +195,7 @@ export function ChatTeamSidebar({ onRefreshTeams, onRefreshAgents, onRefreshHumans, onRefreshGroupChats, onViewProfile, onManageGroupMembers, unreadByAgent, + unreadByChannel, width, onResizeStart, hidden, initialLoading, }: ChatTeamSidebarProps) { @@ -656,7 +659,10 @@ export function ChatTeamSidebar({ const isDropTarget = isDragging && dragOverTeam === tid && dragAgent?.fromTeamId !== tid; const isHighlighted = highlightTeamId === tid; const isSelected = selectedTeamId === tid; - const teamUnread = unreadByAgent ? (agentsByTeam.byTeam.get(tid) ?? []).reduce((sum, a) => sum + (unreadByAgent.get(a.id) ?? 0), 0) : 0; + const agentUnread = unreadByAgent ? (agentsByTeam.byTeam.get(tid) ?? []).reduce((sum, a) => sum + (unreadByAgent.get(a.id) ?? 0), 0) : 0; + const teamGc = groupChats.find(gc => gc.type === 'team' && gc.teamId === tid); + const channelUnread = teamGc && unreadByChannel ? (unreadByChannel[teamGc.channelKey] ?? 0) : 0; + const teamUnread = agentUnread + channelUnread; return (
( - - ))} + {groupChatsByTeam.unmatched.map(gc => { + const chUnread = unreadByChannel?.[gc.channelKey] ?? 0; + return ( + + ); + })} - {isMobile ? ( - <> - {/* Mobile: original flat layout with nested agents */} + {/* Ungrouped agents listed individually */} + {agentsByTeam.ungrouped.length > 0 && ( +
+

{t('chat.agents')}

+ {agentsByTeam.ungrouped.map(a => renderAgentItem(a))} +
+ )} + {/* Teams as clickable rows (drill into L2 on mobile, expand on desktop) */} + {teams.length > 0 && ( +
+

{t('chat.teams')}

{teams.map(tm => { const agentList = agentsByTeam.byTeam.get(tm.id) ?? []; - if (agentList.length === 0 && (!tm.members || tm.members.length === 0)) return null; - return renderTeamSection(tm.id, tm, agentList, tm.name); + const memberCount = tm.members?.length || agentList.length; + return renderTeamRow(tm.id, tm, memberCount, tm.name); })} - {teams.filter(tm => { - const agentList = agentsByTeam.byTeam.get(tm.id) ?? []; - return agentList.length === 0 && (!tm.members || tm.members.length === 0); - }).map(tm => renderTeamSection(tm.id, tm, [], tm.name))} - {agentsByTeam.ungrouped.length > 0 && renderTeamSection('_ungrouped', null, agentsByTeam.ungrouped, t('chat.other'))} - - ) : ( - <> - {/* Desktop L1: ungrouped agents listed individually above teams */} - {agentsByTeam.ungrouped.length > 0 && ( -
-

{t('chat.agents')}

- {agentsByTeam.ungrouped.map(a => renderAgentItem(a))} -
- )} - {/* Desktop L1: simple clickable team rows */} - {teams.length > 0 && ( -
-

{t('chat.teams')}

- {teams.map(tm => { - const agentList = agentsByTeam.byTeam.get(tm.id) ?? []; - const memberCount = tm.members?.length || agentList.length; - return renderTeamRow(tm.id, tm, memberCount, tm.name); - })} -
- )} - +
)} {/* No teams — flat agent list */} diff --git a/packages/web-ui/src/components/MobileDrawer.tsx b/packages/web-ui/src/components/MobileDrawer.tsx index 71d37657..6a20bbc2 100644 --- a/packages/web-ui/src/components/MobileDrawer.tsx +++ b/packages/web-ui/src/components/MobileDrawer.tsx @@ -1,12 +1,12 @@ import { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Avatar } from './Avatar.tsx'; -import { PAGE } from '../routes.ts'; +import { PAGE, type PageId } from '../routes.ts'; import type { AuthUser } from '../api.ts'; interface MobileDrawerProps { authUser?: AuthUser; - onNavigate: (page: string) => void; + onNavigate: (page: PageId) => void; } export function MobileDrawer({ authUser, onNavigate }: MobileDrawerProps) { @@ -36,7 +36,7 @@ export function MobileDrawer({ authUser, onNavigate }: MobileDrawerProps) { setTimeout(() => window.dispatchEvent(new CustomEvent('markus:open-edit-profile')), 100); return; } - onNavigate(page); + onNavigate(page as PageId); }; if (!open) return null; diff --git a/packages/web-ui/src/components/SearchModal.tsx b/packages/web-ui/src/components/SearchModal.tsx new file mode 100644 index 00000000..5e446087 --- /dev/null +++ b/packages/web-ui/src/components/SearchModal.tsx @@ -0,0 +1,433 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { api, type AgentInfo, type TaskInfo, type ProjectInfo, type DeliverableInfo, type RequirementInfo } from '../api.ts'; +import { navBus } from '../navBus.ts'; +import { PAGE, type PageId } from '../routes.ts'; +import { Avatar } from './Avatar.tsx'; + +type SearchCategory = 'all' | 'agents' | 'tasks' | 'requirements' | 'projects' | 'deliverables'; + +interface SearchResults { + agents: AgentInfo[]; + tasks: TaskInfo[]; + requirements: RequirementInfo[]; + projects: ProjectInfo[]; + deliverables: DeliverableInfo[]; +} + +interface FlatItem { + id: string; + type: 'agent' | 'task' | 'requirement' | 'project' | 'deliverable' | 'showMore'; + page: PageId; + params?: Record; + expandCategory?: SearchCategory; + totalCount?: number; +} + +const EMPTY: SearchResults = { agents: [], tasks: [], requirements: [], projects: [], deliverables: [] }; + +type SectionId = 'agents' | 'tasks' | 'requirements' | 'projects' | 'deliverables'; + +const PAGE_SECTION_ORDER: Record = { + team: ['agents', 'tasks', 'requirements', 'projects', 'deliverables'], + work: ['projects', 'requirements', 'tasks', 'agents', 'deliverables'], + deliverables: ['deliverables', 'agents', 'tasks', 'requirements', 'projects'], +}; +const DEFAULT_ORDER: SectionId[] = ['agents', 'tasks', 'requirements', 'projects', 'deliverables']; + +let _persistedQuery = ''; +let _persistedCategory: SearchCategory = 'all'; +let _persistedResults: SearchResults = EMPTY; +let _persistedSearched = false; + +export function SearchModal({ onClose, currentPage }: { onClose: () => void; currentPage?: string }) { + const { t } = useTranslation(['common', 'home', 'work']); + const [query, setQuery] = useState(_persistedQuery); + const [category, setCategory] = useState(_persistedCategory); + const [results, setResults] = useState(_persistedResults); + const [loading, setLoading] = useState(false); + const [searched, setSearched] = useState(_persistedSearched); + const [focusIdx, setFocusIdx] = useState(-1); + + useEffect(() => { _persistedQuery = query; }, [query]); + useEffect(() => { _persistedCategory = category; }, [category]); + useEffect(() => { _persistedResults = results; }, [results]); + useEffect(() => { _persistedSearched = searched; }, [searched]); + const inputRef = useRef(null); + const debounceRef = useRef | undefined>(undefined); + const backdropRef = useRef(null); + const listRef = useRef(null); + + useEffect(() => { + setTimeout(() => { inputRef.current?.focus(); inputRef.current?.select(); }, 100); + }, []); + + const doSearch = useCallback(async (q: string) => { + if (!q.trim()) { setResults(EMPTY); setSearched(false); setFocusIdx(-1); return; } + setLoading(true); + setSearched(true); + setFocusIdx(-1); + const lower = q.toLowerCase(); + try { + const [agentsRes, tasksRes, requirementsRes, projectsRes, deliverablesRes] = await Promise.allSettled([ + api.agents.list(), + api.tasks.list({ search: q, pageSize: 20 }), + api.requirements.list(), + api.projects.list(), + api.deliverables.search({ q, limit: 20 }), + ]); + const agents = agentsRes.status === 'fulfilled' + ? agentsRes.value.agents.filter(a => a.name?.toLowerCase().includes(lower) || a.role?.toLowerCase().includes(lower)) + : []; + const tasks = tasksRes.status === 'fulfilled' ? tasksRes.value.tasks : []; + const requirements = requirementsRes.status === 'fulfilled' + ? requirementsRes.value.requirements.filter(r => r.title?.toLowerCase().includes(lower) || r.description?.toLowerCase().includes(lower)) + : []; + const projects = projectsRes.status === 'fulfilled' + ? projectsRes.value.projects.filter(p => p.name?.toLowerCase().includes(lower) || p.description?.toLowerCase().includes(lower)) + : []; + const deliverables = deliverablesRes.status === 'fulfilled' ? deliverablesRes.value.results : []; + setResults({ agents, tasks, requirements, projects, deliverables }); + } catch { + setResults(EMPTY); + } finally { + setLoading(false); + } + }, []); + + const handleInput = (value: string) => { + setQuery(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => doSearch(value), 400); + }; + + const navigate = useCallback((page: PageId, params?: Record) => { + onClose(); + navBus.navigate(page, params); + }, [onClose]); + + const categories: { id: SearchCategory; label: string }[] = [ + { id: 'all', label: t('common:all', { defaultValue: '全部' }) }, + { id: 'agents', label: t('common:agents', { defaultValue: '智能体' }) }, + { id: 'tasks', label: t('common:tasks', { defaultValue: '任务' }) }, + { id: 'requirements', label: t('common:requirements', { defaultValue: '需求' }) }, + { id: 'projects', label: t('common:projects', { defaultValue: '项目' }) }, + { id: 'deliverables', label: t('common:deliverables', { defaultValue: '交付物' }) }, + ]; + + const sectionOrder = useMemo(() => PAGE_SECTION_ORDER[currentPage || ''] || DEFAULT_ORDER, [currentPage]); + + const flatItems = useMemo(() => { + const items: FlatItem[] = []; + const limit = category === 'all' ? 8 : undefined; + const addSection = (section: SectionId) => { + if (category !== 'all' && category !== section) return; + const arr = results[section === 'agents' ? 'agents' : section === 'tasks' ? 'tasks' : section === 'requirements' ? 'requirements' : section === 'projects' ? 'projects' : 'deliverables']; + const sliced = limit ? arr.slice(0, limit) : arr; + for (const item of sliced) { + switch (section) { + case 'agents': items.push({ id: item.id, type: 'agent', page: PAGE.TEAM, params: { agentId: item.id } }); break; + case 'tasks': items.push({ id: item.id, type: 'task', page: PAGE.WORK, params: { openTask: item.id } }); break; + case 'requirements': items.push({ id: item.id, type: 'requirement', page: PAGE.WORK, params: { openRequirement: item.id } }); break; + case 'projects': items.push({ id: item.id, type: 'project', page: PAGE.WORK, params: { projectId: item.id } }); break; + case 'deliverables': items.push({ id: item.id, type: 'deliverable', page: PAGE.DELIVERABLES, params: { openDeliverable: item.id } }); break; + } + } + if (limit && arr.length > limit) { + items.push({ id: `more_${section}`, type: 'showMore', page: '' as PageId, expandCategory: section as SearchCategory, totalCount: arr.length }); + } + }; + for (const s of sectionOrder) addSection(s); + return items; + }, [results, category, sectionOrder]); + + const openItem = useCallback((item: FlatItem) => { + if (item.type === 'showMore' && item.expandCategory) { + setCategory(item.expandCategory); + setFocusIdx(-1); + return; + } + navigate(item.page, item.params); + }, [navigate]); + + useEffect(() => { + const el = listRef.current; + if (!el || focusIdx < 0) return; + const target = el.querySelector(`[data-idx="${focusIdx}"]`); + if (target) target.scrollIntoView({ block: 'nearest' }); + }, [focusIdx]); + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (e.key === 'Escape') { onClose(); return; } + if (e.key === 'Tab') { + e.preventDefault(); + const catIds = categories.map(c => c.id); + const curIdx = catIds.indexOf(category); + const next = e.shiftKey + ? (curIdx <= 0 ? catIds.length - 1 : curIdx - 1) + : (curIdx >= catIds.length - 1 ? 0 : curIdx + 1); + setCategory(catIds[next]); + setFocusIdx(-1); + return; + } + const isDown = (e.ctrlKey && e.key === 'n') || e.key === 'ArrowDown'; + const isUp = (e.ctrlKey && e.key === 'p') || e.key === 'ArrowUp'; + if (isDown || isUp) { + e.preventDefault(); + setFocusIdx(prev => { + const max = flatItems.length - 1; + if (max < 0) return -1; + if (isDown) return prev >= max ? 0 : prev + 1; + return prev <= 0 ? max : prev - 1; + }); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + if (focusIdx >= 0 && focusIdx < flatItems.length) { + openItem(flatItems[focusIdx]); + } + } + }, [onClose, flatItems, focusIdx, openItem, categories, category]); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); + + const hasResults = flatItems.length > 0; + + let itemCounter = 0; + + return ( +
{ if (e.target === backdropRef.current) onClose(); }} + > +
+ {/* Search input */} +
+
+ + handleInput(e.target.value)} + placeholder={t('common:search')} + className="w-full bg-surface-elevated border border-border-default rounded-xl pl-9 pr-9 py-2.5 text-sm text-fg-primary placeholder:text-fg-tertiary focus:border-brand-500 focus:outline-none transition-colors" + /> + {query && ( + + )} +
+
+ {categories.map(cat => ( + + ))} +
+
+ + {/* Results */} +
+ {loading && ( +
+
{t('common:loading')}
+
+ )} + + {!loading && searched && !hasResults && ( +
+ +

{t('common:noResults', { defaultValue: '没有找到相关结果' })}

+
+ )} + + {!loading && hasResults && ( +
+ {sectionOrder.map(section => { + if (category !== 'all' && category !== section) return null; + const limit = category === 'all' ? 8 : undefined; + const showMoreBtn = (cat: SearchCategory) => { + const idx = itemCounter++; + const total = cat === 'agents' ? results.agents.length + : cat === 'tasks' ? results.tasks.length + : cat === 'requirements' ? results.requirements.length + : cat === 'projects' ? results.projects.length + : results.deliverables.length; + return ( + + ); + }; + if (section === 'agents' && results.agents.length > 0) return ( +
+

{t('common:agents', { defaultValue: '智能体' })}

+
+ {(limit ? results.agents.slice(0, limit) : results.agents).map(agent => { + const idx = itemCounter++; + return ( + + ); + })} +
+ {limit && results.agents.length > limit && showMoreBtn('agents')} +
+ ); + if (section === 'tasks' && results.tasks.length > 0) return ( +
+

{t('common:tasks', { defaultValue: '任务' })}

+
+ {(limit ? results.tasks.slice(0, limit) : results.tasks).map(task => { + const idx = itemCounter++; + return ( + + ); + })} +
+ {limit && results.tasks.length > limit && showMoreBtn('tasks')} +
+ ); + if (section === 'requirements' && results.requirements.length > 0) return ( +
+

{t('common:requirements', { defaultValue: '需求' })}

+
+ {(limit ? results.requirements.slice(0, limit) : results.requirements).map(req => { + const idx = itemCounter++; + return ( + + ); + })} +
+ {limit && results.requirements.length > limit && showMoreBtn('requirements')} +
+ ); + if (section === 'projects' && results.projects.length > 0) return ( +
+

{t('common:projects', { defaultValue: '项目' })}

+
+ {(limit ? results.projects.slice(0, limit) : results.projects).map(proj => { + const idx = itemCounter++; + return ( + + ); + })} +
+ {limit && results.projects.length > limit && showMoreBtn('projects')} +
+ ); + if (section === 'deliverables' && results.deliverables.length > 0) return ( +
+

{t('common:deliverables', { defaultValue: '交付物' })}

+
+ {(limit ? results.deliverables.slice(0, limit) : results.deliverables).map(d => { + const idx = itemCounter++; + return ( + + ); + })} +
+ {limit && results.deliverables.length > limit && showMoreBtn('deliverables')} +
+ ); + return null; + })} +
+ )} + + {!loading && !searched && ( +
+ +

{t('common:searchHint', { defaultValue: '输入关键词搜索' })}

+
+ )} +
+ + {/* Footer */} +
+ {navigator.platform.toUpperCase().includes('MAC') ? 'Cmd+P' : 'Ctrl+P'} {t('common:toggle', { defaultValue: '唤起/关闭' })} + Tab {t('common:switchTab', { defaultValue: '切换分类' })} + ↑↓ {t('common:navigate', { defaultValue: '导航' })} + Enter {t('common:open', { defaultValue: '打开' })} + Esc {t('common:close', { defaultValue: '关闭' })} +
+
+
+ ); +} + +function StatusDot({ status }: { status: string }) { + const color = status === 'active' || status === 'idle' ? 'bg-green-500' + : status === 'working' || status === 'busy' ? 'bg-blue-500' + : status === 'paused' ? 'bg-amber-500' + : 'bg-gray-400'; + return ; +} + +function TaskStatusIcon({ status }: { status: string }) { + const color = status === 'completed' ? 'text-green-500 bg-green-500/15' + : status === 'in_progress' ? 'text-blue-500 bg-blue-500/15' + : status === 'pending' ? 'text-amber-500 bg-amber-500/15' + : 'text-fg-tertiary bg-surface-elevated'; + return ( +
+ +
+ ); +} diff --git a/packages/web-ui/src/hooks/useUnreadCounts.ts b/packages/web-ui/src/hooks/useUnreadCounts.ts new file mode 100644 index 00000000..813a7666 --- /dev/null +++ b/packages/web-ui/src/hooks/useUnreadCounts.ts @@ -0,0 +1,126 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { api, wsClient } from '../api.ts'; + +const POLL_INTERVAL_MS = 60_000; + +let _globalCounts: Record = {}; +let _listeners = new Set<() => void>(); + +function notify() { + for (const fn of _listeners) fn(); +} + +export function useUnreadCounts() { + const [counts, setCounts] = useState>(_globalCounts); + const pollRef = useRef | undefined>(undefined); + + const refresh = useCallback(async () => { + try { + const resp = await api.unread.getCounts(); + _globalCounts = resp.counts ?? {}; + setCounts(_globalCounts); + notify(); + } catch { /* silent */ } + }, []); + + const markRead = useCallback(async (conversationKey: string) => { + const ts = new Date().toISOString(); + delete _globalCounts[conversationKey]; + setCounts({ ..._globalCounts }); + notify(); + try { + await api.unread.markRead(conversationKey, ts); + } catch { /* silent */ } + }, []); + + const markAllRead = useCallback(async () => { + _globalCounts = {}; + setCounts({}); + notify(); + try { + await api.unread.markAllRead(); + } catch { /* silent */ } + }, []); + + useEffect(() => { + refresh(); + pollRef.current = setInterval(refresh, POLL_INTERVAL_MS); + + const unsub = wsClient.on('chat:unread_update', (event) => { + const key = (event.payload as { conversationKey?: string })?.conversationKey; + if (key) { + _globalCounts[key] = (_globalCounts[key] ?? 0) + 1; + setCounts({ ..._globalCounts }); + notify(); + } + }); + + const listener = () => setCounts({ ..._globalCounts }); + _listeners.add(listener); + + return () => { + if (pollRef.current) clearInterval(pollRef.current); + unsub(); + _listeners.delete(listener); + }; + }, [refresh]); + + const totalUnread = useMemo(() => { + return Object.values(counts).reduce((sum, n) => sum + n, 0); + }, [counts]); + + const getSessionUnread = useCallback((sessionId: string): number => { + return counts[`session:${sessionId}`] ?? 0; + }, [counts]); + + const getChannelUnread = useCallback((channelKey: string): number => { + return counts[`channel:${channelKey}`] ?? 0; + }, [counts]); + + return { counts, totalUnread, getSessionUnread, getChannelUnread, markRead, markAllRead, refresh }; +} + +/** + * Get unread count for a specific agent by summing all session:* entries + * that belong to sessions of that agent. + * Since session keys encode the session ID (not the agent ID), the caller + * must provide a mapping of sessionId -> agentId. + */ +export function useAgentUnread( + agentSessionMap: Map, + counts: Record +): Map { + return useMemo(() => { + const result = new Map(); + for (const [key, count] of Object.entries(counts)) { + if (key.startsWith('session:')) { + const sessionId = key.slice('session:'.length); + const agentId = agentSessionMap.get(sessionId); + if (agentId) { + result.set(agentId, (result.get(agentId) ?? 0) + count); + } + } + } + return result; + }, [agentSessionMap, counts]); +} + +/** + * Get unread for a team by summing its team channel + all member agent sessions. + */ +export function getTeamUnread( + teamId: string, + teamAgentIds: string[], + teamChannelKey: string | undefined, + agentUnreads: Map, + counts: Record +): number { + let total = 0; + if (teamChannelKey) { + total += counts[`channel:${teamChannelKey}`] ?? 0; + } + for (const agentId of teamAgentIds) { + total += agentUnreads.get(agentId) ?? 0; + } + return total; +} diff --git a/packages/web-ui/src/pages/Deliverables.tsx b/packages/web-ui/src/pages/Deliverables.tsx index 140b6d35..2085227c 100644 --- a/packages/web-ui/src/pages/Deliverables.tsx +++ b/packages/web-ui/src/pages/Deliverables.tsx @@ -167,6 +167,52 @@ export function DeliverablesPage({ authUser: _authUser }: { authUser?: AuthUser return () => { unsub1(); unsub2(); unsub3(); }; }, [refresh]); + // Handle deep navigation to a specific deliverable + const pendingOpenRef = useRef(null); + const itemsRef = useRef(items); + itemsRef.current = items; + + const openDeliverableById = useCallback((id: string) => { + const showDetail = (item: DeliverableInfo) => { + setSelected(item); + if (isMobile) { + setMobileShowDetail(true); + history.pushState({ mobileDetail: PAGE.DELIVERABLES }, '', window.location.hash); + } + }; + const found = itemsRef.current.find(d => d.id === id); + if (found) { showDetail(found); return; } + api.deliverables.get(id).then(r => { if (r.deliverable) showDetail(r.deliverable); }).catch(() => {}); + }, [isMobile]); + + useEffect(() => { + const navId = localStorage.getItem('markus_nav_openDeliverable'); + if (navId) { + localStorage.removeItem('markus_nav_openDeliverable'); + if (itemsRef.current.length > 0) { + openDeliverableById(navId); + } else { + pendingOpenRef.current = navId; + } + } + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.params?.openDeliverable) { + localStorage.removeItem('markus_nav_openDeliverable'); + openDeliverableById(detail.params.openDeliverable); + } + }; + window.addEventListener('markus:navigate', handler); + return () => window.removeEventListener('markus:navigate', handler); + }, [openDeliverableById]); + + useEffect(() => { + const id = pendingOpenRef.current; + if (!id || items.length === 0) return; + pendingOpenRef.current = null; + openDeliverableById(id); + }, [items, openDeliverableById]); + const checkNeedMore = useCallback(() => { const el = listRef.current; if (!el || loading || loadingMore || items.length >= totalCount) return; diff --git a/packages/web-ui/src/pages/Home.tsx b/packages/web-ui/src/pages/Home.tsx index d038dfb4..e333cf77 100644 --- a/packages/web-ui/src/pages/Home.tsx +++ b/packages/web-ui/src/pages/Home.tsx @@ -162,21 +162,21 @@ export function HomePage({ authUser }: { authUser?: { id: string; name: string;
) : ( - +
+ +
+ + {showCreateMenu && ( +
+
+ + + +
+ +
+
+ )} +
+
)}
diff --git a/packages/web-ui/src/pages/Search.tsx b/packages/web-ui/src/pages/Search.tsx index 79c6ea8e..814fe0a7 100644 --- a/packages/web-ui/src/pages/Search.tsx +++ b/packages/web-ui/src/pages/Search.tsx @@ -1,15 +1,16 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { api, type AgentInfo, type TaskInfo, type ProjectInfo, type DeliverableInfo } from '../api.ts'; +import { api, type AgentInfo, type TaskInfo, type ProjectInfo, type DeliverableInfo, type RequirementInfo } from '../api.ts'; import { navBus } from '../navBus.ts'; import { PAGE } from '../routes.ts'; import { Avatar } from '../components/Avatar.tsx'; -type SearchCategory = 'all' | 'agents' | 'tasks' | 'projects' | 'deliverables'; +type SearchCategory = 'all' | 'agents' | 'tasks' | 'requirements' | 'projects' | 'deliverables'; interface SearchResults { agents: AgentInfo[]; tasks: TaskInfo[]; + requirements: RequirementInfo[]; projects: ProjectInfo[]; deliverables: DeliverableInfo[]; } @@ -18,11 +19,11 @@ export function SearchPage() { const { t } = useTranslation(['common', 'home', 'work']); const [query, setQuery] = useState(''); const [category, setCategory] = useState('all'); - const [results, setResults] = useState({ agents: [], tasks: [], projects: [], deliverables: [] }); + const [results, setResults] = useState({ agents: [], tasks: [], requirements: [], projects: [], deliverables: [] }); const [loading, setLoading] = useState(false); const [searched, setSearched] = useState(false); const inputRef = useRef(null); - const debounceRef = useRef>(); + const debounceRef = useRef | undefined>(undefined); useEffect(() => { setTimeout(() => inputRef.current?.focus(), 100); @@ -30,7 +31,7 @@ export function SearchPage() { const doSearch = useCallback(async (q: string) => { if (!q.trim()) { - setResults({ agents: [], tasks: [], projects: [], deliverables: [] }); + setResults({ agents: [], tasks: [], requirements: [], projects: [], deliverables: [] }); setSearched(false); return; } @@ -38,9 +39,10 @@ export function SearchPage() { setSearched(true); const lower = q.toLowerCase(); try { - const [agentsRes, tasksRes, projectsRes, deliverablesRes] = await Promise.allSettled([ + const [agentsRes, tasksRes, requirementsRes, projectsRes, deliverablesRes] = await Promise.allSettled([ api.agents.list(), api.tasks.list({ search: q, pageSize: 20 }), + api.requirements.list(), api.projects.list(), api.deliverables.search({ q, limit: 20 }), ]); @@ -49,14 +51,17 @@ export function SearchPage() { ? agentsRes.value.agents.filter(a => a.name?.toLowerCase().includes(lower) || a.role?.toLowerCase().includes(lower)) : []; const tasks = tasksRes.status === 'fulfilled' ? tasksRes.value.tasks : []; + const requirements = requirementsRes.status === 'fulfilled' + ? requirementsRes.value.requirements.filter(r => r.title?.toLowerCase().includes(lower) || r.description?.toLowerCase().includes(lower)) + : []; const projects = projectsRes.status === 'fulfilled' ? projectsRes.value.projects.filter(p => p.name?.toLowerCase().includes(lower) || p.description?.toLowerCase().includes(lower)) : []; const deliverables = deliverablesRes.status === 'fulfilled' ? deliverablesRes.value.results : []; - setResults({ agents, tasks, projects, deliverables }); + setResults({ agents, tasks, requirements, projects, deliverables }); } catch { - setResults({ agents: [], tasks: [], projects: [], deliverables: [] }); + setResults({ agents: [], tasks: [], requirements: [], projects: [], deliverables: [] }); } finally { setLoading(false); } @@ -72,11 +77,12 @@ export function SearchPage() { { id: 'all', label: t('common:all', { defaultValue: '全部' }) }, { id: 'agents', label: t('common:agents', { defaultValue: '智能体' }) }, { id: 'tasks', label: t('common:tasks', { defaultValue: '任务' }) }, + { id: 'requirements', label: t('common:requirements', { defaultValue: '需求' }) }, { id: 'projects', label: t('common:projects', { defaultValue: '项目' }) }, { id: 'deliverables', label: t('common:deliverables', { defaultValue: '交付物' }) }, ]; - const hasResults = results.agents.length > 0 || results.tasks.length > 0 || results.projects.length > 0 || results.deliverables.length > 0; + const hasResults = results.agents.length > 0 || results.tasks.length > 0 || results.requirements.length > 0 || results.projects.length > 0 || results.deliverables.length > 0; return (
@@ -100,7 +106,7 @@ export function SearchPage() { /> {query && ( ))}
+ {category === 'all' && results.agents.length > 8 && ( + + )}
)} @@ -168,10 +179,10 @@ export function SearchPage() {

{t('common:tasks', { defaultValue: '任务' })}

- {results.tasks.slice(0, category === 'all' ? 5 : 50).map(task => ( + {(category === 'all' ? results.tasks.slice(0, 8) : results.tasks).map(task => ( ))}
+ {category === 'all' && results.tasks.length > 8 && ( + + )} +
+ )} + + {/* Requirements */} + {(category === 'all' || category === 'requirements') && results.requirements.length > 0 && ( +
+

{t('common:requirements', { defaultValue: '需求' })}

+
+ {(category === 'all' ? results.requirements.slice(0, 8) : results.requirements).map(req => ( + + ))} +
+ {category === 'all' && results.requirements.length > 8 && ( + + )}
)} @@ -190,10 +240,10 @@ export function SearchPage() {

{t('common:projects', { defaultValue: '项目' })}

- {results.projects.slice(0, category === 'all' ? 5 : 50).map(proj => ( + {(category === 'all' ? results.projects.slice(0, 8) : results.projects).map(proj => ( ))}
+ {category === 'all' && results.projects.length > 8 && ( + + )}
)} @@ -214,10 +269,10 @@ export function SearchPage() {

{t('common:deliverables', { defaultValue: '交付物' })}

- {results.deliverables.slice(0, category === 'all' ? 5 : 50).map(d => ( + {(category === 'all' ? results.deliverables.slice(0, 8) : results.deliverables).map(d => ( ))}
+ {category === 'all' && results.deliverables.length > 8 && ( + + )}
)} diff --git a/packages/web-ui/src/pages/Settings.tsx b/packages/web-ui/src/pages/Settings.tsx index 5f7bfbbd..42abbbf2 100644 --- a/packages/web-ui/src/pages/Settings.tsx +++ b/packages/web-ui/src/pages/Settings.tsx @@ -2452,7 +2452,7 @@ function RemoteAccessSection() { useEffect(() => { return wsClient.on('remote:status', (event) => { - const payload = event.payload as RemoteStatus | undefined; + const payload = event.payload as unknown as RemoteStatus | undefined; if (payload) { setStatus(payload); setToggling(false); diff --git a/packages/web-ui/src/pages/Team.tsx b/packages/web-ui/src/pages/Team.tsx index 2df50d9a..04b674d2 100644 --- a/packages/web-ui/src/pages/Team.tsx +++ b/packages/web-ui/src/pages/Team.tsx @@ -29,6 +29,7 @@ import { TeamProfile, TABS as TEAM_TABS, type TeamTab } from './TeamProfile.tsx' import { useResizablePanel } from '../hooks/useResizablePanel.ts'; import { useIsMobile } from '../hooks/useIsMobile.ts'; import { useSwipeTabs } from '../hooks/useSwipeTabs.ts'; +import { useUnreadCounts } from '../hooks/useUnreadCounts.ts'; import { Avatar } from '../components/Avatar.tsx'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -798,14 +799,29 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string const [initialLoading, setInitialLoading] = useState(true); const isMobile = useIsMobile(); - // Mobile: URL hash is the single source of truth (#chat = list, #chat/d = detail) + // Mobile: URL hash is the single source of truth for 3-layer navigation + // L1 (roster): #team — sidebar list + // L2 (team detail): #team/t/ — team agent list + channel + // L3 (chat): #team/d — agent/channel chat const hash = useSyncExternalStore(_subHash, _getHash); const mobileShowChat = isMobile && (hash.startsWith(`#${PAGE.TEAM}/`) || hash.startsWith('#chat/')); - + const mobileTeamHash = isMobile && hash.match(/^#team\/t\/(.+)$/); + const mobileLayer: 'roster' | 'team' | 'chat' = !isMobile ? 'roster' + : mobileTeamHash ? 'team' + : mobileShowChat ? 'chat' + : 'roster'; + const mobileTeamId = mobileTeamHash ? mobileTeamHash[1] : null; + + const mobileBackHashRef = useRef(PAGE.TEAM); const enterMobileDetail = useCallback(() => { + mobileBackHashRef.current = window.location.hash.slice(1) || PAGE.TEAM; window.location.hash = `${PAGE.TEAM}/d`; }, []); + const enterMobileTeam = useCallback((teamId: string) => { + window.location.hash = `${PAGE.TEAM}/t/${teamId}`; + }, []); + // Profile tab: still uses pushState for back navigation useEffect(() => { if (!isMobile) return; @@ -1032,6 +1048,9 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string // Group chats const [groupChats, setGroupChats] = useState }>>([]); + const groupChatsRef = useRef(groupChats); + groupChatsRef.current = groupChats; + const pendingSelectTeamRef = useRef(null); const [showMemberPanel, setShowMemberPanel] = useState(false); // Teams @@ -1135,14 +1154,18 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string // ── Per-agent unread notification counts + auto-read ──────────────────────── const [unreadByAgent, setUnreadByAgent] = useState>(new Map()); const unreadIdsByAgent = useRef>(new Map()); + const agentIdsRef = useRef>(new Set()); + useEffect(() => { agentIdsRef.current = new Set(agents.map(a => a.id)); }, [agents]); + const refreshUnreadCounts = useCallback(async () => { try { const { notifications } = await api.notifications.list(authUser?.id, true); const counts = new Map(); const ids = new Map(); + const knownAgents = agentIdsRef.current; for (const n of notifications) { const agentId = (n.metadata?.agentId as string) || undefined; - if (agentId) { + if (agentId && knownAgents.has(agentId)) { counts.set(agentId, (counts.get(agentId) ?? 0) + 1); const list = ids.get(agentId) ?? []; list.push(n.id); @@ -1151,6 +1174,10 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string } setUnreadByAgent(counts); unreadIdsByAgent.current = ids; + // Broadcast total for BottomNav badge + let total = 0; + for (const v of counts.values()) total += v; + window.dispatchEvent(new CustomEvent('markus:team-unread-changed', { detail: { count: total } })); } catch { /* */ } }, [authUser?.id]); @@ -1173,6 +1200,33 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string } }, [chatMode, selectedAgent, markAgentNotificationsRead]); + // ── Chat unread counts (message-level read cursors) ────────────────────────── + const { counts: chatUnreadCounts, markRead: markChatRead } = useUnreadCounts(); + const unreadByChannel = useMemo(() => { + const result: Record = {}; + for (const [key, count] of Object.entries(chatUnreadCounts)) { + if (key.startsWith('channel:')) { + result[key.slice('channel:'.length)] = count; + } + } + return result; + }, [chatUnreadCounts]); + + // Auto mark-read when a conversation becomes visible + useEffect(() => { + const isVisible = !isMobile || mobileLayer === 'chat'; + if (!isVisible) return; + if (chatMode === 'channel' && activeChannel) { + markChatRead(`channel:${activeChannel}`); + } else if (chatMode === 'direct' && activeSessionId) { + markChatRead(`session:${activeSessionId}`); + } else if (chatMode === 'dm' && activeDmUserId) { + const dmChannel = `dm:${[authUser?.id, activeDmUserId].sort().join(':')}`; + markChatRead(`channel:${dmChannel}`); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatMode, activeChannel, activeSessionId, activeDmUserId, mobileLayer]); + // ── Data loading ───────────────────────────────────────────────────────────── const refreshAgents = useCallback(() => api.agents.list().then(d => setAgents(d.agents)).catch(() => {}), []); const refreshTeams = useCallback(() => api.teams.list().then(d => setTeams(d.teams)).catch(() => {}), []); @@ -1257,6 +1311,15 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string setMainTab('chat'); if (isMobile) enterMobileDetail(); } + if (detail.params?.selectTeam) { + const teamId = detail.params.selectTeam; + if (isMobile) { + enterMobileTeam(teamId); + } else { + const teamGc = groupChatsRef.current.find(gc => gc.type === 'team' && gc.teamId === teamId); + if (teamGc) { setChatMode('channel'); setActiveChannel(teamGc.channelKey); setMainTab('chat'); setShowMemberPanel(false); setShowTeamDetailPanel(true); } + } + } if (detail.params?.openHire === 'true') { // handled by ChatTeamSidebar via nav events } @@ -1286,11 +1349,30 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string localStorage.removeItem('markus_nav_selectAgent'); handleViewProfile(selectAgent); } + const selectTeam = localStorage.getItem('markus_nav_selectTeam'); + if (selectTeam) { + localStorage.removeItem('markus_nav_selectTeam'); + if (isMobile) { + enterMobileTeam(selectTeam); + } else { + pendingSelectTeamRef.current = selectTeam; + } + } window.addEventListener('markus:navigate', handleNav); return () => window.removeEventListener('markus:navigate', handleNav); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + const teamId = pendingSelectTeamRef.current; + if (!teamId || groupChats.length === 0) return; + const teamGc = groupChats.find(gc => gc.type === 'team' && gc.teamId === teamId); + if (teamGc) { + pendingSelectTeamRef.current = null; + setChatMode('channel'); setActiveChannel(teamGc.channelKey); setMainTab('chat'); setShowMemberPanel(false); setShowTeamDetailPanel(true); + } + }, [groupChats]); + // Auto-select secretary agent when no valid agent is selected. // Also handles stale IDs from localStorage (e.g. deleted agents). useEffect(() => { @@ -2616,7 +2698,7 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string selectedAgent ? t('page.placeholder.direct') : t('page.placeholder.noAgent'); // ── Render ──────────────────────────────────────────────────────────────────── - const showChatOnMobile = isMobile && mobileShowChat; + const showChatOnMobile = isMobile && mobileLayer === 'chat'; return (
@@ -2639,7 +2721,7 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string onSelectTeam={(teamId) => { const teamGc = groupChats.find(gc => gc.type === 'team' && gc.teamId === teamId); if (isMobile) { - if (teamGc) { setChatMode('channel'); setActiveChannel(teamGc.channelKey); setMainTab('chat'); setShowMemberPanel(false); enterMobileDetail(); } + enterMobileTeam(teamId); } else { if (teamGc) { setChatMode('channel'); setActiveChannel(teamGc.channelKey); setMainTab('chat'); setShowMemberPanel(false); if (!showTeamDetailPanel && !l2SpaceTight) setShowTeamDetailPanel(true); } } @@ -2652,12 +2734,92 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string onViewProfile={handleViewProfile} onManageGroupMembers={(channelKey) => { setChatMode('channel'); setActiveChannel(channelKey); setMainTab('chat'); setShowMemberPanel(true); if (isMobile) enterMobileDetail(); }} unreadByAgent={unreadByAgent} + unreadByChannel={unreadByChannel} width={isMobile ? undefined : chatSidebar.width} onResizeStart={isMobile ? undefined : chatSidebar.onResizeStart} - hidden={isMobile && mobileShowChat} + hidden={isMobile && mobileLayer !== 'roster'} initialLoading={initialLoading} /> + {/* ── L2: Mobile team detail view ── */} + {isMobile && mobileLayer === 'team' && mobileTeamId && (() => { + const l2Team = teams.find(t => t.id === mobileTeamId); + if (!l2Team) return null; + const l2Agents = agents.filter(a => a.teamId === mobileTeamId); + const l2Gc = groupChats.find(gc => gc.type === 'team' && gc.teamId === mobileTeamId); + return ( +
+
+ +
+
+ +
+
+

{l2Team.name}

+

{t('chat.members_other', { count: l2Team.members?.length || l2Agents.length })}

+
+
+
+
+ {l2Gc && (() => { + const gcUnread = unreadByChannel[l2Gc.channelKey] ?? 0; + return ( + + ); + })()} + {l2Agents.length > 0 && ( + <> +

{t('chat.agents')}

+ {l2Agents.map(agent => { + const agentUnread = unreadByAgent.get(agent.id) ?? 0; + return ( + + ); + })} + + )} +
+
+ ); + })()} + {/* ── L2: Team detail panel (desktop only) ── */} {/* Inline mode: when space allows */} {showTeamDetailPanel && !l2SpaceTight && !isMobile && (() => { @@ -2736,7 +2898,7 @@ export function TeamPage({ initialAgentId, authUser }: { initialAgentId?: string {/* Mobile Row 1: back + name + status */}
+ {/* Auto-click Chrome Allow Dialog toggle */} +
+
+
+
{t('browserAutomation.autoClickAllowDialog')}
+
+ {t('browserAutomation.autoClickAllowDialogDesc')} +
+
+
{t('browserAutomation.autoClickAllowDialogMacNote')}
+
{t('browserAutomation.autoClickAllowDialogWinNote')}
+
{t('browserAutomation.autoClickAllowDialogLinuxNote')}
+
+
+
+ + +
+
+
+ {/* Remote Debugging Port */}
diff --git a/scripts/markus-chrome-allow/build.sh b/scripts/markus-chrome-allow/build.sh new file mode 100755 index 00000000..73396101 --- /dev/null +++ b/scripts/markus-chrome-allow/build.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +echo "Building markus-chrome-allow..." +swiftc -O main.swift -o markus-chrome-allow +echo "Built: $SCRIPT_DIR/markus-chrome-allow" diff --git a/scripts/markus-chrome-allow/main.swift b/scripts/markus-chrome-allow/main.swift new file mode 100644 index 00000000..47689353 --- /dev/null +++ b/scripts/markus-chrome-allow/main.swift @@ -0,0 +1,174 @@ +import ApplicationServices +import AppKit +import Foundation +import CoreGraphics + +func jsonOut(_ dict: [String: Any]) { + if let data = try? JSONSerialization.data(withJSONObject: dict), + let str = String(data: data, encoding: .utf8) { + print(str) + } +} + +func exitWithError(_ msg: String, code: Int32) -> Never { + jsonOut(["error": msg, "clicked": false]) + exit(code) +} + +let args = CommandLine.arguments + +// --check: report permission and Chrome status +if args.contains("--check") { + let trusted = AXIsProcessTrusted() + let chromeRunning = !NSRunningApplication.runningApplications( + withBundleIdentifier: "com.google.Chrome" + ).isEmpty + jsonOut([ + "accessibilityPermission": trusted, + "chromeRunning": chromeRunning, + "platform": "darwin" + ]) + exit(0) +} + +// --open-accessibility: open System Settings > Accessibility +if args.contains("--open-accessibility") { + let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! + NSWorkspace.shared.open(url) + jsonOut(["opened": "accessibility_settings"]) + exit(0) +} + +guard AXIsProcessTrusted() else { + exitWithError("Accessibility permission not granted", code: 2) +} + +let chromeApps = NSRunningApplication.runningApplications( + withBundleIdentifier: "com.google.Chrome" +) +guard let chrome = chromeApps.first else { + exitWithError("Chrome is not running", code: 1) +} + +var timeoutSec = 5.0 +if let idx = args.firstIndex(of: "--timeout"), idx + 1 < args.count, + let t = Double(args[idx + 1]) { + timeoutSec = t +} + +let chromePid = Int(chrome.processIdentifier) + +// Dialog window name patterns (multi-language) +let dialogNamePatterns = [ + "允许远程调试", + "Allow remote debugging", + "Allow debugging", + "リモートデバッグを許可", + "원격 디버깅 허용", +] + +struct DialogWindow { + let x: CGFloat + let y: CGFloat + let width: CGFloat + let height: CGFloat +} + +/// Find Chrome's "Allow remote debugging?" dialog by window name via CoreGraphics. +func findDialogWindow() -> DialogWindow? { + guard let windowList = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] else { + return nil + } + + for info in windowList { + guard let ownerPid = info[kCGWindowOwnerPID as String] as? Int, ownerPid == chromePid else { continue } + guard let name = info[kCGWindowName as String] as? String, !name.isEmpty else { continue } + guard let boundsDict = info[kCGWindowBounds as String] else { continue } + + let matchesPattern = dialogNamePatterns.contains { name.contains($0) } + guard matchesPattern else { continue } + + var rect = CGRect.zero + guard CGRectMakeWithDictionaryRepresentation(boundsDict as! CFDictionary, &rect) else { continue } + + if rect.width > 100 && rect.height > 80 { + return DialogWindow(x: rect.origin.x, y: rect.origin.y, width: rect.width, height: rect.height) + } + } + return nil +} + +func clickAt(_ point: CGPoint) -> Bool { + guard let mouseDown = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left), + let mouseUp = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left) else { + return false + } + mouseDown.post(tap: .cghidEventTap) + Thread.sleep(forTimeInterval: 0.05) + mouseUp.post(tap: .cghidEventTap) + return true +} + +/// Click the "Allow" button via coordinate offset on the dialog window. +func clickAllowButton(dialog: DialogWindow) -> Bool { + let buttonX = dialog.x + dialog.width - 55 + let buttonY = dialog.y + dialog.height - 35 + return clickAt(CGPoint(x: buttonX, y: buttonY)) +} + +// Also try AX button approach as fallback (older Chrome) +let appElement = AXUIElementCreateApplication(chrome.processIdentifier) + +func findAllowButtonAX(_ element: AXUIElement, depth: Int = 0) -> AXUIElement? { + if depth > 12 { return nil } + var roleRef: CFTypeRef? + AXUIElementCopyAttributeValue(element, kAXRoleAttribute as CFString, &roleRef) + let role = roleRef as? String ?? "" + var titleRef: CFTypeRef? + AXUIElementCopyAttributeValue(element, kAXTitleAttribute as CFString, &titleRef) + let title = titleRef as? String ?? "" + + if role == "AXButton" { + let lower = title.lowercased() + if lower == "allow" || lower == "允许" || lower == "allow debugging" { + return element + } + } + + var childrenRef: CFTypeRef? + let err = AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childrenRef) + guard err == .success, let children = childrenRef as? [AXUIElement] else { return nil } + for child in children { + if let found = findAllowButtonAX(child, depth: depth + 1) { return found } + } + return nil +} + +let pollInterval: TimeInterval = 0.3 +let deadline = Date().addingTimeInterval(timeoutSec) + +while Date() < deadline { + // Strategy 1: Find dialog window by name and click via CGEvent mouse + if let dialog = findDialogWindow() { + if clickAllowButton(dialog: dialog) { + Thread.sleep(forTimeInterval: 0.3) + if findDialogWindow() == nil { + jsonOut(["clicked": true, "method": "mouse_click"]) + exit(0) + } + } + } + + // Strategy 2: AX API fallback (older Chrome InfoBar style) + if let button = findAllowButtonAX(appElement) { + let result = AXUIElementPerformAction(button, kAXPressAction as CFString) + if result == .success { + jsonOut(["clicked": true, "method": "accessibility"]) + exit(0) + } + } + + Thread.sleep(forTimeInterval: pollInterval) +} + +exitWithError("Allow button not found within timeout", code: 1) diff --git a/scripts/markus-chrome-allow/markus-chrome-allow b/scripts/markus-chrome-allow/markus-chrome-allow new file mode 100755 index 0000000000000000000000000000000000000000..1227684eb2d21e2ab511c1051e6912c4247bb902 GIT binary patch literal 63856 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 Browser Automation. +On macOS, this requires Accessibility permission — guide the user: +1. Open System Settings > Privacy & Security > Accessibility +2. Add the application running Markus (Markus.app, Terminal, or iTerm) to the allowed list +3. Enable the toggle in Settings > Browser Automation + +On Windows, no additional permissions are needed. + +**Linux:** +Auto-click is not supported on Linux. Recommend using Remote Debugging Port instead: +1. Launch Chrome with `--remote-debugging-port=9222` +2. Set port 9222 in Settings > Browser Automation > Remote Debugging Port + +**All platforms (alternative):** +If the user prefers not to grant accessibility permission, using a dedicated Chrome profile +with `--remote-debugging-port=9222 --user-data-dir=/path/to/markus-profile` eliminates +the permission dialog entirely. ## Prerequisites From a54dd937e0d7a99c35adc5e077f0da9310cf3826 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Tue, 19 May 2026 17:59:13 +0800 Subject: [PATCH 12/19] =?UTF-8?q?fix:=20overhaul=20browser=20automation=20?= =?UTF-8?q?logic=20=E2=80=94=20remove=20blind=20clicks,=20add=20smart=20mu?= =?UTF-8?q?tex,=20fix=20lazy=20startup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Strategy 3 (estimated/blind click) from Swift auto-clicker binary - Restore smart mutex so only one clicker process runs at a time - Only trigger auto-click on initial MCP connection, not every reconnect - Make first agent truly lazy: disconnect after caching tool list - Route global mcpServers chrome-devtools through registerChromeDevtoolsLazy - Skip chrome-devtools in scheduleMcpRelease idle disconnect - Change setReconnector to Map per agent - Add withAgentLock to wrapListPages - Prune ownedPages against actual list_pages after reconnect Co-authored-by: Cursor --- packages/core/src/agent-manager.ts | 134 ++++++++++++------ packages/core/src/tools/browser-session.ts | 116 +++++++++++---- .../core/src/tools/chrome-dialog-clicker.ts | 16 +-- packages/core/src/tools/mcp-client.ts | 85 ++++++++++- scripts/markus-chrome-allow/main.swift | 42 +++++- .../markus-chrome-allow/markus-chrome-allow | Bin 63856 -> 90336 bytes 6 files changed, 313 insertions(+), 80 deletions(-) diff --git a/packages/core/src/agent-manager.ts b/packages/core/src/agent-manager.ts index bbb46e9d..135c9a08 100644 --- a/packages/core/src/agent-manager.ts +++ b/packages/core/src/agent-manager.ts @@ -304,6 +304,7 @@ export class AgentManager { private browserSessionManager: BrowserSessionManager; private remoteDebuggingPort = 0; private autoClickAllowDialog = false; + private chromeAutoClickRunning = false; private globalSecurityPolicy?: SecurityPolicy; private globalMcpServers?: Record; private skillRegistry?: SkillRegistry; @@ -496,6 +497,9 @@ export class AgentManager { this.sharedDataDir = options.sharedDataDir; this.eventBus = options.eventBus ?? new EventBus(); this.mcpManager = new MCPClientManager(); + this.mcpManager.setOnReconnect((serverName) => { + this.triggerChromeDialogAutoClick(serverName); + }); this.browserSessionManager = new BrowserSessionManager(); this.globalSecurityPolicy = options.securityPolicy; this.globalMcpServers = options.mcpServers; @@ -612,20 +616,51 @@ export class AgentManager { return { ...config, args }; } - private chromeAutoClickRunning = false; - /** - * Trigger auto-click of Chrome's "Allow remote debugging?" dialog before - * an MCP connection is established. Only fires when the feature is enabled - * and the server is chrome-devtools. Ensures only one instance runs at a time. + * Trigger Chrome dialog auto-click with smart mutex. + * Only one clicker process runs at a time. If triggered while already running, + * it's a no-op (the running clicker will detect the dialog when it appears). */ private triggerChromeDialogAutoClick(serverName: string): void { if (serverName !== 'chrome-devtools' || !this.autoClickAllowDialog) return; if (this.chromeAutoClickRunning) return; this.chromeAutoClickRunning = true; - clickChromeAllowDialog(60).catch(() => {}).finally(() => { - this.chromeAutoClickRunning = false; + clickChromeAllowDialog(60) + .catch(() => {}) + .finally(() => { this.chromeAutoClickRunning = false; }); + } + + /** + * Register chrome-devtools tools for an agent WITHOUT starting the MCP process. + * Uses cached tool descriptors if available; otherwise does one connection to populate cache. + * The MCP process starts lazily on the agent's first browser tool call. + */ + private async registerChromeDevtoolsLazy( + agentId: string, + serverName: string, + serverConfig: { command: string; args?: string[]; env?: Record }, + ): Promise { + let cachedTools = this.mcpManager.getCachedTools(serverName); + if (!cachedTools) { + // First-ever connection: connect to get tool list, then disconnect immediately. + // The MCP process is only needed to discover available tools. + this.triggerChromeDialogAutoClick(serverName); + await this.mcpManager.connectServerScoped(serverName, serverConfig, agentId); + cachedTools = this.mcpManager.getCachedTools(serverName); + await this.mcpManager.disconnectServerScoped(serverName, agentId); + if (!cachedTools) { + throw new Error(`Failed to get tool list from ${serverName}`); + } + } + + // Lazy registration: tools call back to callToolByKey which auto-connects. + let mcpTools = this.mcpManager.registerLazyScoped(serverName, serverConfig, agentId, cachedTools); + mcpTools = this.browserSessionManager.wrapToolHandlers(mcpTools, agentId); + this.browserSessionManager.setReconnector(agentId, serverName, async () => { + await this.mcpManager.disconnectServerScoped(serverName, agentId); + await this.mcpManager.connectServerScoped(serverName, serverConfig, agentId); }); + return mcpTools; } setTaskService(taskService: TaskServiceBridge): void { @@ -888,20 +923,20 @@ export class AgentManager { for (const [serverName, rawServerConfig] of Object.entries(skill.manifest.mcpServers)) { try { let mcpTools: AgentToolHandler[]; - if (isolation === 'per-agent' || isolation === 'pooled') { - const serverConfig = this.enrichChromeDevtoolsConfig(serverName, rawServerConfig); - this.triggerChromeDialogAutoClick(serverName); + const serverConfig = this.enrichChromeDevtoolsConfig(serverName, rawServerConfig); + if (serverName === 'chrome-devtools') { + // Lazy start: register tools now, connect on first call. + // Startup semaphore in MCPClientManager serializes connections. + mcpTools = await this.registerChromeDevtoolsLazy(id, serverName, serverConfig); + } else if (isolation === 'per-agent' || isolation === 'pooled') { await this.mcpManager.connectServerScoped(serverName, serverConfig, id); mcpTools = this.mcpManager.getToolHandlersScoped(serverName, id); mcpTools = this.browserSessionManager.wrapToolHandlers(mcpTools, id); - this.browserSessionManager.setReconnector(id, async () => { - this.triggerChromeDialogAutoClick(serverName); + this.browserSessionManager.setReconnector(id, serverName, async () => { await this.mcpManager.disconnectServerScoped(serverName, id); await this.mcpManager.connectServerScoped(serverName, serverConfig, id); }); } else { - const serverConfig = this.enrichChromeDevtoolsConfig(serverName, rawServerConfig); - this.triggerChromeDialogAutoClick(serverName); await this.mcpManager.connectServer(serverName, serverConfig); mcpTools = this.mcpManager.getToolHandlers(serverName); } @@ -930,26 +965,27 @@ export class AgentManager { const skill = this.skillRegistry?.get(skillName); const isolation = skill?.manifest.isolation ?? 'shared'; for (const [serverName, rawSrvConfig] of Object.entries(mcpServers)) { - if (isolation === 'per-agent' || isolation === 'pooled') { - const srvConfig = this.enrichChromeDevtoolsConfig(serverName, rawSrvConfig); + const srvConfig = this.enrichChromeDevtoolsConfig(serverName, rawSrvConfig); + if (serverName === 'chrome-devtools') { + const chromeTools = await this.registerChromeDevtoolsLazy(id, serverName, srvConfig); + tools.push(...chromeTools); + } else if (isolation === 'per-agent' || isolation === 'pooled') { await this.mcpManager.connectServerScoped(serverName, srvConfig, id); - this.triggerChromeDialogAutoClick(serverName); tools.push(...this.mcpManager.getToolHandlersScoped(serverName, id)); } else { - const srvConfig = this.enrichChromeDevtoolsConfig(serverName, rawSrvConfig); await this.mcpManager.connectServer(serverName, srvConfig); - this.triggerChromeDialogAutoClick(serverName); tools.push(...this.mcpManager.getToolHandlers(serverName)); } } - if (isolation === 'per-agent' || isolation === 'pooled') { + // Wrap with BrowserSessionManager for per-agent tools (tab isolation) + const hasChromeDevtools = Object.keys(mcpServers).includes('chrome-devtools'); + if (!hasChromeDevtools && (isolation === 'per-agent' || isolation === 'pooled')) { tools = this.browserSessionManager.wrapToolHandlers(tools, id); for (const [serverName, rawSrvConfig] of Object.entries(mcpServers)) { const srvConfig = this.enrichChromeDevtoolsConfig(serverName, rawSrvConfig); - this.browserSessionManager.setReconnector(id, async () => { + this.browserSessionManager.setReconnector(id, serverName, async () => { await this.mcpManager.disconnectServerScoped(serverName, id); await this.mcpManager.connectServerScoped(serverName, srvConfig, id); - this.triggerChromeDialogAutoClick(serverName); }); } } @@ -1380,10 +1416,16 @@ export class AgentManager { // Connect MCP servers and register their tools const mcpConfigs = request.mcpServers ?? this.globalMcpServers; if (mcpConfigs) { - for (const [serverName, serverConfig] of Object.entries(mcpConfigs)) { + for (const [serverName, rawServerConfig] of Object.entries(mcpConfigs)) { try { - await this.mcpManager.connectServer(serverName, serverConfig); - const mcpTools = this.mcpManager.getToolHandlers(serverName); + const serverConfig = this.enrichChromeDevtoolsConfig(serverName, rawServerConfig); + let mcpTools: AgentToolHandler[]; + if (serverName === 'chrome-devtools') { + mcpTools = await this.registerChromeDevtoolsLazy(id, serverName, serverConfig); + } else { + await this.mcpManager.connectServer(serverName, serverConfig); + mcpTools = this.mcpManager.getToolHandlers(serverName); + } for (const tool of mcpTools) { agent.registerTool(tool); } @@ -1600,7 +1642,21 @@ export class AgentManager { const isolation = skill.manifest.isolation ?? 'shared'; for (const [serverName, rawServerConfig] of Object.entries(skill.manifest.mcpServers)) { if (serverName === 'chrome-devtools') { - log.info(`Skipping chrome-devtools MCP restore for agent ${id} (lazy connect)`); + mcpConnections.push((async () => { + try { + const serverConfig = this.enrichChromeDevtoolsConfig(serverName, rawServerConfig); + const mcpTools = await this.registerChromeDevtoolsLazy(id, serverName, serverConfig); + const toolNames: string[] = []; + for (const tool of mcpTools) { + agent.registerTool(tool); + toolNames.push(tool.name); + } + agent.activateTools(toolNames); + log.info(`Skill ${skillName} chrome-devtools registered lazily for agent ${id}`); + } catch (error) { + log.warn(`Failed to register chrome-devtools lazily for agent ${id}`, { error: String(error) }); + } + })()); continue; } mcpConnections.push((async () => { @@ -1608,18 +1664,15 @@ export class AgentManager { let mcpTools: AgentToolHandler[]; if (isolation === 'per-agent' || isolation === 'pooled') { const serverConfig = this.enrichChromeDevtoolsConfig(serverName, rawServerConfig); - this.triggerChromeDialogAutoClick(serverName); await this.mcpManager.connectServerScoped(serverName, serverConfig, id); mcpTools = this.mcpManager.getToolHandlersScoped(serverName, id); mcpTools = this.browserSessionManager.wrapToolHandlers(mcpTools, id); - this.browserSessionManager.setReconnector(id, async () => { - this.triggerChromeDialogAutoClick(serverName); + this.browserSessionManager.setReconnector(id, serverName, async () => { await this.mcpManager.disconnectServerScoped(serverName, id); await this.mcpManager.connectServerScoped(serverName, serverConfig, id); }); } else { const serverConfig = this.enrichChromeDevtoolsConfig(serverName, rawServerConfig); - this.triggerChromeDialogAutoClick(serverName); await this.mcpManager.connectServer(serverName, serverConfig); mcpTools = this.mcpManager.getToolHandlers(serverName); } @@ -1650,24 +1703,24 @@ export class AgentManager { const skill = this.skillRegistry?.get(skillName); const isolation = skill?.manifest.isolation ?? 'shared'; for (const [serverName, rawSrvConfig] of Object.entries(mcpServers)) { - if (isolation === 'per-agent' || isolation === 'pooled') { - const srvConfig = this.enrichChromeDevtoolsConfig(serverName, rawSrvConfig); - this.triggerChromeDialogAutoClick(serverName); + const srvConfig = this.enrichChromeDevtoolsConfig(serverName, rawSrvConfig); + if (serverName === 'chrome-devtools') { + const chromeTools = await this.registerChromeDevtoolsLazy(id, serverName, srvConfig); + tools.push(...chromeTools); + } else if (isolation === 'per-agent' || isolation === 'pooled') { await this.mcpManager.connectServerScoped(serverName, srvConfig, id); tools.push(...this.mcpManager.getToolHandlersScoped(serverName, id)); } else { - const srvConfig = this.enrichChromeDevtoolsConfig(serverName, rawSrvConfig); - this.triggerChromeDialogAutoClick(serverName); await this.mcpManager.connectServer(serverName, srvConfig); tools.push(...this.mcpManager.getToolHandlers(serverName)); } } - if (isolation === 'per-agent' || isolation === 'pooled') { + const hasChromeDevtools = Object.keys(mcpServers).includes('chrome-devtools'); + if (!hasChromeDevtools && (isolation === 'per-agent' || isolation === 'pooled')) { tools = this.browserSessionManager.wrapToolHandlers(tools, id); for (const [serverName, rawSrvConfig] of Object.entries(mcpServers)) { const srvConfig = this.enrichChromeDevtoolsConfig(serverName, rawSrvConfig); - this.browserSessionManager.setReconnector(id, async () => { - this.triggerChromeDialogAutoClick(serverName); + this.browserSessionManager.setReconnector(id, serverName, async () => { await this.mcpManager.disconnectServerScoped(serverName, id); await this.mcpManager.connectServerScoped(serverName, srvConfig, id); }); @@ -2273,8 +2326,9 @@ export class AgentManager { if (this.mcpReleaseTimers.has(agentId)) return; const timer = setTimeout(() => { this.mcpReleaseTimers.delete(agentId); - this.browserSessionManager.cleanupAgent(agentId); - this.mcpManager.disconnectAllForScope(agentId).catch(() => {}); + // Disconnect idle non-browser MCP processes. + // chrome-devtools manages its own lifecycle via the MCP idle timer (5min). + this.mcpManager.disconnectAllForScope(agentId, { skip: ['chrome-devtools'] }).catch(() => {}); log.info(`Released scoped MCP processes for idle agent: ${agentId}`); }, AgentManager.MCP_IDLE_GRACE_MS); timer.unref(); diff --git a/packages/core/src/tools/browser-session.ts b/packages/core/src/tools/browser-session.ts index 620eca8f..c628f6f6 100644 --- a/packages/core/src/tools/browser-session.ts +++ b/packages/core/src/tools/browser-session.ts @@ -48,7 +48,7 @@ export class BrowserSessionManager { /** * Per-agent mutex: only one browser operation runs at a time per MCP process. * This prevents session A's "select → operate" from being interleaved with - * session B's operations. + * session B's operations within the same agent. */ private agentLocks = new Map>(); @@ -58,6 +58,7 @@ export class BrowserSessionManager { * going through the ownership check wrapper. */ private selectPageHandlers = new Map(); + private listPageHandlers = new Map(); /** * Per-agent callback to disconnect + reconnect the MCP server process. @@ -66,7 +67,7 @@ export class BrowserSessionManager { * Since handlers look up the server by key dynamically, they automatically * route to the new process after reconnect. */ - private reconnectors = new Map Promise>(); + private reconnectors = new Map Promise>>(); private _bringToFront = false; private _autoCloseTabs = true; @@ -77,11 +78,16 @@ export class BrowserSessionManager { set autoCloseTabs(v: boolean) { this._autoCloseTabs = v; } /** - * Register a reconnect callback for an agent's MCP server. - * Called by agent-manager after wrapToolHandlers. + * Register a reconnect callback for a specific MCP server of an agent. + * Multiple servers can each have their own reconnector without overwriting. */ - setReconnector(agentId: string, callback: () => Promise): void { - this.reconnectors.set(agentId, callback); + setReconnector(agentId: string, serverKey: string, callback: () => Promise): void { + let map = this.reconnectors.get(agentId); + if (!map) { + map = new Map(); + this.reconnectors.set(agentId, map); + } + map.set(serverKey, callback); } // ─── Stale page recovery ────────────────────────────────────────────────── @@ -91,11 +97,13 @@ export class BrowserSessionManager { } /** - * Clear all ownership state for an agent and reconnect its MCP server. - * Returns true if reconnect succeeded. + * Reconnect the MCP server for an agent. Preserves ownership state + * because page IDs remain stable across reconnects (Chrome stays running). + * Only clears the currentPage pointer so the next operation re-selects. */ private async reconnectMcp(agentId: string): Promise { - const reconnect = this.reconnectors.get(agentId); + const map = this.reconnectors.get(agentId); + const reconnect = map?.get('chrome-devtools'); if (!reconnect) { log.warn(`No reconnector available for agent ${agentId}`); return false; @@ -103,11 +111,11 @@ export class BrowserSessionManager { log.info(`Stale page detected for agent ${agentId} — reconnecting MCP server`); - // Clear all session state for this agent + // Only clear currentPage and lastActive (forces re-select on next op). + // Ownership is preserved — page IDs are stable since Chrome is still running. const prefix = `${agentId}::`; - for (const key of [...this.ownedPages.keys()]) { + for (const key of [...this.currentPage.keys()]) { if (key === agentId || key.startsWith(prefix)) { - this.ownedPages.delete(key); this.currentPage.delete(key); } } @@ -116,6 +124,7 @@ export class BrowserSessionManager { try { await reconnect(); log.info(`MCP server reconnected for agent ${agentId}`); + await this.pruneOwnedPages(agentId); return true; } catch (err) { log.error(`Failed to reconnect MCP server for agent ${agentId}: ${err}`); @@ -123,6 +132,35 @@ export class BrowserSessionManager { } } + /** + * After reconnect, call list_pages and prune ownedPages to only valid IDs. + * Pages that were closed while the MCP was disconnected are removed. + */ + private async pruneOwnedPages(agentId: string): Promise { + const listHandler = this.listPageHandlers.get(agentId); + if (!listHandler) return; + + try { + const result = await listHandler.execute({}); + const livePages = this.parsePageEntries(result); + const liveIds = new Set(livePages.map(p => p.id)); + + const prefix = `${agentId}::`; + for (const [key, owned] of this.ownedPages) { + if (key === agentId || key.startsWith(prefix)) { + for (const pageId of owned) { + if (!liveIds.has(pageId)) { + owned.delete(pageId); + log.debug(`Pruned stale page ${pageId} from ${key}`); + } + } + } + } + } catch (err) { + log.warn(`Failed to prune owned pages for ${agentId}: ${err}`); + } + } + // ─── Internal helpers ───────────────────────────────────────────────────── private async withAgentLock(agentId: string, fn: () => Promise): Promise { @@ -216,15 +254,20 @@ export class BrowserSessionManager { handlers.find((h) => (h.name.split('__').pop() ?? h.name) === name); const newPageHandler = findHandler('new_page'); const selectPageHandler = findHandler('select_page'); + const listPageHandler = findHandler('list_pages'); if (selectPageHandler) { this.selectPageHandlers.set(agentId, selectPageHandler); } + if (listPageHandler) { + this.listPageHandlers.set(agentId, listPageHandler); + } return handlers.map((h) => { const baseName = h.name.split('__').pop() ?? h.name; switch (baseName) { case 'new_page': + case 'open_page': return this.wrapNewPage(h, agentId); case 'list_pages': return this.wrapListPages(h, agentId); @@ -293,14 +336,16 @@ export class BrowserSessionManager { ...handler, execute: async (args: Record) => { const ownerKey = this.extractOwnerKey(agentId, args); - let result = await handler.execute(args); + return this.withAgentLock(agentId, async () => { + let result = await handler.execute(args); - if (this.isStalePageError(result)) { - const ok = await this.reconnectMcp(agentId); - if (ok) result = await handler.execute(args); - } + if (this.isStalePageError(result)) { + const ok = await this.reconnectMcp(agentId); + if (ok) result = await handler.execute(args); + } - return this.annotateResponse(result, ownerKey); + return this.annotateResponse(result, ownerKey); + }); }, }; } @@ -325,8 +370,15 @@ export class BrowserSessionManager { if (this.isStalePageError(result)) { const ok = await this.reconnectMcp(agentId); if (ok) { - return 'Browser session was reset because tabs were closed externally. ' - + 'Your previously owned tabs are gone. Call new_page or navigate_page to create a new tab.'; + // Remove only the failed page from ownership + if (pageId !== undefined) { + this.getOwned(ownerKey).delete(pageId); + if (this.currentPage.get(ownerKey) === pageId) { + this.currentPage.delete(ownerKey); + } + } + return `Tab ${pageId ?? 'unknown'} was closed externally. ` + + `${this.ownedPagesSummary(ownerKey)} Call new_page or navigate_page to create a new tab.`; } } @@ -357,8 +409,17 @@ export class BrowserSessionManager { if (this.isStalePageError(result)) { const ok = await this.reconnectMcp(agentId); if (ok) { - return 'Browser session was reset because tabs were closed externally. ' - + 'Your previously owned tabs are gone. Call new_page or navigate_page to create a new tab.'; + // Remove only the failed page from ownership + if (pageId !== undefined) { + this.getOwned(ownerKey).delete(pageId); + } + const remaining = this.getOwned(ownerKey); + if (remaining.size > 0) { + return `Tab ${pageId ?? 'unknown'} was already closed externally. ` + + `${this.ownedPagesSummary(ownerKey)}`; + } + return `Tab ${pageId ?? 'unknown'} was closed externally and you have no remaining tabs. ` + + 'Call new_page or navigate_page to create a new tab.'; } } @@ -505,8 +566,14 @@ export class BrowserSessionManager { if (this.isStalePageError(result)) { const ok = await this.reconnectMcp(agentId); if (ok) { - return 'Browser session was reset because tabs were closed externally. ' - + 'Your previously owned tabs are gone. Call navigate_page or new_page to create a new tab, then retry.'; + // Remove the stale page from ownership + const currentPageId = this.currentPage.get(ownerKey); + if (currentPageId !== undefined) { + this.getOwned(ownerKey).delete(currentPageId); + this.currentPage.delete(ownerKey); + } + return `The tab you were operating on was closed externally. ` + + `${this.ownedPagesSummary(ownerKey)} Call navigate_page or new_page to create a new tab, then retry.`; } } @@ -530,6 +597,7 @@ export class BrowserSessionManager { } this.agentLocks.delete(agentId); this.selectPageHandlers.delete(agentId); + this.listPageHandlers.delete(agentId); this.lastActiveSession.delete(agentId); this.reconnectors.delete(agentId); if (total > 0) { diff --git a/packages/core/src/tools/chrome-dialog-clicker.ts b/packages/core/src/tools/chrome-dialog-clicker.ts index b72fbce0..a0f31557 100644 --- a/packages/core/src/tools/chrome-dialog-clicker.ts +++ b/packages/core/src/tools/chrome-dialog-clicker.ts @@ -152,9 +152,11 @@ export async function testAutoClick(): Promise { /** * Spawn chrome-devtools-mcp, auto-click the Allow dialog, then navigate to a page. + * If Chrome is already in debug mode, connection succeeds without dialog. * Returns whether navigation succeeded and the page title. */ async function runMcpTest(): Promise<{ navigated: boolean; title?: string }> { + const npxCmd = platform() === 'win32' ? 'npx.cmd' : 'npx'; return new Promise((resolveTest, rejectTest) => { @@ -247,24 +249,16 @@ async function runMcpTest(): Promise<{ navigated: boolean; title?: string }> { sendNotification('notifications/initialized', {}); await sendRequest('tools/list', {}); - // open_page (new tab) triggers Chrome CDP connection → "Allow" dialog + // open_page triggers Chrome CDP connection → "Allow" dialog + // Open in foreground so the user can see the test working const navResult = await sendRequest('tools/call', { name: 'open_page', - arguments: { url: 'https://example.com', background: true }, + arguments: { url: 'https://example.com' }, }) as { content?: Array<{ text?: string }> }; const text = navResult?.content?.[0]?.text ?? ''; const navigated = !text.toLowerCase().includes('error'); - // Close the test tab to avoid leaving garbage - const pageIdMatch = text.match(/(\d+):\s*https?:\/\/example\.com/); - if (pageIdMatch) { - await sendRequest('tools/call', { - name: 'close_page', - arguments: { pageId: Number(pageIdMatch[1]) }, - }).catch(() => {}); - } - clearTimeout(timeout); cleanup(); resolveTest({ navigated, title: navigated ? 'example.com' : undefined }); diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 695fc1c3..c5e6060b 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -10,7 +10,7 @@ interface MCPServerConfig { env?: Record; } -interface MCPToolDescriptor { +export interface MCPToolDescriptor { name: string; description: string; inputSchema: Record; @@ -48,11 +48,16 @@ export class MCPClientManager { private stdoutBuffers = new Map(); private idleTimers = new Map>(); private idleTimeoutMs: number = DEFAULT_IDLE_TIMEOUT_MS; + private onReconnectCallback?: (serverName: string) => void; private static scopedKey(name: string, scopeId: string): string { return `${name}::${scopeId}`; } + setOnReconnect(callback: (serverName: string) => void): void { + this.onReconnectCallback = callback; + } + setIdleTimeout(ms: number): void { this.idleTimeoutMs = ms; } @@ -150,6 +155,9 @@ export class MCPClientManager { this.servers.set(key, { process: proc, tools }); this.resetIdleTimer(key); + // Cache tool descriptors by base server name (for lazy registration) + const baseName = key.split('::')[0]; + this.toolCache.set(baseName, tools); log.info(`MCP server ${displayName} connected with ${tools.length} tools`); return tools; @@ -167,10 +175,35 @@ export class MCPClientManager { /** * Connect a scoped (per-agent) instance of the MCP server. * Each (name, scopeId) pair gets its own child process. + * Serialized via startup lock to prevent concurrent connections to the same external resource. */ async connectServerScoped(name: string, config: MCPServerConfig, scopeId: string): Promise { const key = MCPClientManager.scopedKey(name, scopeId); - return this.connectByKey(key, `${name}[${scopeId}]`, config); + let tools: MCPToolDescriptor[] = []; + await this.withStartupLock(name, async () => { + tools = await this.connectByKey(key, `${name}[${scopeId}]`, config); + }); + return tools; + } + + /** + * Startup semaphore: serializes MCP process creation for servers that share + * an external resource (e.g. chrome-devtools → Chrome). Prevents concurrent + * CDP connections from crashing Chrome. + */ + private startupLocks = new Map>(); + + private async withStartupLock(serverName: string, fn: () => Promise): Promise { + const prev = this.startupLocks.get(serverName) ?? Promise.resolve(); + let release: () => void; + const gate = new Promise(r => { release = r; }); + this.startupLocks.set(serverName, gate); + await prev; + try { + await fn(); + } finally { + release!(); + } } private async callToolByKey(key: string, toolName: string, args: Record): Promise { @@ -180,7 +213,13 @@ export class MCPClientManager { const saved = this.serverConfigs.get(key); if (saved) { log.info(`MCP server ${key} not running, auto-reconnecting...`); - await this.connectByKey(key, saved.displayName, saved.config); + const serverName = key.split('::')[0]; + this.onReconnectCallback?.(serverName); + await this.withStartupLock(serverName, async () => { + if (!this.servers.has(key)) { + await this.connectByKey(key, saved.displayName, saved.config); + } + }); server = this.servers.get(key); } if (!server) throw new Error(`MCP server not found: ${key}`); @@ -219,6 +258,26 @@ export class MCPClientManager { })); } + /** + * Register a server config and tool descriptors WITHOUT starting the process. + * Returns tool handlers that will auto-connect on first call via callToolByKey. + * Used for lazy-start servers (e.g. chrome-devtools: only connect when agent + * actually calls a browser tool). + */ + registerLazyScoped(name: string, config: MCPServerConfig, scopeId: string, tools: MCPToolDescriptor[]): AgentToolHandler[] { + const key = MCPClientManager.scopedKey(name, scopeId); + const displayName = `${name}[${scopeId}]`; + this.serverConfigs.set(key, { displayName, config }); + return tools.map((tool) => ({ + name: `${name}__${tool.name}`, + description: `[MCP:${name}] ${tool.description}`, + inputSchema: tool.inputSchema, + execute: async (args: Record) => { + return this.callToolByKey(key, tool.name, args); + }, + })); + } + getToolHandlers(serverName: string): AgentToolHandler[] { return this.getToolHandlersByKey(serverName, serverName); } @@ -239,6 +298,21 @@ export class MCPClientManager { })); } + /** + * Get cached tool descriptors for a server (from any scoped or shared instance). + * Returns undefined if no instance has connected yet. + */ + getCachedTools(serverName: string): MCPToolDescriptor[] | undefined { + for (const [key, server] of this.servers) { + if (key === serverName || key.startsWith(`${serverName}::`)) { + return server.tools; + } + } + return this.toolCache.get(serverName); + } + + private toolCache = new Map(); + async disconnectServer(name: string): Promise { const server = this.servers.get(name); if (server) { @@ -260,10 +334,13 @@ export class MCPClientManager { * Shared (non-scoped) servers are not affected. * The server configs are retained so the server can auto-reconnect on next tool call. */ - async disconnectAllForScope(scopeId: string): Promise { + async disconnectAllForScope(scopeId: string, opts?: { skip?: string[] }): Promise { const suffix = `::${scopeId}`; + const skipSet = opts?.skip ? new Set(opts.skip) : undefined; for (const key of [...this.servers.keys()]) { if (key.endsWith(suffix)) { + const serverName = key.split('::')[0]; + if (skipSet?.has(serverName)) continue; await this.disconnectServer(key); } } diff --git a/scripts/markus-chrome-allow/main.swift b/scripts/markus-chrome-allow/main.swift index 47689353..755755ea 100644 --- a/scripts/markus-chrome-allow/main.swift +++ b/scripts/markus-chrome-allow/main.swift @@ -39,6 +39,44 @@ if args.contains("--open-accessibility") { exit(0) } +// --list-windows: debug mode to show all Chrome windows +if args.contains("--list-windows") { + let chromeDebugApps = NSRunningApplication.runningApplications( + withBundleIdentifier: "com.google.Chrome" + ) + guard let chromeDebug = chromeDebugApps.first else { + jsonOut(["error": "Chrome not running"]) + exit(1) + } + let pid = Int(chromeDebug.processIdentifier) + guard let windowList = CGWindowListCopyWindowInfo([.optionAll], kCGNullWindowID) as? [[String: Any]] else { + jsonOut(["error": "Cannot get window list"]) + exit(1) + } + var windows: [[String: Any]] = [] + for info in windowList { + guard let ownerPid = info[kCGWindowOwnerPID as String] as? Int, ownerPid == pid else { continue } + var entry: [String: Any] = [:] + entry["name"] = info[kCGWindowName as String] as? String ?? "(nil)" + entry["layer"] = info[kCGWindowLayer as String] as? Int ?? -1 + if let boundsDict = info[kCGWindowBounds as String] { + var rect = CGRect.zero + if CGRectMakeWithDictionaryRepresentation(boundsDict as! CFDictionary, &rect) { + entry["x"] = Int(rect.origin.x) + entry["y"] = Int(rect.origin.y) + entry["w"] = Int(rect.width) + entry["h"] = Int(rect.height) + } + } + windows.append(entry) + } + if let data = try? JSONSerialization.data(withJSONObject: windows, options: .prettyPrinted), + let str = String(data: data, encoding: .utf8) { + print(str) + } + exit(0) +} + guard AXIsProcessTrusted() else { exitWithError("Accessibility permission not granted", code: 2) } @@ -61,6 +99,7 @@ let chromePid = Int(chrome.processIdentifier) // Dialog window name patterns (multi-language) let dialogNamePatterns = [ "允许远程调试", + "要允许远程调试吗", "Allow remote debugging", "Allow debugging", "リモートデバッグを許可", @@ -171,4 +210,5 @@ while Date() < deadline { Thread.sleep(forTimeInterval: pollInterval) } -exitWithError("Allow button not found within timeout", code: 1) +jsonOut(["clicked": false, "reason": "no_dialog_found"]) +exit(1) diff --git a/scripts/markus-chrome-allow/markus-chrome-allow b/scripts/markus-chrome-allow/markus-chrome-allow index 1227684eb2d21e2ab511c1051e6912c4247bb902..ecc8126de780fa6c403f8857739ce476c95d1ddd 100755 GIT binary patch literal 90336 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 literal 63856 zcmeHw3w)Htx%cdD0voOY0tpw%a?u8hkYEUbYTeBSLgW(6Mu?QU*7LE&Tf3(*i4&1bG+1Ukong5L=dbcVup<)O|bEx-BK3VxGKq6lF%KeOZq9rDNNtO)o+T>@(P z)#eC(`($~-P)n~5_=UnjufIx`(DKWiCHQ?H2Z6AfpITO8f&Q-cR)%Lf!}YbEpr=yH z&pcc3bIA4-Ea4@A(-|ngt-@L330M1FHJ(ex<2#by7Fm<9`sr0Zk3Sr&zjSH%#&)+}G1N9+h5?&t2oz z@_R(`vq~m}zm|M7zA$SozYioo#a;>b&9BbC+Us{az5dDoWYF^aSn@j}?q+GR>6lMojAf?#Y6kMpre_;te7 zNa6iMNKXd(M|@~KRzK8kE{O7ky)J}F7`kMEJdXNM*i$ntZyMzCmW$d{uT)jfH{i|z zj9tT~i^9_vddq{ZVEy!YT=Z4nac?_0^s|5cJFo)cG+{ z&}7d|buR<{)Gu{Aj6Kz#G3({pDG0i1tGyMybck9}N`OB-)J_?wdw-@H`p{qBeX&pP z%9s3eRs0$I7^u6w(2iu9O>HegE^D`X0C+lWo-gZU{X9ZF>&AfkMe|l=Q;Y#y1!wwH}z@lg?H+o6fp z_(_vFnu&7BMr)iIZ+Pz{>Y1+xeR9XPY=ilCPZ(K)75UghGFw^;$2}UM}jT%X;mor)~>+KGTzo8K_g;kHymstig!B9YF}Xisdn@9kLi;FefY%f;s1KhJ4=sls^lju|YD;o8fN%x-Yy85%}H)}Qrb z+gp-ZPL46sJc?b2C4t{L@PijP!-iSo(}6!1_=x%L^ak_Y?5M$Dd(MLL4CUW|%wHfM zdOu@;&Ra|)qsL9D(ebF)Zn`3xA?vpwEgf;v&-(ss`!Up^vTw`!6i-Ed>sNQZlW`T> z-+KJcZ6h&`7>nD_^?zav)hC{>8rf%G09!~tW@vcF$hNy)Gc@GNb|Ci=)_CxGmJqp~ z;^u>sxXfnA{N@;!n6f!KaFoL;^%pOO7sQ9KR1nv#u2t4d=DYUsTfAEebsP=`7$>8C2Ul&5w2H5 zdc(xIm}jsDuG=B3q4B3VjY+0RvD$81QO|%fH!#*P&XA7zi1BF})L@3)tw$c5TaMs2 zk&jbT4COIat)|h@k!Zhl4B6D^=mf}f+%zW23^%-&p1^M3Wnd*1W_kC!B1m z;5??lHj`kRmPLYB9_k*3owN=oUTM(jXxOOWg?eY$NNc@axY_`+3CbRgPqsV?ujqgEsiY?s&-nNF!gg0=g8wXiT&bX`-KFqUR7NK52z4 zZtl42o%u$#-+bcEZ45pMuL#dz?c2}gIB4l^bHiA}S;kVxe|5lDv0V@E_z8HC9UcCv zBb9i--y{8z9giWMcr~?m>`Nb*7n^V`E1`T^zwlmj9~)tfE8|77+;41cPFlp`hvr%1 zsVGA}e_e&K;o*L4UmE(7bT@184q)-ehD4gjS=wVSVlCJ)ZBOh)__hmsK8=mq#TuHv zx4to*jX7muD=tJZhiD#bM0pd+Losg-Cu~}*TS;KpItf9l0cFOftM+wfIDdzs{ z*`G~gCFzFLQ-3wGlIm$~&wB@0;~B%uo5#4UjbuwS{x5>ZPq<$igSnO#-IFN#PkDQg zH`2gzc5hsI(e+v)eB9XRM6^r3>!--8HnH}Z=+`dT4~^3nq?zxq{t)|+GZ{CrIO!n? zG-blThNPQWTq(B!<;p;m&u(FHD&P7S_FVOazr@a)lA;;kWpU!W-)L&6mpv`^^t_1D3EeOsEn zPd^>=`u{05WzlJrNo>s63f^0viDe9JikWw|$IRI*?tslIYXPkb=m+0d97Ozb`-)M$R{6a!+0oq2y;F3Yal!PuhK)~CtI5v{m;Z2 zPya6F-9mdV=%HpybM@ezF>{&F!Eluhegs(*9Ynh1>UBLFhBAr{zK3{UI`AT&bPz(Q ztcSNDUWPsXN`$5R#d=6Sg~mi#4_(Mp)h+L(&Ozg)>mklro$KLp)FBzkMrr@R zu(wSahx1ICvC)taDK=UzHrox6;sk`rod4*kL)Feev_W$*2Wd2~$tP30w_!b^J(NP& zt6^SS<5t%8e3Qxf-npVzaK17$w%b|5v5k@6v4fV)XK{8?{Nc^uS7v1K_H{O##a`P@ zb=sUS;cR9AHlnVnvHf+dAsgAzPFuvf#m{CN$MLgS6Xf_hXCCETri^V8+NM1OVcIv3 zKp4Uvy!-xnB>eJ-=p$%@+JDtFt}O%Q_`ca>fNviK`yR>n%`udrvAu{;nNMdC-)UOW zc8=2E7irv9Ax`azu}zKs6`1_;W`x9#){*1D_W;wl(w^}P)jsFv9UTuV`<#zaM%m9O z<2nN4nzG{3aoxfBr$zr&)y|I+*!BUKk2v>6^N~(%wV=PhM*MTgkjQNyC3+lq_r?`% zeBFfpr(kb%gTZ|27-J=`zRr%eud`#k@-S{ToUPyBv#97-IJ&%5ouJ{;qj_X)Ht~a9o*BRHDILqQp*P!?mC-k#xlIiFhsNbm@_}1ME zA84Nc>7Lm9A#^4adu7F6(tJoQH8pB|Z_3VuMso|w;{3*C#F@_w-z;lHAgV)*{~7ze~<$G}b9m1Ih^hF^b#9tELfMHfE>&Lu#iFy#-~-#wrjh z{$n}fJ57$Z?;w1{AkJn-JLO4<79*X`X4?>w%mE&bh}wCW7HvjZl99%O`Z5pcv_JU^ z(uJQ9Hr@hE_DJ(U?z@jL?7K}Wd$gji^fP-Edn7+IvIjqNp7S3aoz3N-bLyz*XS626 z{>CA#*V|F87$9}=( zO^JSm@bd*Y@1f4~*t>o)j_n%*pUCA{WQ~u)+<0LF!;uYg6$)R~I+okd5i^~4k>A>% z#qqK}h4y%~NBR;xmt()|G^L)}IhvL124APie9CwQE6HBB7JK4R(K}J*ON`Mbk^k!hQ$KR@SsPKo}J>b;J$ z6VkZ6uu*z%03Udy;)pf=24wqJlqnm|a$3KId5`odH(BxCDCyL}VXUNOtTnz2`HEcC z$z5{2NxV%$rqvpmUP9h=ZQW(sAZ6N5^?H-3Nyu~nWts-FoRbv=jpo0tdFSK^>~~(a z;#_2pWf(T?!1uR(E&Z+WOq3%%kj^OW800B|EVPe1HkQ3W@;!<&tz)e(WMEI-Hk8GM zp2luI1{uC9WqMF0Qx(!OpoeicTjTd*54!26tl^|7>31gwvN+CA+fE{FiXmE(OmMG@f=t!#T8T0RIW- z>v72bvB?x2MKWS+E$#htT83KVK47x#mW`%8Ef~9Fs5b*VXl-^Vye84O8oG_^#~4p9 z>Yn?CHBS8_`I2enI)@ zYZhVjaU%LPi|@1fT;u%5A`fd4;v=KqK-`S}lU`_#jPLh6o#do+N~64L>^I6bM6RnI z!rHwX*zDb=TWDQ7G9^j1uA!gp>x$uPZi3HQ0^d^vAGEmfVqj6@$w>=q;F}s~ucGXG zsQvzE=OlD{1@*laBuPyP86cG%M2wkHX4Wf&?Sw@wnqjTgSl&AIeICzNmQ=@ij7wh^;3RA73 zUHFfm0e5TH-qr3pX@R{d8>7;lr^Lr`ux_A>RhbcYMeju?+P-025WolLCDPQtwI^^kq;FC%P)#yWsB#SZr&o)hao>t`rK?a?7#qL%jtOImv&TAo@57on-wa;@e|QdlgM8xXM0Rxf zPS!C0qxFrgPS!~MeiZ!SGaJhAPK@MgL!T*~+Wj1J0(aE*-+=e9t*K9pIb<|nJ-a`6 zV7ywfMw8EjUnp)evf=IP=A!L<$Y5`bSL8KbpmEt~;P%v$pU;5&Ng(ozWQT_#j}<-#?>!qv7}CC9 z4W4H3Y`v|z%?zFx-&qEfBfZf0(0N6@t|{l=4!hy#S*%COnfCgLGwd2?r3NuZNzr=nycs+{!yMdw z)|vY^gzf8I#X8NX4s3~d9^~%;&JG>lro8`Y#Teg@eK=!y|B83s69>~67%>L+3oYmm z>7Hb-0e|8_@{z0-q|^JMv;FhhXr7b(vMa3dGRS~&;Clz9eKY03&us$_yr1Q^Za&5u zSo;%)NG7Gc`n?qG9g^^!J>w?p3y0XI9LogAcZGG^%cu{36DR*5#<=Sp6@M>LXq!Es znR6zfKP}+F_b?{f!V&xsN6Iscd_&Y2dHv&GPm{iWi2M?|NhJ@Uiw$ql2K zp?b$1iK`RspGEXS*(Tl+Zf4 z8g0}5CI$AeeSi&pq_weYZnin|u{PTI9OZskz6Z>>c-K4WiR|_=%qx7azukpUt8e7t z{7v&l=+tt~y6rpoPDgySWzqMjaga4$K7_^R(>)680ckwwJFa$ISb}wrC4ucx#sjvp zmt8~eBiTOUt=I|dYcD$leITTBRu1H$vSyc|(^jbb1fFkbfF175YZ}&&4Sg6OO9tdf z!uWfcd6p5jY-Q=#$6Di8qYrBNuSFW(D{^_SLK|gy@Pn#%om_8_nL%cXA7m`s9I?*c zL?N@jfXlSQD4qN>g~yPdk-%PQ9YN`^3&XC!8;c6B_G3PY>e$M`-1xPPIN{A5 zSHmw|4Zk$SFyQ->AGKZpZ+!0)GM!hw52klF=V8-#!w!E8-aA!(>ZJ5jbT*=QSFO@k z&f^_%lH{e8lje>EqsC z9*-|o#h0t%x2ocms`zcHxL*|ys^Y6u@wKY>U8?v7Rs4IZ`1e)u2UPJFRqmpH%VVs`$UD;xSeHyefV{75_>VAESzoSH&l);#aBS(^T;ps(6kn zK1UV*wkn>diuY5+huFHtcZe$eXR3Hq6@Nw*e@+$ur7AvL#dDM@K1LNEuZmAp#jjGu zuU5sUsp2zK@f=lrjw=3bRXk4>pQno7sERLE#fw$(rKF$9xe_WI+>;L~Kp) zmHfe8fIkKqD(Uc5{K5Mf{_J>^o<>~n5l@ZF>u331moHGY3_nu|ti~?}>`Ep=Tj|Y( zbA6r~+`VLrRiM~Y$sA$)W@BEy7Z*eWeq0u3i`7Uep-3Q@{`ua!Tc0`5`s!nU*}b`S z@5a_&{7BUpwdeB%R$GFenn2iNaeK<^s;atvyoLM|8>1&S{^Z2QmriWlbYkOuCpJEK zV&ldW8}^>qu&s6Xvw!}zdqM!IkkQJspszb>kIc+^=|J2Mf%uOFsW1p-w*&op~=FrfVGrn88Ux4bSK4)`tpK-f}A zH_I)nz2R!F-xBuLcmj1$Q|BX?SiB)#I9TWRck3RrsRgm%CuTKX$R&AK1zrBI$KA;c zku}%2a4X(3EyVBm*WyPc_*I%8otZlqg=kbQ!QN@hE#jdd4;c(X1h6|YS1w!?;1jn3TID%(v7_TAfHNH(9S0F-Z5<6p2Li zn@@Tuu71cf3}U008yhb?iSEJpEze+P3J+z5y~9}I)1z3zBdLt-Ol5|5aiQtVSk`a* zIF?X)1?&IP70gsSo|#wRP!mpPNr%!Ib6c398XK?DNo>H8Nx0iRnHf{?^Cq;DFdtzp z=n?sIC+so!V^~|v3~R<4k_V;p-wmZ9H5-R4dY+VJ*or|{BtLHWvGIqd?FkPhKHTq- z{*Rh>;O!os$)JUx2o7lUoTfy2mMC>dHKH61%!-&OSHe^A&%#T~(eI4XFz)oMQOd1R z%9$P#9F+_`)HR@M;Qy@#k|Sc@l^hX1F*zc3J;@O$!?EOu*oP%Y;4ASYN3iPg zkk&xL!mA`l=y!^Q2gwkwkz@M3A*I8;@=*A(U_8kY_&*+wl;J2D!mZ*- zj*!2jaI_5ZZH8mGTON|%r+oSy359r3%0swY9$I7ww~Hq^GEs(bzZ{c)r1UFgc$Eyl zA;SzAPLbi&GW@0tr^@ge8BUYobQxyKFiVCrWGI%LZ}QWd7SD3JWE^QHBd- zNNWq_FOuP685YTKi418kLgk8O=#ZgUXObf~OT1Ku%Vbz8!{su(g@;yYuMD5m#Ut#B zB`1^O%oeQL{5eAZzXWEs%6)t1hW&5C9$4}cSw2;i7m7D0vj`usWcIWS_g*G^N#=jZ z!zIij_;rT;J9i@@BbN^VO36*iMLxh51Le}3qj@82pHlw&pYQM{EfJHHZ!$MLYp;T6 z%a;g4?XkSIKfXfgkFEh-1G)xu4d@!sHK1!i*MP18T?4uXbPebl&^4fIK-Yk-0bK*S z26PSR8qhVMYe3h4t^r*Gx(0L&=o-*9pld+afUW^u1G)xu4d@!sHK1!i*MP18T?4uX zbPebl&^4fIK-Yk-0bK*S26PSR8qhVMYe3h4u7UrB8raQ$SDefqlHpMqu9Ttpd+o_= zE64adjR!h9;`sl1D5^RFd=~gUg!o^>bWnbqD!*B#E9I4t>V1xK?;^aQDzD`0p{@a4 z1G)xu4d@!sHK1!i*MP18T?4uXbPebl&^4fIK-Yk-0bK*S26PSR8qhVMYe3h4t^r*G zx(0L&=o-*9pld+afUW^u1G)xu4d@!sHK1!i*MP18T?4uXbPebl&^4fIK-Yk-0bK*S z26PSR8qhVMYe3h4t^r*Gx(0L&=o<=0{$=ky>apP z>gC@ax8l!qTe}KSJ^nYzDLv9X-GILnZkP<35Ax&Bny&>lF=jdk8fRdp<)DWQEa5E! zOFR$CFtWrvkQ;Ol=v~kt6HA;1T4-YZf}rD|g$b-Q7T8PK#O*6&ZCwF6kcBcPOltbZQpR?r=wM?lXFWabN?s|GQ1Ij9!& zJk_dZ3n%T!jeuSylNyfF9kg~k};># zSy8<*6kh3cyMn8|eo6?1T@@=S*;(oG`nso7)wzOh=Jc%bhMBV{??$`RzR>1yI43(7 zJBsW4ey_jER$J@yR=C360Jv$2EDHozhH70E9_F-_7KWAt0~MZ7s3cey3VXoTR(f;c zT%V`L;}6>dwe_}eIOr{}3wxHje097^S9Z|j3VXUWrK-Hd6RZpbYit!g3SkQdUG>aq zFPOWE8seN51?oZ`jyp4!1VZSey`b1r5ianA=X<Q)u7{@`9-V! zp5T(geCC`S%F3Kar8)ZWzNlX);a>2_Ds8@YM!>< zwKo?pT$(l8SswJdt2`xvqVn6&Tkk55-4U3%*nwPoeb1G0LKqtiHn=(*)y{B5rM*Yy zi(EY?!#28iAv@m{b}hX&$64n`SKMA2I+xE^pT|X;7Yx*N30IG1138NwRE4^=yeDR! zrCGTyw;Mv1cxya`{;(&w%H^Y~?n}BvjibCT;H`I*+w4x8J=_~po4vk=z6u7SvyOcA z(9G>cy;(CLf2XxbH9H(%r)utEhl6Xs=UQJ=hYp7$(8Wp7qDF#j*q1BUgCQYBqDqy` zCPu2->2TO=b93j)yrqf>XgG8g$gT@l=GyG5s`zmAT1+a@)lC!EW|M*UZ57TOOt?B< z*ir5X%v$W=)7G?DG0Uu%g@DPN$>3 z82|cUZW=#$qoZiC!xQwneBQM^d&-h(_$9c+K>2MI&YDn_!{b*_pf>Cbd3=>JGwAVo zTp@S~UNq<-0lEQOT{s{Mxr;sFx}abB)5#$k1YcmaCs+XvZihomc84Ro+7+tK_WHwt z`cTe%#67{KYn@>}Zyb)rG~Hke^Fzy)$Y_0NQ3a2di%c&MLRne%KuwLy?_TKjdx~be zf>m|o+(Xc(!%-kD0i~S6o(t;2p`}?l&JbiGZ)z9G^=sBRmehx`F^6-P7Az>8yMRjeURrSx1_dI|xE5{u!``YotTWWaWse-cZ0_%x6TA7`f2QS}+bfXNNrC>_U&SGT{|#ync$; z*Vnq8ju{SFRvGr0ur-g{mPh@Qj<3@sW;-iU7dGs1c!lXe&3xqRYY{j@tG$)sxiz)n zdX+vPy3Z4)&4I{Acild3IrTama62nVy*`*Tuk7UN7M@>8D)&@*{k96)IdmrRMM4xS zR_23XAeOdnSx&UwQ>K_Xz2$V(a@YG^HQoxli#7iAwjGG$)UWW zWlM2raI)caq`*0Xnb;%a5p&j5W54RDz&_Gh4WH@>vZNaFk%Jiud^{%qZPQB$7n4pK z%=^pe%=@MU*I78w= zS)zQl#0w=}EOD8{Yk^7rh{QjV`AH$0EHfh7|Cz+O68~CatHhs5JYV7wqecBC5>Jr0 zRN`wSE|d61iK`{9khoUjJ0)Ht@e>k9B>q2yVdI-5J}B{n5`Ql7BN7i8Bg*fT_)3X) zOMIimdnFD_d_dwKNZcebEhj?_aB_HPeqp$Q@g55{MR=S@#|tg-lxXn%8vLvVAJX8D zG~~W*fsc84X)JS8iAoD@!YM!J2m(f4gNr2NF|=nH25nG9@?MF z+gUzcg9|jcLSSfBJoN&Dt9brFU|5-WUeV4t^XGd4V?`2ARN#RMJ}hvj{4s$CDfz>}$-u1G<%ws8z}U5kr&wTk7xAnXSU4ag zJtpulCI7I%!xel=V0cXNq(D-%m!jY-fw3xzX9>q$_T?A2Q-5KBJIC{04c@N7KNlFI zAfEji{DuZMYw$-JY=UOh_CHF4r)Y4#2D>zPod!Rw!BGwVjRyZwgWEJX1tYDNca8?% zq`}o1yjp|r)!>IT_&E(etidh}Lpi7d-T?g?^c&D`L5DzZg5Cn{ z08yKdfvC-gL63s8^8N^Ey~+FwPZjEIMgA36=*eR zEodF6r}5Z;^m{9O0%kKJ_o*FRnS&-`@J$7>n+ zh6SUif8S7%O+vhqL z7TNM~{e*w3pl=FYmm-u;3C^Ai7S`Z8FoEH~YPGZH7KC6}s`6)?4ZI1o4fn z*QQIC;L=C0xz4;guMb};ot5-$r;mbxzS;e39bX+18fDpn|i2kbXiIDb(X$PX4BDU)@}GAUsM;a zuXp6*{zH9j0l&t;^RlibBc@9a{Nk1~8#it|p_z+4xYSjI3wEKIxczYLjHTl1yr`0c zukY@BTp|fniQ8m0TV_Rm=2AzM_!uusRp2__O8SDkIarca(7JFfXs*-Y4bQ8zWjZ_ioAr0G=&IgbxU4l< z^)pw~rMsMJeizSX@8!7Sv|W3na#un0zqw@jrfhEuiFAynzj|(WXf9I_mv?>If;}xBO6CpcfG^ys9IBj z8(M39b(IxRM{Rv&PC@D1%A1|H&E(gO?6Z~%j4X${prkUZ!cUh#P`xDNxJglWaqZfm zuWLBy0%6`f`iJvU#-LzkiS0j7wWNku4$R2x>7K2f?#wApiLW^DNKV{#tn)a_`Q50l z?Al;}?s#f)`Kf-EyYoKcLN6``xqR?bbhl}Z^LA(0&F}wUTw=!7t zNUQ=&AIH;6>R_4tn&p*MRmB3+k4slz>0#AUmE(mhJ&o!mg?jPK+HzunxAc4&P*qr{ zq|GT40~KYc+MrB$x*e4|A@lT z>x#ON47(3MrT8+zr>}x=V6ri}>z7C@e7qgnC0x3zR;pc^vq;qHX}J_~_jmBCiYwLOTR zZh(R6x$vh!O}IK5L6SixryvdY4~#x$OrZOvh%+CkROTl$cK(dT$nfR$>pYV)Whq0A zE4Z>QbdAu>HMDzM%~qzrcJJhxd;gS?IkWYbFRpm!t}O$9?p}XY-OBskzw6o4@89$N zoR4c`zx~1B70 Date: Tue, 19 May 2026 23:08:36 +0800 Subject: [PATCH 13/19] fix: extend MCP initialize/tools/list timeout to 60s for chrome-devtools startup The 30s timeout was too short when npx needs to download chrome-devtools-mcp on first run, causing the connection to fail before the auto-clicker can dismiss Chrome's "Allow debugging" dialog. Co-authored-by: Cursor --- packages/core/src/tools/mcp-client.ts | 2 +- packages/remote/src/agent.ts | 92 ++++++++++++++++++++++++--- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index c5e6060b..8990eadc 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -407,7 +407,7 @@ export class MCPClientManager { const id = ++this.requestId; const message = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'; - const timeoutMs = method === 'tools/call' ? 120_000 : 30_000; + const timeoutMs = method === 'tools/call' ? 120_000 : 60_000; const timer = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`MCP request timeout for ${method} (id=${id}, ${timeoutMs}ms)`)); diff --git a/packages/remote/src/agent.ts b/packages/remote/src/agent.ts index 66ef1c98..b63a1040 100644 --- a/packages/remote/src/agent.ts +++ b/packages/remote/src/agent.ts @@ -33,6 +33,8 @@ const STUN_SERVERS = [ const RECONNECT_BASE_MS = 2_000; const RECONNECT_MAX_MS = 60_000; const HEARTBEAT_INTERVAL_MS = 25_000; +const DC_PING_INTERVAL_MS = 15_000; +const DC_PING_TIMEOUT_MS = 10_000; export interface RemoteAccessConfig { hubUrl: string; @@ -81,6 +83,8 @@ interface PeerSession { markusToken: string | null; connectedAt: number; lastActiveAt: number; + pingTimer: ReturnType | null; + lastPong: number; } export class RemoteAccessAgent { @@ -424,7 +428,7 @@ export class RemoteAccessAgent { } satisfies RtcConfig); const now = Date.now(); - const session: PeerSession = { pc, dc: null, pendingChunks: new Map(), markusToken: null, connectedAt: now, lastActiveAt: now }; + const session: PeerSession = { pc, dc: null, pendingChunks: new Map(), markusToken: null, connectedAt: now, lastActiveAt: now, pingTimer: null, lastPong: now }; this.peers.set(peerId, session); pc.onStateChange((state: string) => { @@ -450,9 +454,12 @@ export class RemoteAccessAgent { pc.onDataChannel((dc: DataChannel) => { log.info('DataChannel opened', { peerId, label: dc.getLabel() }); session.dc = dc; + session.lastPong = Date.now(); + this.startDcPing(peerId, session); dc.onMessage((msg: string | Buffer) => { const data = typeof msg === 'string' ? msg : msg.toString('utf-8'); + session.lastActiveAt = Date.now(); this.handleDataChannelMessage(peerId, data); }); @@ -469,6 +476,7 @@ export class RemoteAccessAgent { const session = this.peers.get(peerId); if (!session) return; + if (session.pingTimer) clearInterval(session.pingTimer); try { session.dc?.close(); } catch { /* ignore */ } try { session.pc.close(); } catch { /* ignore */ } this.peers.delete(peerId); @@ -476,6 +484,29 @@ export class RemoteAccessAgent { log.info('Peer cleaned up', { peerId }); } + private startDcPing(peerId: string, session: PeerSession): void { + if (session.pingTimer) clearInterval(session.pingTimer); + session.pingTimer = setInterval(() => { + if (!session.dc || !session.dc.isOpen()) { + log.warn('DC not open during ping check, cleaning up', { peerId }); + this.cleanupPeer(peerId); + return; + } + const elapsed = Date.now() - session.lastPong; + if (elapsed > DC_PING_TIMEOUT_MS) { + log.warn('DC ping timeout, peer unresponsive', { peerId, elapsed }); + this.cleanupPeer(peerId); + return; + } + try { + session.dc.sendMessage(JSON.stringify({ type: '__ping' })); + } catch { + log.warn('DC ping send failed', { peerId }); + this.cleanupPeer(peerId); + } + }, DC_PING_INTERVAL_MS); + } + // ── DataChannel Message Handling (HTTP/WS proxy) ───────────────────────── private handleDataChannelMessage(peerId: string, raw: string): void { @@ -487,6 +518,11 @@ export class RemoteAccessAgent { const type = msg.type as string; switch (type) { + case '__pong': { + const s = this.peers.get(peerId); + if (s) s.lastPong = Date.now(); + return; + } case 'http': this.proxyHttpRequest(peerId, msg); break; @@ -557,18 +593,47 @@ export class RemoteAccessAgent { headers: { ...headers, host: `127.0.0.1:${this.config.localPort}`, cookie }, }, (res: IncomingMessage) => { - const chunks: Buffer[] = []; - res.on('data', (c: Buffer) => chunks.push(c)); - res.on('end', () => { - const bodyStr = Buffer.concat(chunks).toString('base64'); + const contentType = res.headers['content-type'] ?? ''; + const isStreaming = contentType.includes('text/event-stream') || + contentType.includes('application/x-ndjson') || + res.headers['transfer-encoding'] === 'chunked' && contentType.includes('stream'); + + if (isStreaming) { this.sendToPeer(peerId, { - type: 'http_response', + type: 'http_response_start', id: reqId, status: res.statusCode ?? 200, headers: res.headers, - body: bodyStr, }); - }); + + res.on('data', (c: Buffer) => { + this.sendToPeer(peerId, { + type: 'http_response_chunk', + id: reqId, + data: c.toString('base64'), + }); + }); + + res.on('end', () => { + this.sendToPeer(peerId, { + type: 'http_response_end', + id: reqId, + }); + }); + } else { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const bodyStr = Buffer.concat(chunks).toString('base64'); + this.sendToPeer(peerId, { + type: 'http_response', + id: reqId, + status: res.statusCode ?? 200, + headers: res.headers, + body: bodyStr, + }); + }); + } } ); @@ -593,7 +658,16 @@ export class RemoteAccessAgent { const path = (msg.path as string) ?? '/ws'; const wsUrl = `ws://127.0.0.1:${this.config.localPort}${path}`; - const ws = new WebSocket(wsUrl); + const session = this.peers.get(peerId); + if (session && !session.markusToken) { + session.markusToken = this.generateMarkusToken(); + } + const headers: Record = {}; + if (session?.markusToken) { + headers['cookie'] = `markus_token=${session.markusToken}`; + } + + const ws = new WebSocket(wsUrl, { headers }); const key = `${peerId}:${wsId}`; ws.on('open', () => { From 226f4200acf743c85afeb884cec7439d59166fd9 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 20 May 2026 10:46:49 +0800 Subject: [PATCH 14/19] fix: keep peer session alive on ICE failure, relay-capable ping/pong - On WebRTC failed/closed: close PC/DC resources but keep PeerSession alive for relay fallback (don't destroy markusToken) - Rename startDcPing to startPeerPing: sends ping via sendRaw which falls back to relay, starts on auth not DC open - Add 5-minute relay inactivity timeout for abandoned sessions - Handle relay frames from unknown peers (create relay-only session) - Preserve session state on ICE restart (new offer for existing peer) - Clean up proxied WebSocket connections on peer cleanup Co-authored-by: Cursor --- packages/remote/src/agent.ts | 97 +++++++++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 24 deletions(-) diff --git a/packages/remote/src/agent.ts b/packages/remote/src/agent.ts index b63a1040..ba116fa1 100644 --- a/packages/remote/src/agent.ts +++ b/packages/remote/src/agent.ts @@ -33,8 +33,9 @@ const STUN_SERVERS = [ const RECONNECT_BASE_MS = 2_000; const RECONNECT_MAX_MS = 60_000; const HEARTBEAT_INTERVAL_MS = 25_000; -const DC_PING_INTERVAL_MS = 15_000; -const DC_PING_TIMEOUT_MS = 10_000; +const PEER_PING_INTERVAL_MS = 15_000; +const PEER_PING_TIMEOUT_MS = 10_000; +const RELAY_INACTIVITY_TIMEOUT_MS = 5 * 60_000; export interface RemoteAccessConfig { hubUrl: string; @@ -77,7 +78,7 @@ interface RegistrationResult { } interface PeerSession { - pc: PeerConnection; + pc: PeerConnection | null; dc: DataChannel | null; pendingChunks: Map; markusToken: string | null; @@ -168,7 +169,7 @@ export class RemoteAccessAgent { for (const [peerId, session] of this.peers) { try { session.dc?.close(); } catch { /* ignore */ } - try { session.pc.close(); } catch { /* ignore */ } + try { session.pc?.close(); } catch { /* ignore */ } this.peers.delete(peerId); } @@ -387,14 +388,23 @@ export class RemoteAccessAgent { let session = this.peers.get(peerId); if (!session) { session = this.createPeerConnection(peerId); + } else if (!session.pc) { + // ICE restart: create new PC but preserve existing session state (markusToken, etc.) + log.info('Received offer for relay-only peer, upgrading to P2P', { peerId }); + const newSession = this.createPeerConnection(peerId); + newSession.markusToken = session.markusToken; + newSession.connectedAt = session.connectedAt; + newSession.lastActiveAt = session.lastActiveAt; + if (session.pingTimer) clearInterval(session.pingTimer); + session = newSession; } - session.pc.setRemoteDescription(sdp, DescriptionType.Offer); + session.pc!.setRemoteDescription(sdp, DescriptionType.Offer); } private handleIce(peerId: string, candidate: string, mid?: string): void { const session = this.peers.get(peerId); - if (!session) return; + if (!session?.pc) return; session.pc.addRemoteCandidate(candidate, mid ?? '0'); } @@ -434,7 +444,7 @@ export class RemoteAccessAgent { pc.onStateChange((state: string) => { log.debug('Peer state change', { peerId, state }); if (state === 'failed' || state === 'closed') { - this.cleanupPeer(peerId); + this.handlePcFailed(peerId); } this.emitStatus(); }); @@ -455,7 +465,7 @@ export class RemoteAccessAgent { log.info('DataChannel opened', { peerId, label: dc.getLabel() }); session.dc = dc; session.lastPong = Date.now(); - this.startDcPing(peerId, session); + this.emitStatus(); dc.onMessage((msg: string | Buffer) => { const data = typeof msg === 'string' ? msg : msg.toString('utf-8'); @@ -464,47 +474,68 @@ export class RemoteAccessAgent { }); dc.onClosed(() => { - log.info('DataChannel closed', { peerId }); - this.cleanupPeer(peerId); + log.info('DataChannel closed, keeping session for relay', { peerId }); + session.dc = null; + this.emitStatus(); }); }); return session; } + private handlePcFailed(peerId: string): void { + const session = this.peers.get(peerId); + if (!session) return; + + log.info('WebRTC failed, keeping session alive for relay', { peerId }); + try { session.dc?.close(); } catch { /* ignore */ } + session.dc = null; + try { session.pc?.close(); } catch { /* ignore */ } + session.pc = null; + } + private cleanupPeer(peerId: string): void { const session = this.peers.get(peerId); if (!session) return; if (session.pingTimer) clearInterval(session.pingTimer); try { session.dc?.close(); } catch { /* ignore */ } - try { session.pc.close(); } catch { /* ignore */ } + try { session.pc?.close(); } catch { /* ignore */ } + for (const [key, ws] of this.wsConnections) { + if (key.startsWith(`${peerId}:`)) { + ws.close(); + this.wsConnections.delete(key); + } + } this.peers.delete(peerId); this.emitStatus(); log.info('Peer cleaned up', { peerId }); } - private startDcPing(peerId: string, session: PeerSession): void { + private startPeerPing(peerId: string, session: PeerSession): void { if (session.pingTimer) clearInterval(session.pingTimer); + session.lastPong = Date.now(); session.pingTimer = setInterval(() => { - if (!session.dc || !session.dc.isOpen()) { - log.warn('DC not open during ping check, cleaning up', { peerId }); + const now = Date.now(); + + // Check inactivity — clean up if no messages for RELAY_INACTIVITY_TIMEOUT_MS + if (now - session.lastActiveAt > RELAY_INACTIVITY_TIMEOUT_MS) { + log.info('Peer inactive for too long, cleaning up', { peerId }); this.cleanupPeer(peerId); return; } - const elapsed = Date.now() - session.lastPong; - if (elapsed > DC_PING_TIMEOUT_MS) { - log.warn('DC ping timeout, peer unresponsive', { peerId, elapsed }); + + // Check pong timeout + const elapsed = now - session.lastPong; + if (elapsed > PEER_PING_TIMEOUT_MS) { + log.warn('Peer ping timeout, unresponsive', { peerId, elapsed }); this.cleanupPeer(peerId); return; } - try { - session.dc.sendMessage(JSON.stringify({ type: '__ping' })); - } catch { - log.warn('DC ping send failed', { peerId }); - this.cleanupPeer(peerId); - } - }, DC_PING_INTERVAL_MS); + + // Send ping via whatever transport is available (DC or relay) + this.sendRaw(peerId, JSON.stringify({ type: '__ping' })); + }, PEER_PING_INTERVAL_MS); } // ── DataChannel Message Handling (HTTP/WS proxy) ───────────────────────── @@ -547,6 +578,21 @@ export class RemoteAccessAgent { } private handleRelayFrame(peerId: string, data: string): void { + if (!this.peers.has(peerId)) { + log.info('Relay frame from unknown peer, creating relay-only session', { peerId }); + const now = Date.now(); + this.peers.set(peerId, { + pc: null, + dc: null, + pendingChunks: new Map(), + markusToken: null, + connectedAt: now, + lastActiveAt: now, + pingTimer: null, + lastPong: now, + }); + this.emitStatus(); + } this.handleDataChannelMessage(peerId, data); } @@ -562,6 +608,9 @@ export class RemoteAccessAgent { if (session && !session.markusToken) { session.markusToken = this.generateMarkusToken(); } + if (session && !session.pingTimer) { + this.startPeerPing(peerId, session); + } this.sendToPeer(peerId, { type: 'auth_ok', instanceName: this.config.instanceName ?? 'My Markus', From 4b32993da018e00bb6f314d974ff99afad096e71 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 20 May 2026 11:28:17 +0800 Subject: [PATCH 15/19] fix: ping timeout killing P2P connections immediately after open Send first ping immediately on startPeerPing so the client can respond before the first interval check. Change timeout threshold from 10s to 25s (interval + timeout) to allow a full ping cycle before declaring the peer unresponsive. Co-authored-by: Cursor --- packages/remote/src/agent.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/remote/src/agent.ts b/packages/remote/src/agent.ts index ba116fa1..f44f5c82 100644 --- a/packages/remote/src/agent.ts +++ b/packages/remote/src/agent.ts @@ -387,9 +387,9 @@ export class RemoteAccessAgent { private handleOffer(peerId: string, sdp: string): void { let session = this.peers.get(peerId); if (!session) { + log.info('Received offer, creating new peer connection', { peerId }); session = this.createPeerConnection(peerId); } else if (!session.pc) { - // ICE restart: create new PC but preserve existing session state (markusToken, etc.) log.info('Received offer for relay-only peer, upgrading to P2P', { peerId }); const newSession = this.createPeerConnection(peerId); newSession.markusToken = session.markusToken; @@ -397,6 +397,8 @@ export class RemoteAccessAgent { newSession.lastActiveAt = session.lastActiveAt; if (session.pingTimer) clearInterval(session.pingTimer); session = newSession; + } else { + log.info('Received offer for existing peer (ICE restart)', { peerId }); } session.pc!.setRemoteDescription(sdp, DescriptionType.Offer); @@ -404,7 +406,10 @@ export class RemoteAccessAgent { private handleIce(peerId: string, candidate: string, mid?: string): void { const session = this.peers.get(peerId); - if (!session?.pc) return; + if (!session?.pc) { + log.warn('Received ICE candidate but no PC', { peerId, hasSession: !!session }); + return; + } session.pc.addRemoteCandidate(candidate, mid ?? '0'); } @@ -442,7 +447,7 @@ export class RemoteAccessAgent { this.peers.set(peerId, session); pc.onStateChange((state: string) => { - log.debug('Peer state change', { peerId, state }); + log.info('Peer RTC state', { peerId, state }); if (state === 'failed' || state === 'closed') { this.handlePcFailed(peerId); } @@ -450,14 +455,16 @@ export class RemoteAccessAgent { }); pc.onGatheringStateChange((state: string) => { - log.debug('ICE gathering state', { peerId, state }); + log.info('ICE gathering', { peerId, state }); }); pc.onLocalDescription((sdp: string, type: DescriptionType) => { + log.info('Sending local description', { peerId, type: type as string }); this.send({ type: type as string, peerId, sdp }); }); pc.onLocalCandidate((candidate: string, mid: string) => { + log.info('Sending ICE candidate', { peerId, candidate: candidate.slice(0, 60) }); this.send({ type: 'ice', peerId, candidate, mid }); }); @@ -515,6 +522,10 @@ export class RemoteAccessAgent { private startPeerPing(peerId: string, session: PeerSession): void { if (session.pingTimer) clearInterval(session.pingTimer); session.lastPong = Date.now(); + + // Send first ping immediately so we get a pong before the first timeout check + this.sendRaw(peerId, JSON.stringify({ type: '__ping' })); + session.pingTimer = setInterval(() => { const now = Date.now(); @@ -525,9 +536,9 @@ export class RemoteAccessAgent { return; } - // Check pong timeout + // Check pong timeout — only if we've sent at least one ping and waited long enough const elapsed = now - session.lastPong; - if (elapsed > PEER_PING_TIMEOUT_MS) { + if (elapsed > PEER_PING_INTERVAL_MS + PEER_PING_TIMEOUT_MS) { log.warn('Peer ping timeout, unresponsive', { peerId, elapsed }); this.cleanupPeer(peerId); return; From 10bc60fe2c6bcc079308888bc7b04034f014ca12 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 20 May 2026 11:42:04 +0800 Subject: [PATCH 16/19] fix: handle stream error in proxy to close remote stream properly Co-authored-by: Cursor --- packages/remote/src/agent.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/remote/src/agent.ts b/packages/remote/src/agent.ts index f44f5c82..0bc02a7b 100644 --- a/packages/remote/src/agent.ts +++ b/packages/remote/src/agent.ts @@ -680,6 +680,13 @@ export class RemoteAccessAgent { id: reqId, }); }); + + res.on('error', () => { + this.sendToPeer(peerId, { + type: 'http_response_end', + id: reqId, + }); + }); } else { const chunks: Buffer[] = []; res.on('data', (c: Buffer) => chunks.push(c)); From fe538f3f9a9b37c727d13cb37b05e58e9fa1d089 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 20 May 2026 12:36:23 +0800 Subject: [PATCH 17/19] fix: prevent markdown image flickering during streaming re-renders Use a module-level cache to remember loaded images so re-mounts start in the loaded state instead of flashing the placeholder. Co-authored-by: Cursor --- packages/web-ui/src/components/MarkdownMessage.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/web-ui/src/components/MarkdownMessage.tsx b/packages/web-ui/src/components/MarkdownMessage.tsx index 30e05c74..8f51fe01 100644 --- a/packages/web-ui/src/components/MarkdownMessage.tsx +++ b/packages/web-ui/src/components/MarkdownMessage.tsx @@ -192,15 +192,17 @@ function localImageUrl(filePath: string): string { return `/api/files/image?path=${encodeURIComponent(filePath)}`; } -function MarkdownImage({ src, alt, onPreview, basePath }: { src: string; alt?: string; onPreview?: (src: string) => void; basePath?: string }) { - const [loaded, setLoaded] = useState(false); - const [error, setError] = useState(false); +const loadedImageCache = new Set(); +function MarkdownImage({ src, alt, onPreview, basePath }: { src: string; alt?: string; onPreview?: (src: string) => void; basePath?: string }) { const effectiveSrc = useMemo(() => { if (!isLocalImagePath(src)) return src; return localImageUrl(resolveImagePath(src, basePath)); }, [src, basePath]); + const [loaded, setLoaded] = useState(() => loadedImageCache.has(effectiveSrc)); + const [error, setError] = useState(false); + return ( {!loaded && !error && ( @@ -220,7 +222,7 @@ function MarkdownImage({ src, alt, onPreview, basePath }: { src: string; alt?: s src={effectiveSrc} alt={alt ?? ''} loading="lazy" - onLoad={() => setLoaded(true)} + onLoad={() => { loadedImageCache.add(effectiveSrc); setLoaded(true); }} onError={() => setError(true)} onClick={() => onPreview?.(effectiveSrc)} className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity my-1" From 384cf9a4968b9c4e3ccdfc2e65477bf4cc1c7b9b Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 20 May 2026 18:45:17 +0800 Subject: [PATCH 18/19] feat: add Chrome extension for browser automation with dynamic bridge routing Replace static bridge-vs-npx decision at agent creation time with dynamic per-call routing, so agents always use the extension when connected. - New chrome-extension package with Manifest V3 service worker, CDP tools, WebSocket bridge client, popup UI, and Markus logo icons - WebSocket bridge server (markus-browser-bridge) and in-process MCP proxy (markus-browser-mcp) in core package - Dynamic dispatch in registerChromeDevtoolsLazy: check bridge.connected at call time, fall back to npx chrome-devtools-mcp transparently - Settings UI: step-by-step extension install guide with download zip, open chrome://extensions, and 5s auto-polling for connection status - Onboarding: detect Chrome browser and show extension setup card - pack.mjs script to produce markus-browser-extension.zip (14KB, zero deps) - API endpoints for zip download and opening chrome://extensions - Integrated into Dockerfile and build-binary.sh for distribution Co-authored-by: Cursor --- Dockerfile | 1 + packages/chrome-extension/icons/icon128.png | Bin 0 -> 26249 bytes packages/chrome-extension/icons/icon16.png | Bin 0 -> 848 bytes packages/chrome-extension/icons/icon48.png | Bin 0 -> 4361 bytes packages/chrome-extension/manifest.json | 25 ++ packages/chrome-extension/pack.mjs | 138 +++++++ packages/chrome-extension/package.json | 16 + packages/chrome-extension/popup.html | 84 ++++ packages/chrome-extension/popup.js | 25 ++ packages/chrome-extension/src/background.ts | 56 +++ packages/chrome-extension/src/page-manager.ts | 82 ++++ packages/chrome-extension/src/protocol.ts | 151 +++++++ packages/chrome-extension/src/tools/input.ts | 252 ++++++++++++ .../chrome-extension/src/tools/inspection.ts | 218 ++++++++++ .../chrome-extension/src/tools/navigation.ts | 222 +++++++++++ .../chrome-extension/src/tools/network.ts | 182 +++++++++ packages/chrome-extension/tsconfig.json | 17 + packages/cli/src/commands/start.ts | 1 + packages/core/package.json | 6 +- packages/core/src/agent-manager.ts | 71 +++- packages/core/src/index.ts | 2 + .../core/src/tools/markus-browser-bridge.ts | 164 ++++++++ packages/core/src/tools/markus-browser-mcp.ts | 190 +++++++++ packages/org-manager/src/api-server.ts | 76 ++++ packages/shared/src/utils/config.ts | 2 + packages/web-ui/src/api.ts | 21 +- packages/web-ui/src/locales/en/settings.json | 40 +- .../web-ui/src/locales/zh-CN/settings.json | 40 +- packages/web-ui/src/pages/Settings.tsx | 376 +++++++++++++----- pnpm-lock.yaml | 313 ++++++++++++++- scripts/build-binary.sh | 12 + templates/skills/chrome-devtools/SKILL.md | 165 ++++---- 32 files changed, 2742 insertions(+), 206 deletions(-) create mode 100644 packages/chrome-extension/icons/icon128.png create mode 100644 packages/chrome-extension/icons/icon16.png create mode 100644 packages/chrome-extension/icons/icon48.png create mode 100644 packages/chrome-extension/manifest.json create mode 100644 packages/chrome-extension/pack.mjs create mode 100644 packages/chrome-extension/package.json create mode 100644 packages/chrome-extension/popup.html create mode 100644 packages/chrome-extension/popup.js create mode 100644 packages/chrome-extension/src/background.ts create mode 100644 packages/chrome-extension/src/page-manager.ts create mode 100644 packages/chrome-extension/src/protocol.ts create mode 100644 packages/chrome-extension/src/tools/input.ts create mode 100644 packages/chrome-extension/src/tools/inspection.ts create mode 100644 packages/chrome-extension/src/tools/navigation.ts create mode 100644 packages/chrome-extension/src/tools/network.ts create mode 100644 packages/chrome-extension/tsconfig.json create mode 100644 packages/core/src/tools/markus-browser-bridge.ts create mode 100644 packages/core/src/tools/markus-browser-mcp.ts diff --git a/Dockerfile b/Dockerfile index 5e724237..4d65eb22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ COPY packages/gui/package.json packages/gui/ COPY packages/a2a/package.json packages/a2a/ COPY packages/cli/package.json packages/cli/ COPY packages/web-ui/package.json packages/web-ui/ +COPY packages/chrome-extension/package.json packages/chrome-extension/ RUN pnpm install --frozen-lockfile || pnpm install # ── Stage 2: Build all packages ───────────────────────────────────────────── diff --git a/packages/chrome-extension/icons/icon128.png b/packages/chrome-extension/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..95c6cac697739a315d1af10c97941f83fd3e0cfc GIT binary patch literal 26249 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
N++~+t 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 Ef=iHj0{{R3 literal 0 HcmV?d00001 diff --git a/packages/chrome-extension/icons/icon16.png b/packages/chrome-extension/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..2de675838c710d8d31cd724b2cb7b19db93638de GIT binary patch literal 848 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|Tv8)E(|mmy zw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(-uuz(rC1}QWNE&Rm5z_iQL#WBP} z&^P$Jzi^N|}s9@kem{%3R+*%KkiT;ynZ z@t?5kmHGx@frBllTD@l{cm(I{e*3QYS*~OH=XPg~ol5`wWv9>U?M}B<4?n*-XT}VlZzbOwcyGLPIuU+h@rC5u zW_#@PxmuabillFcU6o4S-8%D&a^kFI3+Bt5iq&daq&0=B_Onv0iJa&^v-t+{vz2m} zMQ(3!&`>aXZ{+m-#ibW7H>)STy*w+xG*n}jl7HDg`zn7KdHL=ucddmiCRb`REi_vw z)z(}#)e`dZ(;ISZY;Al0{*}LR_pa;KsHs(2B0mMJBOipF{Zl7@=pl#Lef>vi z@$yY?9b@(hxob^5^yyTWPTtzMV#XVh!3&Kid|A$O>-OOdveMDf(Gq@q%9bWA_wL!r z{IOZU^DS^?NP>B;_=2s%49Yt+k0b?7=~5NxcFn0fnVhE9doFwKj9NSG#Z$agZ_2+p z-IIGeGcaReY3zHaoiT2kFCN*x{=Q*RbqiN&LV7y;u5}Onzw+<6xI}=JPg`?Nc~nxu zgGC;5-d$=}oilIVso5LWu!Xp~+Q?1+_k8AM-9M8LMmJknT2AEByYT9G=#x#B86tNx zttPqE*B>fXxOFCG?e*6srEx25tgM_KhA&KI$dL2Z+ow0J#;(6_wd?|y2Nz3ka{Dw# z|6IB(>&A(+rIV`TzU^PL_Kpg}a@qc)E8XP|70T4a#n&IMe`?_C>!-SVcm9D*cM=~x zEPNmS>r`*{(rha}?)DSUHr)<8D`nk2{jm%e`|}xm(yLZkJLVj?DA8+qeZ^bt|Gl5% X1->W;J=6T)3QEnMu6{1-oD!M<@U3T0 literal 0 HcmV?d00001 diff --git a/packages/chrome-extension/icons/icon48.png b/packages/chrome-extension/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..5449da3c63a10e53d8f7d264954c0d354596414a GIT binary patch literal 4361 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 D?*ne@ literal 0 HcmV?d00001 diff --git a/packages/chrome-extension/manifest.json b/packages/chrome-extension/manifest.json new file mode 100644 index 00000000..4299c697 --- /dev/null +++ b/packages/chrome-extension/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "Markus Browser Automation", + "description": "Enables Markus AI agents to automate browser tasks without the remote debugging dialog.", + "version": "1.0.0", + "permissions": ["debugger", "tabs", "activeTab", "scripting"], + "host_permissions": [""], + "background": { + "service_worker": "dist/background.js", + "type": "module" + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "action": { + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png" + }, + "default_title": "Markus Browser Automation", + "default_popup": "popup.html" + } +} diff --git a/packages/chrome-extension/pack.mjs b/packages/chrome-extension/pack.mjs new file mode 100644 index 00000000..94760190 --- /dev/null +++ b/packages/chrome-extension/pack.mjs @@ -0,0 +1,138 @@ +/** + * Pack the Chrome extension into a zip file ready for distribution. + * Includes only the files needed to load the extension in Chrome. + * + * Output: dist/markus-browser-extension.zip + */ +import { createWriteStream, readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { createDeflateRaw } from 'node:zlib'; + +const ROOT = new URL('.', import.meta.url).pathname.replace(/\/$/, ''); +const OUT = join(ROOT, 'dist', 'markus-browser-extension.zip'); + +const FILES = [ + 'manifest.json', + 'popup.html', + 'popup.js', + 'dist/background.js', + 'icons/icon16.png', + 'icons/icon48.png', + 'icons/icon128.png', +]; + +// Verify all files exist +for (const f of FILES) { + if (!existsSync(join(ROOT, f))) { + console.error(`Missing file: ${f}. Run "pnpm run build" first.`); + process.exit(1); + } +} + +// Minimal zip writer (no external deps) +class ZipWriter { + constructor(outPath) { + this.entries = []; + this.stream = createWriteStream(outPath); + this.offset = 0; + } + + async addFile(archivePath, content) { + const header = Buffer.alloc(30); + const nameBytes = Buffer.from(archivePath, 'utf8'); + const compressed = await this._deflate(content); + const crc = this._crc32(content); + + // Local file header + header.writeUInt32LE(0x04034b50, 0); // signature + header.writeUInt16LE(20, 4); // version needed + header.writeUInt16LE(0, 6); // flags + header.writeUInt16LE(8, 8); // compression: deflate + header.writeUInt16LE(0, 10); // mod time + header.writeUInt16LE(0, 12); // mod date + header.writeUInt32LE(crc, 14); // crc-32 + header.writeUInt32LE(compressed.length, 18); // compressed size + header.writeUInt32LE(content.length, 22); // uncompressed size + header.writeUInt16LE(nameBytes.length, 26); // filename length + header.writeUInt16LE(0, 28); // extra field length + + const localOffset = this.offset; + this._write(header); + this._write(nameBytes); + this._write(compressed); + + this.entries.push({ archivePath, nameBytes, crc, compressedSize: compressed.length, uncompressedSize: content.length, localOffset }); + } + + finish() { + const cdStart = this.offset; + for (const e of this.entries) { + const cdh = Buffer.alloc(46); + cdh.writeUInt32LE(0x02014b50, 0); // signature + cdh.writeUInt16LE(20, 4); // version made by + cdh.writeUInt16LE(20, 6); // version needed + cdh.writeUInt16LE(0, 8); // flags + cdh.writeUInt16LE(8, 10); // compression + cdh.writeUInt16LE(0, 12); // time + cdh.writeUInt16LE(0, 14); // date + cdh.writeUInt32LE(e.crc, 16); + cdh.writeUInt32LE(e.compressedSize, 20); + cdh.writeUInt32LE(e.uncompressedSize, 24); + cdh.writeUInt16LE(e.nameBytes.length, 28); + cdh.writeUInt16LE(0, 30); // extra + cdh.writeUInt16LE(0, 32); // comment + cdh.writeUInt16LE(0, 34); // disk + cdh.writeUInt16LE(0, 36); // internal attrs + cdh.writeUInt32LE(0, 38); // external attrs + cdh.writeUInt32LE(e.localOffset, 42); + this._write(cdh); + this._write(e.nameBytes); + } + const cdSize = this.offset - cdStart; + + // End of central directory + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(this.entries.length, 8); + eocd.writeUInt16LE(this.entries.length, 10); + eocd.writeUInt32LE(cdSize, 12); + eocd.writeUInt32LE(cdStart, 16); + this._write(eocd); + this.stream.end(); + } + + _write(buf) { + this.stream.write(buf); + this.offset += buf.length; + } + + _deflate(data) { + return new Promise((resolve, reject) => { + const chunks = []; + const deflater = createDeflateRaw(); + deflater.on('data', c => chunks.push(c)); + deflater.on('end', () => resolve(Buffer.concat(chunks))); + deflater.on('error', reject); + deflater.end(data); + }); + } + + _crc32(buf) { + let crc = 0xFFFFFFFF; + for (let i = 0; i < buf.length; i++) { + crc ^= buf[i]; + for (let j = 0; j < 8; j++) crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0); + } + return (crc ^ 0xFFFFFFFF) >>> 0; + } +} + +const zip = new ZipWriter(OUT); +for (const f of FILES) { + const content = readFileSync(join(ROOT, f)); + await zip.addFile(f, content); +} +zip.finish(); + +const size = statSync(OUT).size; +console.log(`Packed ${FILES.length} files → ${OUT} (${(size / 1024).toFixed(1)} KB)`); diff --git a/packages/chrome-extension/package.json b/packages/chrome-extension/package.json new file mode 100644 index 00000000..983c0bdc --- /dev/null +++ b/packages/chrome-extension/package.json @@ -0,0 +1,16 @@ +{ + "name": "@markus/chrome-extension", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "esbuild src/background.ts --bundle --outfile=dist/background.js --format=esm --target=es2022 --platform=browser", + "watch": "esbuild src/background.ts --bundle --outfile=dist/background.js --format=esm --target=es2022 --platform=browser --watch", + "pack": "pnpm run build && node pack.mjs", + "prepare": "pnpm run build" + }, + "devDependencies": { + "@types/chrome": "^0.0.300", + "esbuild": "^0.25.0", + "typescript": "^5.6.0" + } +} diff --git a/packages/chrome-extension/popup.html b/packages/chrome-extension/popup.html new file mode 100644 index 00000000..61521d18 --- /dev/null +++ b/packages/chrome-extension/popup.html @@ -0,0 +1,84 @@ + + + + + + +

+ +

Markus Browser

+
+
+ + Checking... +
+
+
+ Bridge + ws://127.0.0.1:9333 +
+
+ Pages tracked + 0 +
+
+
+
+ Enables Markus agents to automate Chrome without the remote debugging dialog. +
+ + + + diff --git a/packages/chrome-extension/popup.js b/packages/chrome-extension/popup.js new file mode 100644 index 00000000..da49d591 --- /dev/null +++ b/packages/chrome-extension/popup.js @@ -0,0 +1,25 @@ +// Query the background service worker for connection status +chrome.runtime.sendMessage({ type: 'getStatus' }, (response) => { + if (chrome.runtime.lastError || !response) { + document.getElementById('statusText').textContent = 'Extension loading...'; + return; + } + + const statusEl = document.getElementById('status'); + const dotEl = document.getElementById('dot'); + const textEl = document.getElementById('statusText'); + const pageCountEl = document.getElementById('pageCount'); + + if (response.connected) { + statusEl.className = 'status connected'; + dotEl.className = 'dot green'; + textEl.textContent = 'Connected to Markus'; + } else { + statusEl.className = 'status disconnected'; + dotEl.className = 'dot gray'; + textEl.textContent = 'Not connected'; + } + + pageCountEl.textContent = String(response.pageCount || 0); + document.getElementById('bridgeUrl').textContent = response.bridgeUrl || 'ws://127.0.0.1:9333'; +}); diff --git a/packages/chrome-extension/src/background.ts b/packages/chrome-extension/src/background.ts new file mode 100644 index 00000000..8694a31e --- /dev/null +++ b/packages/chrome-extension/src/background.ts @@ -0,0 +1,56 @@ +/** + * Chrome Extension Service Worker — main entry point. + * + * Connects to Markus browser bridge via WebSocket and registers + * all tool handlers that mirror chrome-devtools-mcp's API. + */ + +import { BridgeClient } from './protocol.js'; +import { PageManager } from './page-manager.js'; +import { registerNavigationTools } from './tools/navigation.js'; +import { registerInputTools } from './tools/input.js'; +import { registerInspectionTools, setupConsoleListener } from './tools/inspection.js'; +import { registerNetworkTools, setupNetworkListener } from './tools/network.js'; + +const pm = new PageManager(); +const client = new BridgeClient(); + +// Register all tool handlers +registerNavigationTools((name, handler) => client.registerHandler(name, handler), pm); +registerInputTools((name, handler) => client.registerHandler(name, handler), pm); +registerInspectionTools((name, handler) => client.registerHandler(name, handler), pm); +registerNetworkTools((name, handler) => client.registerHandler(name, handler), pm); + +// Set up CDP event listeners +setupConsoleListener(); +setupNetworkListener(); + +// Clean up page state when tabs are closed +chrome.tabs.onRemoved.addListener((tabId) => { + pm.removeByTabId(tabId); + client.send({ event: 'tab_closed', data: { tabId } }); +}); + +// Handle debugger detach events +chrome.debugger.onDetach.addListener((source) => { + if (source.tabId) { + pm.setDebuggerAttached(source.tabId, false); + } +}); + +// Handle popup status queries +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === 'getStatus') { + sendResponse({ + connected: client.connected, + pageCount: pm.getAllPages().length, + bridgeUrl: 'ws://127.0.0.1:9333', + }); + return true; + } +}); + +// Connect to bridge +client.connect(); + +console.log('[Markus] Browser automation extension initialized'); diff --git a/packages/chrome-extension/src/page-manager.ts b/packages/chrome-extension/src/page-manager.ts new file mode 100644 index 00000000..7257afc8 --- /dev/null +++ b/packages/chrome-extension/src/page-manager.ts @@ -0,0 +1,82 @@ +/** + * Manages the mapping between sequential page IDs (1, 2, 3...) + * used by the MCP protocol and Chrome's native tab IDs. + * Also tracks which page is "selected" (active for CDP operations). + */ + +export class PageManager { + private tabToPage = new Map(); + private pageToTab = new Map(); + private nextPageId = 1; + private _selectedPageId: number | null = null; + private debuggerAttached = new Set(); + + get selectedPageId(): number | null { return this._selectedPageId; } + get selectedTabId(): number | null { + if (this._selectedPageId === null) return null; + return this.pageToTab.get(this._selectedPageId) ?? null; + } + + getPageId(tabId: number): number { + let pageId = this.tabToPage.get(tabId); + if (pageId === undefined) { + pageId = this.nextPageId++; + this.tabToPage.set(tabId, pageId); + this.pageToTab.set(pageId, tabId); + } + return pageId; + } + + getTabId(pageId: number): number | undefined { + return this.pageToTab.get(pageId); + } + + selectPage(pageId: number): boolean { + if (!this.pageToTab.has(pageId)) return false; + this._selectedPageId = pageId; + return true; + } + + removePage(pageId: number): void { + const tabId = this.pageToTab.get(pageId); + if (tabId !== undefined) { + this.tabToPage.delete(tabId); + this.debuggerAttached.delete(tabId); + } + this.pageToTab.delete(pageId); + if (this._selectedPageId === pageId) { + this._selectedPageId = null; + } + } + + removeByTabId(tabId: number): void { + const pageId = this.tabToPage.get(tabId); + if (pageId !== undefined) { + this.removePage(pageId); + } + } + + isDebuggerAttached(tabId: number): boolean { + return this.debuggerAttached.has(tabId); + } + + setDebuggerAttached(tabId: number, attached: boolean): void { + if (attached) { + this.debuggerAttached.add(tabId); + } else { + this.debuggerAttached.delete(tabId); + } + } + + getAllPages(): Array<{ pageId: number; tabId: number }> { + return [...this.pageToTab.entries()].map(([pageId, tabId]) => ({ pageId, tabId })); + } + + clear(): void { + this.tabToPage.clear(); + this.pageToTab.clear(); + this.debuggerAttached.clear(); + this._selectedPageId = null; + this.nextPageId = 1; + } +} diff --git a/packages/chrome-extension/src/protocol.ts b/packages/chrome-extension/src/protocol.ts new file mode 100644 index 00000000..d40f892b --- /dev/null +++ b/packages/chrome-extension/src/protocol.ts @@ -0,0 +1,151 @@ +/** + * WebSocket client that connects to the Markus browser bridge. + * Handles reconnection, keepalive, and message routing. + */ + +export interface BridgeRequest { + id: number; + method: string; + params: Record; +} + +export interface BridgeResponse { + id: number; + result?: unknown; + error?: string; +} + +export type ToolHandler = (params: Record) => Promise; + +const DEFAULT_URL = 'ws://127.0.0.1:9333'; +const RECONNECT_INTERVAL_MS = 3000; +const KEEPALIVE_INTERVAL_MS = 25000; + +export class BridgeClient { + private ws: WebSocket | null = null; + private url: string; + private handlers = new Map(); + private reconnectTimer: ReturnType | null = null; + private keepaliveTimer: ReturnType | null = null; + private _connected = false; + + constructor(url?: string) { + this.url = url ?? DEFAULT_URL; + } + + get connected(): boolean { return this._connected; } + + registerHandler(method: string, handler: ToolHandler): void { + this.handlers.set(method, handler); + } + + connect(): void { + this.cleanup(); + + try { + this.ws = new WebSocket(this.url); + } catch { + this.scheduleReconnect(); + return; + } + + this.ws.onopen = () => { + console.log('[Markus] Connected to bridge'); + this._connected = true; + this.startKeepalive(); + chrome.action.setIcon({ path: { + '16': 'icons/icon16.png', + '48': 'icons/icon48.png', + }}); + chrome.action.setTitle({ title: 'Markus Browser Automation (Connected)' }); + }; + + this.ws.onclose = () => { + console.log('[Markus] Disconnected from bridge'); + this._connected = false; + this.stopKeepalive(); + chrome.action.setTitle({ title: 'Markus Browser Automation (Disconnected)' }); + this.scheduleReconnect(); + }; + + this.ws.onerror = () => { + // onclose will fire after this + }; + + this.ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data as string) as BridgeRequest; + this.handleRequest(msg); + } catch (err) { + console.error('[Markus] Failed to parse message:', err); + } + }; + } + + private async handleRequest(req: BridgeRequest): Promise { + const handler = this.handlers.get(req.method); + if (!handler) { + this.send({ id: req.id, error: `Unknown method: ${req.method}` }); + return; + } + + try { + const result = await handler(req.params); + this.send({ id: req.id, result }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.send({ id: req.id, error: msg }); + } + } + + send(msg: BridgeResponse | { event: string; data: unknown }): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } + + private scheduleReconnect(): void { + if (this.reconnectTimer) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, RECONNECT_INTERVAL_MS); + } + + private startKeepalive(): void { + this.stopKeepalive(); + this.keepaliveTimer = setInterval(() => { + if (this.ws?.readyState === WebSocket.OPEN) { + this.send({ event: 'keepalive', data: { timestamp: Date.now() } }); + } + }, KEEPALIVE_INTERVAL_MS); + } + + private stopKeepalive(): void { + if (this.keepaliveTimer) { + clearInterval(this.keepaliveTimer); + this.keepaliveTimer = null; + } + } + + private cleanup(): void { + this.stopKeepalive(); + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.onopen = null; + this.ws.onclose = null; + this.ws.onerror = null; + this.ws.onmessage = null; + try { this.ws.close(); } catch { /* ignore */ } + this.ws = null; + } + this._connected = false; + } + + disconnect(): void { + this.cleanup(); + } +} diff --git a/packages/chrome-extension/src/tools/input.ts b/packages/chrome-extension/src/tools/input.ts new file mode 100644 index 00000000..3fb3e3c8 --- /dev/null +++ b/packages/chrome-extension/src/tools/input.ts @@ -0,0 +1,252 @@ +/** + * Input tools: click, fill, fill_form, type_text, press_key, hover, drag, handle_dialog, upload_file + * + * These use chrome.debugger CDP commands for input simulation. + * Element targeting uses the "uid" from accessibility tree snapshots. + */ + +import type { PageManager } from '../page-manager.js'; + +async function cdp(tabId: number, method: string, params?: Record): Promise { + return chrome.debugger.sendCommand({ tabId }, method, params); +} + +async function ensureDebugger(pm: PageManager, tabId: number): Promise { + if (pm.isDebuggerAttached(tabId)) return; + await chrome.debugger.attach({ tabId }, '1.3'); + pm.setDebuggerAttached(tabId, true); + await chrome.debugger.sendCommand({ tabId }, 'Page.enable'); + await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); +} + +function requireSelectedTab(pm: PageManager): number { + const tabId = pm.selectedTabId; + if (tabId === null) throw new Error('No page selected. Call new_page or select_page first.'); + return tabId; +} + +/** + * Resolve a snapshot uid to DOM coordinates via JS evaluation. + * The uid corresponds to an aria-snapshot element with a data-uid attribute + * or a node from the accessibility tree. + */ +async function resolveUidToCoords(tabId: number, uid: string): Promise<{ x: number; y: number }> { + const script = ` + (function() { + // Try data-uid attribute first + let el = document.querySelector('[data-uid="${uid}"]'); + if (!el) { + // Try aria-label or other attributes + const all = document.querySelectorAll('*'); + for (const e of all) { + if (e.getAttribute('data-snapshot-uid') === '${uid}') { el = e; break; } + } + } + if (!el) return null; + const rect = el.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + })() + `; + const result = await cdp(tabId, 'Runtime.evaluate', { + expression: script, returnByValue: true, + }) as { result?: { value?: { x: number; y: number } | null } }; + + if (!result?.result?.value) { + throw new Error(`Element with uid "${uid}" not found on page`); + } + return result.result.value; +} + +async function dispatchClick(tabId: number, x: number, y: number): Promise { + await cdp(tabId, 'Input.dispatchMouseEvent', { + type: 'mousePressed', x, y, button: 'left', clickCount: 1, + }); + await cdp(tabId, 'Input.dispatchMouseEvent', { + type: 'mouseReleased', x, y, button: 'left', clickCount: 1, + }); +} + +export function registerInputTools( + register: (name: string, handler: (params: Record) => Promise) => void, + pm: PageManager, +): void { + + register('click', async (params) => { + const uid = params.uid as string; + if (!uid) throw new Error('uid is required'); + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + + const { x, y } = await resolveUidToCoords(tabId, uid); + await dispatchClick(tabId, x, y); + return `Clicked element ${uid} at (${Math.round(x)}, ${Math.round(y)})`; + }); + + register('fill', async (params) => { + const uid = params.uid as string; + const value = params.value as string; + if (!uid) throw new Error('uid is required'); + if (value === undefined) throw new Error('value is required'); + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + + const { x, y } = await resolveUidToCoords(tabId, uid); + await dispatchClick(tabId, x, y); + // Select all then replace + await cdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2 }); // Ctrl+A + await cdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', code: 'KeyA', modifiers: 2 }); + await cdp(tabId, 'Input.insertText', { text: value }); + return `Filled element ${uid} with "${value}"`; + }); + + register('fill_form', async (params) => { + const fields = params.fields as Array<{ uid: string; value: string }>; + if (!fields || !Array.isArray(fields)) throw new Error('fields array is required'); + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + + const results: string[] = []; + for (const field of fields) { + const { x, y } = await resolveUidToCoords(tabId, field.uid); + await dispatchClick(tabId, x, y); + await cdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', key: 'a', code: 'KeyA', modifiers: 2 }); + await cdp(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', key: 'a', code: 'KeyA', modifiers: 2 }); + await cdp(tabId, 'Input.insertText', { text: field.value }); + results.push(`${field.uid}: "${field.value}"`); + } + return `Filled ${results.length} fields:\n${results.join('\n')}`; + }); + + register('type_text', async (params) => { + const text = params.text as string; + if (!text) throw new Error('text is required'); + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + + await cdp(tabId, 'Input.insertText', { text }); + return `Typed "${text}"`; + }); + + register('press_key', async (params) => { + const key = params.key as string; + if (!key) throw new Error('key is required'); + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + + const parts = key.split('+'); + let modifiers = 0; + const modifierKeys: string[] = []; + for (const part of parts.slice(0, -1)) { + const lower = part.toLowerCase().trim(); + if (lower === 'control' || lower === 'ctrl') { modifiers |= 2; modifierKeys.push(lower); } + else if (lower === 'alt') { modifiers |= 1; modifierKeys.push(lower); } + else if (lower === 'shift') { modifiers |= 8; modifierKeys.push(lower); } + else if (lower === 'meta' || lower === 'command' || lower === 'cmd') { modifiers |= 4; modifierKeys.push(lower); } + } + const mainKey = parts[parts.length - 1].trim(); + + await cdp(tabId, 'Input.dispatchKeyEvent', { + type: 'keyDown', key: mainKey, modifiers, + }); + await cdp(tabId, 'Input.dispatchKeyEvent', { + type: 'keyUp', key: mainKey, modifiers, + }); + return `Pressed key: ${key}`; + }); + + register('hover', async (params) => { + const uid = params.uid as string; + if (!uid) throw new Error('uid is required'); + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + + const { x, y } = await resolveUidToCoords(tabId, uid); + await cdp(tabId, 'Input.dispatchMouseEvent', { + type: 'mouseMoved', x, y, + }); + return `Hovered over element ${uid} at (${Math.round(x)}, ${Math.round(y)})`; + }); + + register('drag', async (params) => { + const fromUid = params.from_uid as string ?? params.fromUid as string; + const toUid = params.to_uid as string ?? params.toUid as string; + if (!fromUid || !toUid) throw new Error('from_uid and to_uid are required'); + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + + const from = await resolveUidToCoords(tabId, fromUid); + const to = await resolveUidToCoords(tabId, toUid); + + await cdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: from.x, y: from.y }); + await cdp(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x: from.x, y: from.y, button: 'left' }); + // Intermediate move steps for smooth drag + const steps = 5; + for (let i = 1; i <= steps; i++) { + const x = from.x + (to.x - from.x) * (i / steps); + const y = from.y + (to.y - from.y) * (i / steps); + await cdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }); + } + await cdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x: to.x, y: to.y, button: 'left' }); + + return `Dragged from ${fromUid} to ${toUid}`; + }); + + register('handle_dialog', async (params) => { + const accept = params.accept !== false; + const promptText = params.promptText as string | undefined; + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + + await cdp(tabId, 'Page.handleJavaScriptDialog', { + accept, + ...(promptText !== undefined ? { promptText } : {}), + }); + return `Dialog ${accept ? 'accepted' : 'dismissed'}`; + }); + + register('upload_file', async (params) => { + const uid = params.uid as string; + const filePath = params.filePath as string ?? params.file_path as string; + if (!uid || !filePath) throw new Error('uid and filePath are required'); + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + + // Resolve uid to DOM node + const script = ` + (function() { + let el = document.querySelector('[data-uid="${uid}"]'); + if (!el) { + const all = document.querySelectorAll('input[type="file"]'); + for (const e of all) { + if (e.getAttribute('data-snapshot-uid') === '${uid}') { el = e; break; } + } + } + return el ? true : false; + })() + `; + const found = await cdp(tabId, 'Runtime.evaluate', { + expression: script, returnByValue: true, + }) as { result?: { value?: boolean } }; + + if (!found?.result?.value) { + throw new Error(`File input with uid "${uid}" not found`); + } + + // Use DOM.setFileInputFiles via the node + const docResult = await cdp(tabId, 'DOM.getDocument') as { root?: { nodeId?: number } }; + const nodeResult = await cdp(tabId, 'DOM.querySelector', { + nodeId: docResult?.root?.nodeId, + selector: `[data-uid="${uid}"]`, + }) as { nodeId?: number }; + + if (nodeResult?.nodeId) { + await cdp(tabId, 'DOM.setFileInputFiles', { + files: [filePath], + nodeId: nodeResult.nodeId, + }); + return `Uploaded file "${filePath}" to element ${uid}`; + } + + throw new Error(`Could not set file on element ${uid}`); + }); +} diff --git a/packages/chrome-extension/src/tools/inspection.ts b/packages/chrome-extension/src/tools/inspection.ts new file mode 100644 index 00000000..e4f8f682 --- /dev/null +++ b/packages/chrome-extension/src/tools/inspection.ts @@ -0,0 +1,218 @@ +/** + * Inspection tools: take_screenshot, take_snapshot, evaluate_script, + * get_console_message, list_console_messages, lighthouse_audit + */ + +import type { PageManager } from '../page-manager.js'; + +async function cdp(tabId: number, method: string, params?: Record): Promise { + return chrome.debugger.sendCommand({ tabId }, method, params); +} + +async function ensureDebugger(pm: PageManager, tabId: number): Promise { + if (pm.isDebuggerAttached(tabId)) return; + await chrome.debugger.attach({ tabId }, '1.3'); + pm.setDebuggerAttached(tabId, true); + await chrome.debugger.sendCommand({ tabId }, 'Page.enable'); + await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); +} + +function requireSelectedTab(pm: PageManager): number { + const tabId = pm.selectedTabId; + if (tabId === null) throw new Error('No page selected. Call new_page or select_page first.'); + return tabId; +} + +// Console message storage per tab +const consoleMessages = new Map>(); +let nextMsgId = 1; + +export function setupConsoleListener(): void { + chrome.debugger.onEvent.addListener((source, method, params) => { + if (method === 'Runtime.consoleAPICalled' && source.tabId) { + const p = params as { type: string; args?: Array<{ value?: unknown; description?: string }>; timestamp?: number }; + const text = (p.args ?? []).map(a => a.description ?? String(a.value ?? '')).join(' '); + let messages = consoleMessages.get(source.tabId); + if (!messages) { + messages = []; + consoleMessages.set(source.tabId, messages); + } + messages.push({ + id: nextMsgId++, + level: p.type ?? 'log', + text, + timestamp: p.timestamp ?? Date.now(), + }); + // Keep last 200 messages per tab + if (messages.length > 200) messages.splice(0, messages.length - 200); + } + }); +} + +export function registerInspectionTools( + register: (name: string, handler: (params: Record) => Promise) => void, + pm: PageManager, +): void { + + register('take_screenshot', async (params) => { + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + + const format = (params.format as string) || 'png'; + const quality = params.quality as number | undefined; + const fullPage = params.fullPage === true; + + const cdpParams: Record = { + format: format === 'jpg' ? 'jpeg' : format, + }; + if (quality !== undefined) cdpParams.quality = quality; + if (fullPage) cdpParams.captureBeyondViewport = true; + + const result = await cdp(tabId, 'Page.captureScreenshot', cdpParams) as { data?: string }; + if (!result?.data) throw new Error('Screenshot failed'); + + return `data:image/${format};base64,${result.data}`; + }); + + register('take_snapshot', async (params) => { + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + + // Get accessibility tree + const result = await cdp(tabId, 'Accessibility.getFullAXTree') as { + nodes?: Array<{ + nodeId: string; + role?: { value?: string }; + name?: { value?: string }; + properties?: Array<{ name: string; value: { value?: unknown } }>; + childIds?: string[]; + backendDOMNodeId?: number; + }>; + }; + + if (!result?.nodes || result.nodes.length === 0) { + return 'Empty accessibility tree'; + } + + const lines: string[] = []; + const nodeMap = new Map(result.nodes.map(n => [n.nodeId, n])); + let uidCounter = 1; + + function walk(nodeId: string, depth: number): void { + const node = nodeMap.get(nodeId); + if (!node) return; + + const role = node.role?.value ?? ''; + const name = node.name?.value ?? ''; + + // Skip generic/none roles and unnamed nodes at root level + if (role === 'none' || role === 'generic') { + for (const childId of node.childIds ?? []) { + walk(childId, depth); + } + return; + } + + if (role || name) { + const uid = `e${uidCounter++}`; + const indent = ' '.repeat(depth); + const nameStr = name ? ` "${name}"` : ''; + lines.push(`${indent}[${uid}] ${role}${nameStr}`); + + for (const childId of node.childIds ?? []) { + walk(childId, depth + 1); + } + } + } + + // Start from root + if (result.nodes.length > 0) { + walk(result.nodes[0].nodeId, 0); + } + + return lines.length > 0 ? lines.join('\n') : 'Empty accessibility tree'; + }); + + register('evaluate_script', async (params) => { + const expression = params.expression as string; + if (!expression) throw new Error('expression is required'); + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + + const result = await cdp(tabId, 'Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }) as { result?: { value?: unknown; description?: string }; exceptionDetails?: { text?: string } }; + + if (result?.exceptionDetails) { + throw new Error(`Script error: ${result.exceptionDetails.text}`); + } + + const value = result?.result?.value; + if (value === undefined) return result?.result?.description ?? 'undefined'; + return typeof value === 'string' ? value : JSON.stringify(value, null, 2); + }); + + register('get_console_message', async (params) => { + const msgId = params.msgid as number ?? params.id as number; + if (msgId === undefined) throw new Error('msgid is required'); + const tabId = requireSelectedTab(pm); + + const messages = consoleMessages.get(tabId) ?? []; + const msg = messages.find(m => m.id === msgId); + if (!msg) return `Console message ${msgId} not found`; + + return `[${msg.level}] ${msg.text}`; + }); + + register('list_console_messages', async () => { + const tabId = requireSelectedTab(pm); + const messages = consoleMessages.get(tabId) ?? []; + + if (messages.length === 0) return 'No console messages'; + + return messages.map(m => `${m.id}: [${m.level}] ${m.text}`).join('\n'); + }); + + register('lighthouse_audit', async (params) => { + const tabId = requireSelectedTab(pm); + const categories = (params.categories as string[]) ?? ['accessibility', 'best-practices', 'seo']; + + // Lighthouse is not available via chrome.debugger — provide a basic a11y audit instead + await ensureDebugger(pm, tabId); + + const result = await cdp(tabId, 'Accessibility.getFullAXTree') as { + nodes?: Array<{ role?: { value?: string }; name?: { value?: string } }>; + }; + + const nodes = result?.nodes ?? []; + const issues: string[] = []; + + // Basic accessibility checks + let imagesWithoutAlt = 0; + let buttonsWithoutLabel = 0; + let linksWithoutText = 0; + + for (const node of nodes) { + const role = node.role?.value; + const name = node.name?.value; + if (role === 'img' && !name) imagesWithoutAlt++; + if (role === 'button' && !name) buttonsWithoutLabel++; + if (role === 'link' && !name) linksWithoutText++; + } + + if (imagesWithoutAlt > 0) issues.push(`${imagesWithoutAlt} image(s) without alt text`); + if (buttonsWithoutLabel > 0) issues.push(`${buttonsWithoutLabel} button(s) without labels`); + if (linksWithoutText > 0) issues.push(`${linksWithoutText} link(s) without text`); + + const report = [ + `Accessibility Audit (${nodes.length} nodes analyzed)`, + `Categories: ${categories.join(', ')}`, + '', + issues.length > 0 ? `Issues found:\n${issues.map(i => ` - ${i}`).join('\n')}` : 'No issues found', + ]; + + return report.join('\n'); + }); +} diff --git a/packages/chrome-extension/src/tools/navigation.ts b/packages/chrome-extension/src/tools/navigation.ts new file mode 100644 index 00000000..87a7e540 --- /dev/null +++ b/packages/chrome-extension/src/tools/navigation.ts @@ -0,0 +1,222 @@ +/** + * Navigation tools: new_page, close_page, list_pages, select_page, navigate_page, wait_for + * + * These use chrome.tabs API for tab management and chrome.debugger + * for CDP-level navigation control. + */ + +import type { PageManager } from '../page-manager.js'; + +/** Helper: attach debugger to tab if not already attached */ +async function ensureDebugger(pm: PageManager, tabId: number): Promise { + if (pm.isDebuggerAttached(tabId)) return; + await chrome.debugger.attach({ tabId }, '1.3'); + pm.setDebuggerAttached(tabId, true); + await chrome.debugger.sendCommand({ tabId }, 'Page.enable'); + await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); +} + +/** Helper: send CDP command on a tab */ +async function cdp(tabId: number, method: string, params?: Record): Promise { + return chrome.debugger.sendCommand({ tabId }, method, params); +} + +/** Helper: wait for page load after navigation */ +async function waitForLoad(tabId: number, timeoutMs: number): Promise { + // Check if already loaded before registering listener (avoids race condition) + try { + const tab = await chrome.tabs.get(tabId); + if (tab.status === 'complete') return; + } catch { return; } + + return new Promise((resolve) => { + const timer = setTimeout(() => { + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + }, timeoutMs); + + const listener = (updatedTabId: number, changeInfo: chrome.tabs.TabChangeInfo) => { + if (updatedTabId === tabId && changeInfo.status === 'complete') { + clearTimeout(timer); + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + } + }; + chrome.tabs.onUpdated.addListener(listener); + + // Double-check after listener is registered (another race window) + chrome.tabs.get(tabId).then(t => { + if (t.status === 'complete') { + clearTimeout(timer); + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + } + }).catch(() => { + clearTimeout(timer); + chrome.tabs.onUpdated.removeListener(listener); + resolve(); + }); + }); +} + +export function registerNavigationTools( + register: (name: string, handler: (params: Record) => Promise) => void, + pm: PageManager, +): void { + + register('new_page', async (params) => { + const url = (params.url as string) || 'about:blank'; + const background = params.background === true; + const timeout = (params.timeout as number) || 15000; + + const tab = await chrome.tabs.create({ url, active: !background }); + if (!tab.id) throw new Error('Failed to create tab'); + + const pageId = pm.getPageId(tab.id); + pm.selectPage(pageId); + + if (url !== 'about:blank') { + await waitForLoad(tab.id, timeout); + } + + const updatedTab = await chrome.tabs.get(tab.id); + return formatPageList([{ pageId, tab: updatedTab, selected: true }]); + }); + + register('open_page', async (params) => { + const url = (params.url as string) || 'about:blank'; + const background = params.background === true; + const timeout = (params.timeout as number) || 15000; + + const tab = await chrome.tabs.create({ url, active: !background }); + if (!tab.id) throw new Error('Failed to create tab'); + + const pageId = pm.getPageId(tab.id); + pm.selectPage(pageId); + + if (url !== 'about:blank') { + await waitForLoad(tab.id, timeout); + } + + const updatedTab = await chrome.tabs.get(tab.id); + return formatPageList([{ pageId, tab: updatedTab, selected: true }]); + }); + + register('close_page', async (params) => { + const pageId = params.pageId as number; + if (pageId === undefined) throw new Error('pageId is required'); + + const tabId = pm.getTabId(pageId); + if (tabId === undefined) throw new Error(`Page ${pageId} not found`); + + if (pm.isDebuggerAttached(tabId)) { + try { await chrome.debugger.detach({ tabId }); } catch { /* ignore */ } + } + await chrome.tabs.remove(tabId); + pm.removePage(pageId); + + return `Closed page ${pageId}`; + }); + + register('list_pages', async () => { + const tabs = await chrome.tabs.query({}); + const entries: Array<{ pageId: number; tab: chrome.tabs.Tab; selected: boolean }> = []; + + for (const tab of tabs) { + if (!tab.id || tab.id === chrome.tabs.TAB_ID_NONE) continue; + const pageId = pm.getPageId(tab.id); + entries.push({ pageId, tab, selected: pageId === pm.selectedPageId }); + } + + entries.sort((a, b) => a.pageId - b.pageId); + return formatPageList(entries); + }); + + register('select_page', async (params) => { + const pageId = params.pageId as number; + if (pageId === undefined) throw new Error('pageId is required'); + + const tabId = pm.getTabId(pageId); + if (tabId === undefined) throw new Error(`Page ${pageId} not found`); + + pm.selectPage(pageId); + + const bringToFront = params.bringToFront !== false; + if (bringToFront) { + await chrome.tabs.update(tabId, { active: true }); + const tab = await chrome.tabs.get(tabId); + if (tab.windowId) { + await chrome.windows.update(tab.windowId, { focused: true }); + } + } + + const tab = await chrome.tabs.get(tabId); + return formatPageList([{ pageId, tab, selected: true }]); + }); + + register('navigate_page', async (params) => { + const url = params.url as string | undefined; + const timeout = (params.timeout as number) || 15000; + + const tabId = pm.selectedTabId; + if (tabId === null) throw new Error('No page selected. Call new_page or select_page first.'); + + if (url) { + await ensureDebugger(pm, tabId); + await cdp(tabId, 'Page.navigate', { url }); + await waitForLoad(tabId, timeout); + } else if (params.action === 'back') { + await ensureDebugger(pm, tabId); + await cdp(tabId, 'Page.navigateToHistoryEntry', { entryId: -1 }).catch(() => { + return chrome.tabs.goBack(tabId); + }); + } else if (params.action === 'forward') { + await ensureDebugger(pm, tabId); + await cdp(tabId, 'Page.navigateToHistoryEntry', { entryId: 1 }).catch(() => { + return chrome.tabs.goForward(tabId); + }); + } else if (params.action === 'reload') { + await chrome.tabs.reload(tabId); + await waitForLoad(tabId, timeout); + } + + const tab = await chrome.tabs.get(tabId); + const pageId = pm.getPageId(tabId); + return formatPageList([{ pageId, tab, selected: true }]); + }); + + register('wait_for', async (params) => { + const text = params.text as string; + const timeout = (params.timeout as number) || 30000; + if (!text) throw new Error('text parameter is required'); + + const tabId = pm.selectedTabId; + if (tabId === null) throw new Error('No page selected.'); + + await ensureDebugger(pm, tabId); + + const startTime = Date.now(); + while (Date.now() - startTime < timeout) { + const result = await cdp(tabId, 'Runtime.evaluate', { + expression: `document.body?.innerText?.includes(${JSON.stringify(text)}) ?? false`, + returnByValue: true, + }) as { result?: { value?: boolean } }; + + if (result?.result?.value === true) { + return `Found text "${text}" on page`; + } + await new Promise(r => setTimeout(r, 500)); + } + + throw new Error(`Text "${text}" not found within ${timeout}ms`); + }); +} + +function formatPageList(entries: Array<{ pageId: number; tab: chrome.tabs.Tab; selected: boolean }>): string { + if (entries.length === 0) return 'No pages open'; + return entries.map(e => { + const url = e.tab.url || e.tab.pendingUrl || 'about:blank'; + const sel = e.selected ? ' [selected]' : ''; + return `${e.pageId}: ${url}${sel}`; + }).join('\n'); +} diff --git a/packages/chrome-extension/src/tools/network.ts b/packages/chrome-extension/src/tools/network.ts new file mode 100644 index 00000000..f62813bf --- /dev/null +++ b/packages/chrome-extension/src/tools/network.ts @@ -0,0 +1,182 @@ +/** + * Network tools: list_network_requests, get_network_request + * Performance tools: performance_start_trace, performance_stop_trace, etc. + * Emulation tools: emulate, resize_page + */ + +import type { PageManager } from '../page-manager.js'; + +async function cdp(tabId: number, method: string, params?: Record): Promise { + return chrome.debugger.sendCommand({ tabId }, method, params); +} + +async function ensureDebugger(pm: PageManager, tabId: number): Promise { + if (pm.isDebuggerAttached(tabId)) return; + await chrome.debugger.attach({ tabId }, '1.3'); + pm.setDebuggerAttached(tabId, true); + await chrome.debugger.sendCommand({ tabId }, 'Page.enable'); + await chrome.debugger.sendCommand({ tabId }, 'Runtime.enable'); +} + +function requireSelectedTab(pm: PageManager): number { + const tabId = pm.selectedTabId; + if (tabId === null) throw new Error('No page selected.'); + return tabId; +} + +// Network request storage per tab +interface StoredRequest { + id: string; + url: string; + method: string; + status?: number; + type?: string; + responseHeaders?: Record; + responseBody?: string; + timestamp: number; +} + +const networkRequests = new Map(); +let netIdCounter = 1; +const networkEnabled = new Set(); + +export function setupNetworkListener(): void { + chrome.debugger.onEvent.addListener((source, method, params) => { + if (!source.tabId) return; + const p = params as Record; + + if (method === 'Network.responseReceived') { + let reqs = networkRequests.get(source.tabId); + if (!reqs) { reqs = []; networkRequests.set(source.tabId, reqs); } + const response = p.response as Record | undefined; + reqs.push({ + id: `req${netIdCounter++}`, + url: (response?.url as string) ?? '', + method: (p.type as string) ?? 'GET', + status: response?.status as number | undefined, + type: p.type as string | undefined, + timestamp: Date.now(), + }); + if (reqs.length > 500) reqs.splice(0, reqs.length - 500); + } + }); +} + +async function enableNetwork(pm: PageManager, tabId: number): Promise { + await ensureDebugger(pm, tabId); + if (!networkEnabled.has(tabId)) { + await cdp(tabId, 'Network.enable'); + networkEnabled.add(tabId); + } +} + +export function registerNetworkTools( + register: (name: string, handler: (params: Record) => Promise) => void, + pm: PageManager, +): void { + + register('list_network_requests', async () => { + const tabId = requireSelectedTab(pm); + await enableNetwork(pm, tabId); + + const reqs = networkRequests.get(tabId) ?? []; + if (reqs.length === 0) return 'No network requests captured'; + + return reqs.map(r => + `${r.id}: ${r.method ?? 'GET'} ${r.url} → ${r.status ?? 'pending'}` + ).join('\n'); + }); + + register('get_network_request', async (params) => { + const reqId = params.reqid as string ?? params.id as string; + if (!reqId) throw new Error('reqid is required'); + const tabId = requireSelectedTab(pm); + + const reqs = networkRequests.get(tabId) ?? []; + const req = reqs.find(r => r.id === reqId); + if (!req) return `Network request ${reqId} not found`; + + return JSON.stringify({ + id: req.id, + url: req.url, + method: req.method, + status: req.status, + type: req.type, + }, null, 2); + }); + + register('performance_start_trace', async () => { + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + await cdp(tabId, 'Tracing.start', { + categories: '-*,devtools.timeline,v8.execute,disabled-by-default-devtools.timeline', + }); + return 'Performance trace started'; + }); + + register('performance_stop_trace', async () => { + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + await cdp(tabId, 'Tracing.end'); + return 'Performance trace stopped. Results will be available via tracing events.'; + }); + + register('performance_analyze_insight', async (params) => { + const insightId = params.insightId as string; + return `Performance insight analysis for "${insightId}" is not available in extension mode. Use chrome-devtools-mcp for full performance profiling.`; + }); + + register('take_heapsnapshot', async () => { + return 'Heap snapshots are not available in extension mode. Use chrome-devtools-mcp for memory profiling.'; + }); + + register('emulate', async (params) => { + const tabId = requireSelectedTab(pm); + await ensureDebugger(pm, tabId); + + if (params.width || params.height) { + await cdp(tabId, 'Emulation.setDeviceMetricsOverride', { + width: (params.width as number) || 0, + height: (params.height as number) || 0, + deviceScaleFactor: (params.deviceScaleFactor as number) || 1, + mobile: params.mobile === true, + }); + } + + if (params.userAgent) { + await cdp(tabId, 'Emulation.setUserAgentOverride', { + userAgent: params.userAgent as string, + }); + } + + if (params.geolocation) { + const geo = params.geolocation as { latitude: number; longitude: number; accuracy?: number }; + await cdp(tabId, 'Emulation.setGeolocationOverride', { + latitude: geo.latitude, + longitude: geo.longitude, + accuracy: geo.accuracy ?? 1, + }); + } + + if (params.colorScheme) { + await cdp(tabId, 'Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-color-scheme', value: params.colorScheme as string }], + }); + } + + return 'Emulation settings applied'; + }); + + register('resize_page', async (params) => { + const width = params.width as number; + const height = params.height as number; + if (!width || !height) throw new Error('width and height are required'); + const tabId = requireSelectedTab(pm); + + const tab = await chrome.tabs.get(tabId); + if (tab.windowId) { + await chrome.windows.update(tab.windowId, { width, height }); + } + return `Resized to ${width}x${height}`; + }); +} diff --git a/packages/chrome-extension/tsconfig.json b/packages/chrome-extension/tsconfig.json new file mode 100644 index 00000000..c2b34458 --- /dev/null +++ b/packages/chrome-extension/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "types": ["chrome"] + }, + "include": ["src"] +} diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index fc8346b4..96bc82ba 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -308,6 +308,7 @@ async function createServices(config: ReturnType) { if (config.browser?.autoClickAllowDialog) { agentManager.setBrowserAutoClickAllowDialog(true); } + agentManager.startBrowserBridge(config.browser?.extensionBridgePort); taskService.setAgentManager(agentManager); diff --git a/packages/core/package.json b/packages/core/package.json index 430d6f4f..d9a89bae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,9 +19,11 @@ "js-tiktoken": "^1.0.21", "linkedom": "^0.18.12", "node-cron": "^3.0.3", - "turndown": "^7.2.2" + "turndown": "^7.2.2", + "ws": "^8.19.0" }, "devDependencies": { - "@types/turndown": "^5.0.6" + "@types/turndown": "^5.0.6", + "@types/ws": "^8.18.1" } } diff --git a/packages/core/src/agent-manager.ts b/packages/core/src/agent-manager.ts index 135c9a08..e2e56d98 100644 --- a/packages/core/src/agent-manager.ts +++ b/packages/core/src/agent-manager.ts @@ -35,6 +35,8 @@ import { createRecallTool, type RecallCallbacks } from './tools/recall.js'; import { SemanticMemorySearch, OpenAIEmbeddingProvider, LocalVectorStore } from './memory/semantic-search.js'; import type { SkillRegistry } from './skills/types.js'; import { clickChromeAllowDialog } from './tools/chrome-dialog-clicker.js'; +import { MarkusBrowserBridge } from './tools/markus-browser-bridge.js'; +import { createBridgeToolHandlers, getBridgeToolDescriptors } from './tools/markus-browser-mcp.js'; import { SecurityGuard, type SecurityPolicy } from './security.js'; import { DelegationManager, type TaskDelegation } from '@markus/a2a'; import type { TemplateRegistry } from './templates/registry.js'; @@ -305,6 +307,7 @@ export class AgentManager { private remoteDebuggingPort = 0; private autoClickAllowDialog = false; private chromeAutoClickRunning = false; + private browserBridge: MarkusBrowserBridge; private globalSecurityPolicy?: SecurityPolicy; private globalMcpServers?: Record; private skillRegistry?: SkillRegistry; @@ -501,6 +504,7 @@ export class AgentManager { this.triggerChromeDialogAutoClick(serverName); }); this.browserSessionManager = new BrowserSessionManager(); + this.browserBridge = new MarkusBrowserBridge(); this.globalSecurityPolicy = options.securityPolicy; this.globalMcpServers = options.mcpServers; this.skillRegistry = options.skillRegistry; @@ -597,6 +601,25 @@ export class AgentManager { this.autoClickAllowDialog = enabled; } + startBrowserBridge(port?: number): void { + if (port !== undefined) { + this.browserBridge = new MarkusBrowserBridge(port); + } + this.browserBridge.start(); + } + + stopBrowserBridge(): void { + this.browserBridge.stop(); + } + + get browserExtensionConnected(): boolean { + return this.browserBridge.connected; + } + + getBrowserBridge(): MarkusBrowserBridge { + return this.browserBridge; + } + /** * When remoteDebuggingPort is configured, replace --autoConnect with * --browserUrl so that the chrome-devtools MCP server reuses a persistent @@ -632,33 +655,45 @@ export class AgentManager { /** * Register chrome-devtools tools for an agent WITHOUT starting the MCP process. - * Uses cached tool descriptors if available; otherwise does one connection to populate cache. - * The MCP process starts lazily on the agent's first browser tool call. + * + * Tool handlers dynamically choose bridge vs npx at CALL TIME: + * - If the Chrome extension is connected, use the WebSocket bridge (no dialog, instant). + * - Otherwise, fall back to npx chrome-devtools-mcp (lazy-started on first call). + * + * This ensures agents created before the extension connects can still + * use the bridge once it becomes available, and vice versa. */ private async registerChromeDevtoolsLazy( agentId: string, serverName: string, serverConfig: { command: string; args?: string[]; env?: Record }, ): Promise { - let cachedTools = this.mcpManager.getCachedTools(serverName); - if (!cachedTools) { - // First-ever connection: connect to get tool list, then disconnect immediately. - // The MCP process is only needed to discover available tools. - this.triggerChromeDialogAutoClick(serverName); - await this.mcpManager.connectServerScoped(serverName, serverConfig, agentId); - cachedTools = this.mcpManager.getCachedTools(serverName); - await this.mcpManager.disconnectServerScoped(serverName, agentId); - if (!cachedTools) { - throw new Error(`Failed to get tool list from ${serverName}`); - } - } + const toolDescriptors = getBridgeToolDescriptors(); + + // Register npx config lazily so callToolScoped can auto-connect when needed. + this.mcpManager.registerLazyScoped(serverName, serverConfig, agentId, toolDescriptors); + + let mcpTools: AgentToolHandler[] = toolDescriptors.map((tool) => ({ + name: `${serverName}__${tool.name}`, + description: `[MCP:${serverName}] ${tool.description}`, + inputSchema: tool.inputSchema, + execute: async (args: Record) => { + if (this.browserBridge.connected) { + const result = await this.browserBridge.callTool(tool.name, args); + if (result.error) return `Error: ${result.error}`; + return result.content; + } + // npx fallback; auto-click is triggered by mcpManager's onReconnect callback + return this.mcpManager.callToolScoped(serverName, agentId, tool.name, args); + }, + })); - // Lazy registration: tools call back to callToolByKey which auto-connects. - let mcpTools = this.mcpManager.registerLazyScoped(serverName, serverConfig, agentId, cachedTools); mcpTools = this.browserSessionManager.wrapToolHandlers(mcpTools, agentId); this.browserSessionManager.setReconnector(agentId, serverName, async () => { - await this.mcpManager.disconnectServerScoped(serverName, agentId); - await this.mcpManager.connectServerScoped(serverName, serverConfig, agentId); + if (!this.browserBridge.connected) { + await this.mcpManager.disconnectServerScoped(serverName, agentId); + await this.mcpManager.connectServerScoped(serverName, serverConfig, agentId); + } }); return mcpTools; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c0ae2b9a..84be4669 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -242,3 +242,5 @@ export { type AutoClickCheckResult, type AutoClickTestResult, } from './tools/chrome-dialog-clicker.js'; +export { MarkusBrowserBridge } from './tools/markus-browser-bridge.js'; +export { createBridgeToolHandlers, getBridgeToolDescriptors } from './tools/markus-browser-mcp.js'; diff --git a/packages/core/src/tools/markus-browser-bridge.ts b/packages/core/src/tools/markus-browser-bridge.ts new file mode 100644 index 00000000..23ba0b73 --- /dev/null +++ b/packages/core/src/tools/markus-browser-bridge.ts @@ -0,0 +1,164 @@ +/** + * WebSocket bridge between Markus and the Chrome extension. + * + * Markus side (this file) runs a WS server. The Chrome extension connects + * as a client. Tool calls are forwarded over the socket and results returned. + */ + +import { WebSocketServer, type WebSocket } from 'ws'; +import { createLogger } from '@markus/shared'; + +const log = createLogger('browser-bridge'); + +export interface BridgeToolResult { + content: string; + error?: string; +} + +interface PendingCall { + resolve: (result: BridgeToolResult) => void; + reject: (err: Error) => void; + timer: ReturnType; +} + +const DEFAULT_PORT = 9333; +const TOOL_CALL_TIMEOUT_MS = 120_000; + +export class MarkusBrowserBridge { + private wss: WebSocketServer | null = null; + private client: WebSocket | null = null; + private requestId = 0; + private pending = new Map(); + private port: number; + private _started = false; + private connectionListeners: Array<(connected: boolean) => void> = []; + + constructor(port?: number) { + this.port = port ?? DEFAULT_PORT; + } + + get started(): boolean { return this._started; } + get connected(): boolean { return this.client?.readyState === 1; } + + onConnectionChange(listener: (connected: boolean) => void): void { + this.connectionListeners.push(listener); + } + + private notifyConnectionChange(connected: boolean): void { + for (const listener of this.connectionListeners) { + try { listener(connected); } catch { /* ignore */ } + } + } + + start(): void { + if (this._started) return; + this._started = true; + + this.wss = new WebSocketServer({ port: this.port, host: '127.0.0.1' }); + + this.wss.on('listening', () => { + log.info(`Browser bridge WebSocket server listening on ws://127.0.0.1:${this.port}`); + }); + + this.wss.on('error', (err) => { + log.warn(`Browser bridge WebSocket server error: ${err.message}`); + }); + + this.wss.on('connection', (ws) => { + if (this.client) { + log.info('New extension connection replacing existing one'); + this.client.close(); + } + this.client = ws; + log.info('Chrome extension connected to browser bridge'); + this.notifyConnectionChange(true); + + ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + this.handleMessage(msg); + } catch (err) { + log.warn(`Invalid message from extension: ${err}`); + } + }); + + ws.on('close', () => { + if (this.client === ws) { + this.client = null; + log.info('Chrome extension disconnected from browser bridge'); + this.notifyConnectionChange(false); + this.rejectAllPending('Extension disconnected'); + } + }); + + ws.on('error', (err) => { + log.warn(`Extension WebSocket error: ${err.message}`); + }); + }); + } + + stop(): void { + this.rejectAllPending('Bridge shutting down'); + if (this.client) { + this.client.close(); + this.client = null; + } + if (this.wss) { + this.wss.close(); + this.wss = null; + } + this._started = false; + } + + /** + * Call a tool on the Chrome extension and wait for the result. + */ + async callTool(name: string, args: Record): Promise { + if (!this.connected) { + throw new Error('Chrome extension not connected'); + } + + const id = ++this.requestId; + const message = JSON.stringify({ id, method: name, params: args }); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Tool call ${name} timed out after ${TOOL_CALL_TIMEOUT_MS}ms`)); + }, TOOL_CALL_TIMEOUT_MS); + + this.pending.set(id, { resolve, reject, timer }); + this.client!.send(message); + }); + } + + private handleMessage(msg: { id?: number; result?: unknown; error?: string; event?: string; data?: unknown }): void { + if (msg.event) { + log.debug(`Extension event: ${msg.event}`, msg.data as Record); + return; + } + + if (msg.id === undefined) return; + + const pending = this.pending.get(msg.id); + if (!pending) return; + + this.pending.delete(msg.id); + clearTimeout(pending.timer); + + if (msg.error) { + pending.resolve({ content: '', error: msg.error }); + } else { + const text = typeof msg.result === 'string' ? msg.result : JSON.stringify(msg.result); + pending.resolve({ content: text }); + } + } + + private rejectAllPending(reason: string): void { + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(new Error(reason)); + this.pending.delete(id); + } + } +} diff --git a/packages/core/src/tools/markus-browser-mcp.ts b/packages/core/src/tools/markus-browser-mcp.ts new file mode 100644 index 00000000..1ddd6b4b --- /dev/null +++ b/packages/core/src/tools/markus-browser-mcp.ts @@ -0,0 +1,190 @@ +/** + * In-process MCP-compatible tool provider that forwards tool calls + * to the Chrome extension via the WebSocket bridge. + * + * When the extension is connected, this replaces chrome-devtools-mcp entirely, + * avoiding the "Allow debugging?" dialog and npx startup overhead. + * + * The tool list mirrors chrome-devtools-mcp's tools so that BrowserSessionManager + * and agents work identically regardless of which backend is used. + */ + +import { createLogger } from '@markus/shared'; +import type { MarkusBrowserBridge } from './markus-browser-bridge.js'; +import type { MCPToolDescriptor } from './mcp-client.js'; +import type { AgentToolHandler } from '../agent.js'; + +const log = createLogger('browser-mcp'); + +/** + * Tool descriptors matching chrome-devtools-mcp's tool interface. + * These are registered when the extension is connected. + */ +const TOOL_DESCRIPTORS: MCPToolDescriptor[] = [ + // Navigation tools + { + name: 'new_page', + description: 'Create a new browser page/tab and optionally navigate to a URL', + inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'URL to navigate to (default: about:blank)' }, background: { type: 'boolean', description: 'Open in background' }, timeout: { type: 'number', description: 'Timeout in ms (default: 60000)' } } }, + }, + { + name: 'open_page', + description: 'Open a new browser page/tab and navigate to a URL', + inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'URL to navigate to' }, background: { type: 'boolean', description: 'Open in background' }, timeout: { type: 'number', description: 'Timeout in ms (default: 60000)' } } }, + }, + { + name: 'close_page', + description: 'Close a browser page/tab', + inputSchema: { type: 'object', properties: { pageId: { type: 'number', description: 'Page ID to close' } }, required: ['pageId'] }, + }, + { + name: 'list_pages', + description: 'List all open browser pages/tabs', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'select_page', + description: 'Select a browser page/tab as the active one', + inputSchema: { type: 'object', properties: { pageId: { type: 'number', description: 'Page ID to select' } }, required: ['pageId'] }, + }, + { + name: 'navigate_page', + description: 'Navigate the selected page to a URL or go back/forward/reload', + inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'URL to navigate to' }, action: { type: 'string', enum: ['back', 'forward', 'reload'] }, timeout: { type: 'number', description: 'Timeout in ms' } } }, + }, + { + name: 'wait_for', + description: 'Wait for text to appear on the selected page', + inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to wait for' }, timeout: { type: 'number', description: 'Timeout in ms (default: 30000)' } }, required: ['text'] }, + }, + // Input tools + { + name: 'click', + description: 'Click an element identified by its accessibility snapshot uid', + inputSchema: { type: 'object', properties: { uid: { type: 'string', description: 'Element uid from snapshot' } }, required: ['uid'] }, + }, + { + name: 'fill', + description: 'Fill an input element with text (replaces existing content)', + inputSchema: { type: 'object', properties: { uid: { type: 'string', description: 'Element uid from snapshot' }, value: { type: 'string', description: 'Text to fill' } }, required: ['uid', 'value'] }, + }, + { + name: 'fill_form', + description: 'Fill multiple form fields at once', + inputSchema: { type: 'object', properties: { fields: { type: 'array', items: { type: 'object', properties: { uid: { type: 'string' }, value: { type: 'string' } }, required: ['uid', 'value'] } } }, required: ['fields'] }, + }, + { + name: 'type_text', + description: 'Type text into the currently focused element', + inputSchema: { type: 'object', properties: { text: { type: 'string', description: 'Text to type' } }, required: ['text'] }, + }, + { + name: 'press_key', + description: 'Press a keyboard key or key combination (e.g. Enter, Ctrl+A)', + inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'Key or combination (e.g. Enter, Ctrl+A)' } }, required: ['key'] }, + }, + { + name: 'hover', + description: 'Hover over an element identified by its accessibility snapshot uid', + inputSchema: { type: 'object', properties: { uid: { type: 'string', description: 'Element uid from snapshot' } }, required: ['uid'] }, + }, + { + name: 'drag', + description: 'Drag from one element to another', + inputSchema: { type: 'object', properties: { from_uid: { type: 'string' }, to_uid: { type: 'string' } }, required: ['from_uid', 'to_uid'] }, + }, + { + name: 'handle_dialog', + description: 'Accept or dismiss a JavaScript dialog (alert, confirm, prompt)', + inputSchema: { type: 'object', properties: { accept: { type: 'boolean', description: 'Accept (true) or dismiss (false)' }, promptText: { type: 'string', description: 'Text for prompt dialog' } } }, + }, + { + name: 'upload_file', + description: 'Upload a file to a file input element', + inputSchema: { type: 'object', properties: { uid: { type: 'string', description: 'File input element uid' }, filePath: { type: 'string', description: 'Path to file to upload' } }, required: ['uid', 'filePath'] }, + }, + // Inspection tools + { + name: 'take_screenshot', + description: 'Take a screenshot of the selected page', + inputSchema: { type: 'object', properties: { format: { type: 'string', description: 'Image format (png or jpg)' }, quality: { type: 'number', description: 'Quality 0-100 for jpg' }, fullPage: { type: 'boolean', description: 'Capture full page' } } }, + }, + { + name: 'take_snapshot', + description: 'Take an accessibility tree snapshot of the selected page', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'evaluate_script', + description: 'Evaluate JavaScript in the selected page', + inputSchema: { type: 'object', properties: { expression: { type: 'string', description: 'JavaScript expression to evaluate' } }, required: ['expression'] }, + }, + { + name: 'get_console_message', + description: 'Get a specific console message by ID', + inputSchema: { type: 'object', properties: { msgid: { type: 'number', description: 'Message ID' } }, required: ['msgid'] }, + }, + { + name: 'list_console_messages', + description: 'List all console messages from the selected page', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'lighthouse_audit', + description: 'Run an accessibility audit on the selected page', + inputSchema: { type: 'object', properties: { categories: { type: 'array', items: { type: 'string' } } } }, + }, + // Network tools + { + name: 'list_network_requests', + description: 'List captured network requests from the selected page', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'get_network_request', + description: 'Get details of a specific network request', + inputSchema: { type: 'object', properties: { reqid: { type: 'string', description: 'Request ID' } }, required: ['reqid'] }, + }, + // Emulation tools + { + name: 'emulate', + description: 'Set device emulation (viewport, user agent, geolocation, color scheme)', + inputSchema: { type: 'object', properties: { width: { type: 'number' }, height: { type: 'number' }, deviceScaleFactor: { type: 'number' }, mobile: { type: 'boolean' }, userAgent: { type: 'string' }, geolocation: { type: 'object', properties: { latitude: { type: 'number' }, longitude: { type: 'number' } } }, colorScheme: { type: 'string' } } }, + }, + { + name: 'resize_page', + description: 'Resize the browser window', + inputSchema: { type: 'object', properties: { width: { type: 'number' }, height: { type: 'number' } }, required: ['width', 'height'] }, + }, +]; + +/** + * Creates tool handlers that forward calls through the browser bridge. + * These handlers have the same interface as MCPClientManager's tool handlers, + * so they can be used with BrowserSessionManager.wrapToolHandlers(). + */ +export function createBridgeToolHandlers( + bridge: MarkusBrowserBridge, + serverName: string, +): AgentToolHandler[] { + return TOOL_DESCRIPTORS.map((tool) => ({ + name: `${serverName}__${tool.name}`, + description: `[MCP:${serverName}] ${tool.description}`, + inputSchema: tool.inputSchema, + execute: async (args: Record) => { + const result = await bridge.callTool(tool.name, args); + if (result.error) { + return `Error: ${result.error}`; + } + return result.content; + }, + })); +} + +/** + * Get the static tool descriptors (useful for lazy registration when + * the extension is connected but we don't need to spawn chrome-devtools-mcp). + */ +export function getBridgeToolDescriptors(): MCPToolDescriptor[] { + return TOOL_DESCRIPTORS; +} diff --git a/packages/org-manager/src/api-server.ts b/packages/org-manager/src/api-server.ts index 81aa99b7..7939f54a 100644 --- a/packages/org-manager/src/api-server.ts +++ b/packages/org-manager/src/api-server.ts @@ -7095,11 +7095,14 @@ EXPLANATION_END`; const { loadConfig: loadCfg } = await import('@markus/shared'); const currentConfig = loadCfg(this.markusConfigPath); const browser = currentConfig.browser ?? {}; + const am = this.orgService.getAgentManager(); this.json(res, 200, { bringToFront: browser.bringToFront ?? false, remoteDebuggingPort: browser.remoteDebuggingPort ?? 0, autoCloseTabs: browser.autoCloseTabs ?? true, autoClickAllowDialog: browser.autoClickAllowDialog ?? false, + extensionBridgePort: browser.extensionBridgePort ?? 9333, + extensionConnected: am.browserExtensionConnected, }); return; } @@ -7145,15 +7148,88 @@ EXPLANATION_END`; const { loadConfig: loadCfg } = await import('@markus/shared'); const currentConfig = loadCfg(this.markusConfigPath); const browser = currentConfig.browser ?? {}; + const am2 = this.orgService.getAgentManager(); this.json(res, 200, { bringToFront: browser.bringToFront ?? false, remoteDebuggingPort: browser.remoteDebuggingPort ?? 0, autoCloseTabs: browser.autoCloseTabs ?? true, autoClickAllowDialog: browser.autoClickAllowDialog ?? false, + extensionBridgePort: browser.extensionBridgePort ?? 9333, + extensionConnected: am2.browserExtensionConnected, }); return; } + // Settings — Chrome Extension: download zip + if (path === '/api/settings/browser/extension.zip' && req.method === 'GET') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + + try { + const { fileURLToPath } = await import('node:url'); + const { dirname: dn, resolve: rslv, join: jn } = await import('node:path'); + const { execSync } = await import('node:child_process'); + const { existsSync: ex, readFileSync, statSync } = await import('node:fs'); + + const thisDir = dn(fileURLToPath(import.meta.url)); + // Search order: dev workspace → binary install → cwd fallback + const zipCandidates = [ + jn(rslv(thisDir, '..', '..', 'chrome-extension'), 'dist', 'markus-browser-extension.zip'), + jn(rslv(thisDir, '..', '..', '..', 'chrome-extension'), 'markus-browser-extension.zip'), + jn(rslv(process.cwd(), 'packages', 'chrome-extension'), 'dist', 'markus-browser-extension.zip'), + jn(rslv(process.cwd(), 'chrome-extension'), 'markus-browser-extension.zip'), + ]; + let zipPath = zipCandidates.find(p => ex(p)); + + // If not found, try building from source + if (!zipPath) { + const extDir = [ + rslv(thisDir, '..', '..', 'chrome-extension'), + rslv(process.cwd(), 'packages', 'chrome-extension'), + ].find(d => ex(jn(d, 'package.json'))); + if (extDir) { + try { execSync('pnpm run pack', { cwd: extDir, timeout: 30000, stdio: 'pipe' }); } catch { /* ignore */ } + const built = jn(extDir, 'dist', 'markus-browser-extension.zip'); + if (ex(built)) zipPath = built; + } + } + if (!zipPath) { this.json(res, 404, { error: 'Extension zip not found.' }); return; } + + const data = readFileSync(zipPath); + res.writeHead(200, { + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="markus-browser-extension.zip"', + 'Content-Length': data.length, + }); + res.end(data); + } catch (e) { + this.json(res, 500, { error: String(e) }); + } + return; + } + + // Settings — Chrome Extension: open chrome://extensions page + if (path === '/api/settings/browser/open-extensions-page' && req.method === 'POST') { + const auth = await this.requireAuth(req, res); + if (!auth) return; + + try { + const { exec: execCb } = await import('node:child_process'); + const platform = process.platform; + if (platform === 'darwin') { + execCb('open -a "Google Chrome" "chrome://extensions"', () => {}); + } else if (platform === 'win32') { + execCb('start chrome "chrome://extensions"', () => {}); + } else { + execCb('xdg-open "chrome://extensions" 2>/dev/null || google-chrome "chrome://extensions"', () => {}); + } + this.json(res, 200, { ok: true }); + } catch (e) { + this.json(res, 500, { error: String(e) }); + } + return; + } + // Settings — Browser auto-click test if (path === '/api/settings/browser/test-auto-click' && req.method === 'POST') { const auth = await this.requireAuth(req, res); diff --git a/packages/shared/src/utils/config.ts b/packages/shared/src/utils/config.ts index 3ff670d9..112f9391 100644 --- a/packages/shared/src/utils/config.ts +++ b/packages/shared/src/utils/config.ts @@ -71,6 +71,8 @@ export interface MarkusConfig { autoCloseTabs?: boolean; /** Auto-click Chrome's "Allow debugging" dialog via OS accessibility APIs (macOS/Windows) */ autoClickAllowDialog?: boolean; + /** WebSocket port for Chrome extension bridge (default: 9333) */ + extensionBridgePort?: number; }; integrations?: { feishu?: { appId?: string; appSecret?: string }; diff --git a/packages/web-ui/src/api.ts b/packages/web-ui/src/api.ts index 68ae70a3..25807b64 100644 --- a/packages/web-ui/src/api.ts +++ b/packages/web-ui/src/api.ts @@ -1184,9 +1184,9 @@ export const api = { getAgent: () => request<{ maxToolIterations: number; cognitive: { enabled: boolean; maxDepth?: number; appraisalModel?: string; timeoutMs?: number } }>('/settings/agent'), updateAgent: (settings: { maxToolIterations?: number; cognitive?: { enabled?: boolean; maxDepth?: number; appraisalModel?: string; timeoutMs?: number } }) => request<{ maxToolIterations: number; cognitive: { enabled: boolean; maxDepth?: number; appraisalModel?: string; timeoutMs?: number } }>('/settings/agent', { method: 'POST', body: JSON.stringify(settings) }), - getBrowser: () => request<{ bringToFront: boolean; remoteDebuggingPort: number; autoCloseTabs: boolean; autoClickAllowDialog: boolean }>('/settings/browser'), + getBrowser: () => request<{ bringToFront: boolean; remoteDebuggingPort: number; autoCloseTabs: boolean; autoClickAllowDialog: boolean; extensionBridgePort: number; extensionConnected: boolean }>('/settings/browser'), updateBrowser: (settings: { bringToFront?: boolean; remoteDebuggingPort?: number; autoCloseTabs?: boolean; autoClickAllowDialog?: boolean }) => - request<{ bringToFront: boolean; remoteDebuggingPort: number; autoCloseTabs: boolean; autoClickAllowDialog: boolean }>('/settings/browser', { method: 'POST', body: JSON.stringify(settings) }), + request<{ bringToFront: boolean; remoteDebuggingPort: number; autoCloseTabs: boolean; autoClickAllowDialog: boolean; extensionBridgePort: number; extensionConnected: boolean }>('/settings/browser', { method: 'POST', body: JSON.stringify(settings) }), testAutoClick: () => request<{ checkResult: { platform: string; supported: boolean; accessibilityPermission: boolean; chromeRunning: boolean; binaryAvailable: boolean }; openedAccessibilitySettings: boolean; @@ -1195,6 +1195,23 @@ export const api = { pageTitle?: string; error?: string; }>('/settings/browser/test-auto-click', { method: 'POST' }), + downloadExtensionZip: () => { + const url = `${BASE}/settings/browser/extension.zip`; + const headers: Record = {}; + const token = localStorage.getItem('markus_token'); + if (token) headers['Authorization'] = `Bearer ${token}`; + return fetch(url, { headers }).then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.blob(); + }).then(blob => { + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'markus-browser-extension.zip'; + a.click(); + URL.revokeObjectURL(a.href); + }); + }, + openExtensionsPage: () => request<{ ok: boolean }>('/settings/browser/open-extensions-page', { method: 'POST' }), getSearch: () => request<{ serper: { configured: boolean; preview: string }; brave: { configured: boolean; preview: string }; bocha: { configured: boolean; preview: string } }>('/settings/search'), updateSearch: (keys: { serperApiKey?: string; braveApiKey?: string; bochaApiKey?: string }) => request<{ serper: { configured: boolean; preview: string }; brave: { configured: boolean; preview: string }; bocha: { configured: boolean; preview: string } }>('/settings/search', { method: 'POST', body: JSON.stringify(keys) }), diff --git a/packages/web-ui/src/locales/en/settings.json b/packages/web-ui/src/locales/en/settings.json index 8e70011a..38147aa1 100644 --- a/packages/web-ui/src/locales/en/settings.json +++ b/packages/web-ui/src/locales/en/settings.json @@ -49,6 +49,16 @@ "title": "Manual Configuration", "description": "Edit ~/.markus/markus.json directly, then restart the server." }, + "browserExtension": { + "step": "5", + "title": "Install Browser Automation Extension", + "description": "Let AI agents control Chrome to browse, test apps, and inspect pages — no permission dialog each time.", + "notChrome": "Your current browser is not Chrome. Open Markus in Chrome to install the extension.", + "alreadyConnected": "Extension is already connected.", + "downloadBtn": "Download Extension", + "openChromeBtn": "Open Extensions Page", + "loadHint": "After unzipping, go to Chrome Extensions page → enable \"Developer mode\" → \"Load unpacked\" → select the unzipped folder" + }, "applyProviders": "Apply {{count}} Provider(s)", "foundPrefix": "Found:" }, @@ -266,7 +276,35 @@ "autoClickTestNoPermission": "Accessibility permission not granted. The system settings page has been opened — please add this app to the list.", "autoClickTestChromeNotRunning": "Chrome is not running. Please start Chrome first.", "autoClickTestUnsupported": "Auto-click is not supported on this platform. Use Remote Debugging Port instead.", - "autoClickTestError": "Test failed: {{error}}" + "autoClickTestError": "Test failed: {{error}}", + "modeExtension": "Chrome Extension (Active)", + "modeExtensionDesc": "Browser tools route through the extension — no debugging dialog, no extra permissions, works when screen is locked.", + "modeDebuggingPort": "Remote Debugging Port :{{port}} (Active)", + "modeDebuggingPortDesc": "Connected via persistent debugging port — no permission dialog on each connection.", + "modeAutoClick": "Auto-Connect + Auto-Click (Active)", + "modeAutoClickDesc": "Using chrome-devtools-mcp with OS auto-click to handle the debugging dialog. May not work when screen is locked.", + "modeManual": "Auto-Connect (Manual Approval Required)", + "modeManualDesc": "Chrome will show a permission dialog each time an agent connects. You must click Allow manually.", + "modeRecommended": "recommended", + "generalSettings": "General", + "fallbackModes": "Connection Mode (Fallback)", + "extensionStatus": "Chrome Extension", + "extensionStatusDesc": "Enables browser automation without the debugging dialog. No Accessibility permissions needed, works even when screen is locked.", + "extensionConnected": "Connected", + "extensionDisconnected": "Not Connected", + "extensionActiveNote": "Extension active — browser tools route through extension (no debugging dialog)", + "extensionSetupTitle": "Install Chrome Extension", + "extensionStep1": "Download & Unzip", + "extensionStep1Desc": "Download the zip and extract it to any location (e.g. Desktop)", + "extensionStep1Btn": "Download markus-browser-extension.zip", + "extensionStep1Downloading": "Preparing download...", + "extensionStep2": "Open Chrome Extensions", + "extensionStep2Desc": "Open the extensions management page in Chrome", + "extensionStep2Btn": "Open chrome://extensions", + "extensionStep3": "Load Extension", + "extensionStep3Desc": "Enable \"Developer mode\" (top right toggle), click \"Load unpacked\", and select the folder extracted in step 1", + "extensionDownloadError": "Failed to download extension", + "extensionOpenError": "Failed to open Chrome extensions page" }, "dataStorage": { "title": "Data & Storage", diff --git a/packages/web-ui/src/locales/zh-CN/settings.json b/packages/web-ui/src/locales/zh-CN/settings.json index 4caf3d02..6aacbc13 100644 --- a/packages/web-ui/src/locales/zh-CN/settings.json +++ b/packages/web-ui/src/locales/zh-CN/settings.json @@ -49,6 +49,16 @@ "title": "手动配置", "description": "直接编辑 ~/.markus/markus.json,然后重启服务器。" }, + "browserExtension": { + "step": "5", + "title": "安装浏览器自动化扩展", + "description": "让 AI 智能体能直接操控 Chrome 浏览网页、测试应用,无需每次弹窗授权。", + "notChrome": "当前浏览器不是 Chrome,请在 Chrome 中打开 Markus 以安装扩展。", + "alreadyConnected": "扩展已连接,无需操作。", + "downloadBtn": "下载扩展", + "openChromeBtn": "打开扩展页面", + "loadHint": "解压后,在 Chrome 扩展页面开启「开发者模式」→「加载已解压的扩展程序」→ 选择解压文件夹" + }, "applyProviders": "应用 {{count}} 个提供商", "foundPrefix": "已找到:" }, @@ -266,7 +276,35 @@ "autoClickTestNoPermission": "辅助功能权限未授予。已打开系统设置页面,请将本应用添加到列表中。", "autoClickTestChromeNotRunning": "Chrome 未运行。请先启动 Chrome。", "autoClickTestUnsupported": "当前平台不支持自动点击。请使用「远程调试端口」作为替代方案。", - "autoClickTestError": "测试失败:{{error}}" + "autoClickTestError": "测试失败:{{error}}", + "modeExtension": "Chrome 扩展(已激活)", + "modeExtensionDesc": "浏览器工具通过扩展运行 — 无调试弹窗、无需额外权限、锁屏可用。", + "modeDebuggingPort": "远程调试端口 :{{port}}(已激活)", + "modeDebuggingPortDesc": "通过持久调试端口连接 — 每次连接无需权限弹窗。", + "modeAutoClick": "自动连接 + 自动点击(已激活)", + "modeAutoClickDesc": "使用 chrome-devtools-mcp 并通过系统 API 自动点击调试弹窗。锁屏时可能不工作。", + "modeManual": "自动连接(需手动允许)", + "modeManualDesc": "每次 Agent 连接浏览器时,Chrome 会弹出权限对话框,需要手动点击「允许」。", + "modeRecommended": "推荐", + "generalSettings": "通用设置", + "fallbackModes": "连接方式(备选)", + "extensionStatus": "Chrome 扩展", + "extensionStatusDesc": "无需调试弹窗即可实现浏览器自动化。无需辅助功能权限,锁屏状态下也能正常工作。", + "extensionConnected": "已连接", + "extensionDisconnected": "未连接", + "extensionActiveNote": "扩展已激活 — 浏览器工具通过扩展运行(无调试弹窗)", + "extensionSetupTitle": "安装 Chrome 扩展", + "extensionStep1": "下载并解压扩展", + "extensionStep1Desc": "下载压缩包后,解压到任意位置(如桌面)", + "extensionStep1Btn": "下载 markus-browser-extension.zip", + "extensionStep1Downloading": "准备下载...", + "extensionStep2": "打开 Chrome 扩展页面", + "extensionStep2Desc": "在 Chrome 中打开扩展管理页面", + "extensionStep2Btn": "打开 chrome://extensions", + "extensionStep3": "加载扩展", + "extensionStep3Desc": "开启右上角的「开发者模式」开关,点击「加载已解压的扩展程序」,选择第 1 步解压出来的文件夹", + "extensionDownloadError": "下载扩展失败", + "extensionOpenError": "打开 Chrome 扩展页面失败" }, "dataStorage": { "title": "数据与存储", diff --git a/packages/web-ui/src/pages/Settings.tsx b/packages/web-ui/src/pages/Settings.tsx index 2c15897c..53e1f51b 100644 --- a/packages/web-ui/src/pages/Settings.tsx +++ b/packages/web-ui/src/pages/Settings.tsx @@ -157,6 +157,8 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat const [browserRemotePort, setBrowserRemotePort] = useState(0); const [browserAutoClose, setBrowserAutoClose] = useState(true); const [browserAutoClickAllow, setBrowserAutoClickAllow] = useState(false); + const [browserExtensionConnected, setBrowserExtensionConnected] = useState(false); + const [browserExtensionPort, setBrowserExtensionPort] = useState(9333); const [browserSaving, setBrowserSaving] = useState(false); const [browserMsg, setBrowserMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); @@ -226,6 +228,8 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat setBrowserRemotePort(d.remoteDebuggingPort ?? 0); setBrowserAutoClose(d.autoCloseTabs ?? true); setBrowserAutoClickAllow(d.autoClickAllowDialog ?? false); + setBrowserExtensionConnected(d.extensionConnected ?? false); + setBrowserExtensionPort(d.extensionBridgePort ?? 9333); } }) .catch(() => {}); @@ -495,6 +499,21 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat if (oauthPollRef.current) clearInterval(oauthPollRef.current); }, []); + // Poll extension connection status when browser tab is active and not yet connected + useEffect(() => { + if (resolvedTab !== 'browser' || browserExtensionConnected) return; + const poll = setInterval(async () => { + try { + const d = await api.settings.getBrowser(); + if (d.extensionConnected) { + setBrowserExtensionConnected(true); + clearInterval(poll); + } + } catch { /* ignore */ } + }, 5000); + return () => clearInterval(poll); + }, [resolvedTab, browserExtensionConnected]); + // Add/Edit/Delete provider state const [showAddProvider, setShowAddProvider] = useState(false); const [addProviderForm, setAddProviderForm] = useState({ name: '', apiKey: '', baseUrl: '', model: '', contextWindow: 128000, maxOutputTokens: 16384, costInput: 1, costOutput: 5 }); @@ -1021,6 +1040,51 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat title={t('setupGuide.manual.title')} description={ }} />} /> + + {/* Option 5: Chrome Extension (only shown in Chrome) */} + {/Chrome\/\d/.test(navigator.userAgent) && !/Edg\//.test(navigator.userAgent) && ( + + {browserExtensionConnected ? ( +
+ + {t('setupGuide.browserExtension.alreadyConnected')} +
+ ) : ( +
+
+ + +
+
{t('setupGuide.browserExtension.loadHint')}
+
+ )} +
+ )}
)} @@ -1740,18 +1804,38 @@ export function Settings({ theme, onThemeChange, authUser, onLogout, onUserUpdat {resolvedTab === 'browser' && <>
-
-
- {t('browserAutomation.description')} + {/* ── Active Mode Banner ── */} +
0 ? 'bg-blue-500/10 border border-blue-500/20' : browserAutoClickAllow ? 'bg-amber-500/10 border border-amber-500/20' : 'bg-surface-elevated border border-border-default'}`}> +
0 ? 'bg-blue-400' : browserAutoClickAllow ? 'bg-amber-400' : 'bg-gray-400'}`} /> +
+
0 ? 'text-blue-400' : browserAutoClickAllow ? 'text-amber-400' : 'text-fg-secondary'}`}> + {browserExtensionConnected + ? t('browserAutomation.modeExtension') + : browserRemotePort > 0 + ? t('browserAutomation.modeDebuggingPort', { port: browserRemotePort }) + : browserAutoClickAllow + ? t('browserAutomation.modeAutoClick') + : t('browserAutomation.modeManual')} +
+
+ {browserExtensionConnected + ? t('browserAutomation.modeExtensionDesc') + : browserRemotePort > 0 + ? t('browserAutomation.modeDebuggingPortDesc') + : browserAutoClickAllow + ? t('browserAutomation.modeAutoClickDesc') + : t('browserAutomation.modeManualDesc')} +
+
- {/* Bring to Front toggle */} + {/* ── General Settings (always visible) ── */} +
+
{t('browserAutomation.generalSettings')}
{t('browserAutomation.bringToFront')}
-
- {t('browserAutomation.bringToFrontDesc')} -
+
{t('browserAutomation.bringToFrontDesc')}
- - {/* Auto-close tabs toggle */}
{t('browserAutomation.autoCloseTabs')}
-
- {t('browserAutomation.autoCloseTabsDesc')} -
+
{t('browserAutomation.autoCloseTabsDesc')}
+
+ + {/* ── Connection Mode: Chrome Extension ── */} +
+ {/* Header row: title + status badge */} +
+
+
{t('browserAutomation.extensionStatus')}
+ ({t('browserAutomation.modeRecommended')}) +
+ + + {browserExtensionConnected ? t('browserAutomation.extensionConnected') : t('browserAutomation.extensionDisconnected')} + +
+
{t('browserAutomation.extensionStatusDesc')}
- {/* Auto-click Chrome Allow Dialog toggle */} -
-
-
-
{t('browserAutomation.autoClickAllowDialog')}
-
- {t('browserAutomation.autoClickAllowDialogDesc')} + {browserExtensionConnected ? ( +
+ + {t('browserAutomation.extensionActiveNote')} +
+ ) : ( + /* Step-by-step install guide */ +
+
{t('browserAutomation.extensionSetupTitle')}
+ + {/* Step 1: Download */} +
+ 1 +
+
{t('browserAutomation.extensionStep1')}
+
{t('browserAutomation.extensionStep1Desc')}
+
-
-
{t('browserAutomation.autoClickAllowDialogMacNote')}
-
{t('browserAutomation.autoClickAllowDialogWinNote')}
-
{t('browserAutomation.autoClickAllowDialogLinuxNote')}
+
+ + {/* Step 2: Open extensions page */} +
+ 2 +
+
{t('browserAutomation.extensionStep2')}
+
{t('browserAutomation.extensionStep2Desc')}
+
-
- - + + {/* Step 3: Load unpacked */} +
+ 3 +
+
{t('browserAutomation.extensionStep3')}
+
{t('browserAutomation.extensionStep3Desc')}
+
-
+ )} +
- {/* Remote Debugging Port */} -
-
-
-
{t('browserAutomation.remoteDebuggingPort')}
-
- {t('browserAutomation.remoteDebuggingPortDescLead')}{' '} - --remote-debugging-port=9222{' '} - {t('browserAutomation.remoteDebuggingPortDescTail')} + {/* ── Fallback modes (collapsed when extension is connected) ── */} + {!browserExtensionConnected && ( +
+
{t('browserAutomation.fallbackModes')}
+ + {/* Auto-click Chrome Allow Dialog toggle */} +
+
+
+
{t('browserAutomation.autoClickAllowDialog')}
+
{t('browserAutomation.autoClickAllowDialogDesc')}
+
+
{t('browserAutomation.autoClickAllowDialogMacNote')}
+
{t('browserAutomation.autoClickAllowDialogWinNote')}
+
{t('browserAutomation.autoClickAllowDialogLinuxNote')}
+
+
+
+ +
-
- { setBrowserRemotePort(Number(e.target.value)); setBrowserMsg(null); }} - className="w-24 px-3 py-1.5 text-sm border border-border-default rounded-lg bg-surface-primary text-fg-primary text-right" - placeholder="0" - /> - +
+ + {/* Remote Debugging Port */} +
+
+
+
{t('browserAutomation.remoteDebuggingPort')}
+
+ {t('browserAutomation.remoteDebuggingPortDescLead')}{' '} + --remote-debugging-port=9222{' '} + {t('browserAutomation.remoteDebuggingPortDescTail')} +
+
+
+ { setBrowserRemotePort(Number(e.target.value)); setBrowserMsg(null); }} + className="w-24 px-3 py-1.5 text-sm border border-border-default rounded-lg bg-surface-primary text-fg-primary text-right" + placeholder="0" + /> + +
+ )} - {browserMsg && } -
+ {browserMsg &&
}
} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90fa9d79..679a1d6e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,18 @@ importers: specifier: workspace:* version: link:../shared + packages/chrome-extension: + devDependencies: + '@types/chrome': + specifier: ^0.0.300 + version: 0.0.300 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + typescript: + specifier: ^5.6.0 + version: 5.9.3 + packages/cli: dependencies: systray2: @@ -122,10 +134,16 @@ importers: turndown: specifier: ^7.2.2 version: 7.2.2 + ws: + specifier: ^8.19.0 + version: 8.19.0 devDependencies: '@types/turndown': specifier: ^5.0.6 version: 5.0.6 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 packages/gui: dependencies: @@ -366,6 +384,12 @@ packages: '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -378,6 +402,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} @@ -390,6 +420,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} @@ -402,6 +438,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} @@ -414,6 +456,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} @@ -426,6 +474,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} @@ -438,6 +492,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} @@ -450,6 +510,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} @@ -462,6 +528,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} @@ -474,6 +546,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} @@ -486,6 +564,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} @@ -498,6 +582,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} @@ -510,6 +600,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} @@ -522,6 +618,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} @@ -534,6 +636,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} @@ -546,6 +654,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} @@ -558,6 +672,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} @@ -570,6 +690,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} @@ -582,6 +708,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} @@ -594,6 +726,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} @@ -606,6 +744,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} @@ -618,6 +762,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} @@ -630,6 +780,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} @@ -642,6 +798,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} @@ -654,6 +816,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} @@ -666,6 +834,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} @@ -1187,6 +1361,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/chrome@0.0.300': + resolution: {integrity: sha512-vS5bUmNjMrbPut43Q8plS8GTAiJE+pBOxw1ovGC4LcwiGNKTithAH7TxhzHeX4wNq/FsaLj5KaW7riI0CgYjIg==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} @@ -1220,6 +1397,15 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/filesystem@0.0.36': + resolution: {integrity: sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==} + + '@types/filewriter@0.0.33': + resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1685,6 +1871,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -3061,156 +3252,234 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true '@esbuild/aix-ppc64@0.27.4': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.27.3': optional: true '@esbuild/android-arm64@0.27.4': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.3': optional: true '@esbuild/android-arm@0.27.4': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.3': optional: true '@esbuild/android-x64@0.27.4': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.3': optional: true '@esbuild/darwin-arm64@0.27.4': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.3': optional: true '@esbuild/darwin-x64@0.27.4': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.3': optional: true '@esbuild/freebsd-arm64@0.27.4': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.3': optional: true '@esbuild/freebsd-x64@0.27.4': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.3': optional: true '@esbuild/linux-arm64@0.27.4': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.3': optional: true '@esbuild/linux-arm@0.27.4': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.3': optional: true '@esbuild/linux-ia32@0.27.4': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.3': optional: true '@esbuild/linux-loong64@0.27.4': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.3': optional: true '@esbuild/linux-mips64el@0.27.4': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.3': optional: true '@esbuild/linux-ppc64@0.27.4': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.3': optional: true '@esbuild/linux-riscv64@0.27.4': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.3': optional: true '@esbuild/linux-s390x@0.27.4': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.3': optional: true '@esbuild/linux-x64@0.27.4': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.3': optional: true '@esbuild/netbsd-arm64@0.27.4': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.3': optional: true '@esbuild/netbsd-x64@0.27.4': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.3': optional: true '@esbuild/openbsd-arm64@0.27.4': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.3': optional: true '@esbuild/openbsd-x64@0.27.4': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.3': optional: true '@esbuild/openharmony-arm64@0.27.4': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.3': optional: true '@esbuild/sunos-x64@0.27.4': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.3': optional: true '@esbuild/win32-arm64@0.27.4': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.3': optional: true '@esbuild/win32-ia32@0.27.4': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.3': optional: true @@ -3579,6 +3848,11 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/chrome@0.0.300': + dependencies: + '@types/filesystem': 0.0.36 + '@types/har-format': 1.2.16 + '@types/d3-color@3.1.3': {} '@types/d3-drag@3.0.7': @@ -3614,6 +3888,14 @@ snapshots: '@types/estree@1.0.8': {} + '@types/filesystem@0.0.36': + dependencies: + '@types/filewriter': 0.0.33 + + '@types/filewriter@0.0.33': {} + + '@types/har-format@1.2.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -4112,6 +4394,35 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -5564,7 +5875,7 @@ snapshots: vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0): dependencies: - esbuild: 0.27.3 + esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 diff --git a/scripts/build-binary.sh b/scripts/build-binary.sh index 771d59c3..64435427 100755 --- a/scripts/build-binary.sh +++ b/scripts/build-binary.sh @@ -136,6 +136,18 @@ TEMPLATES_DIR="$ROOT_DIR/packages/cli/templates" LOGO_PNG="$ROOT_DIR/packages/web-ui/public/logo.png" [[ -f "$LOGO_PNG" ]] && cp "$LOGO_PNG" "$STAGE_DIR/logo.png" && ok "Logo copied" +# Chrome extension zip (for browser automation setup) +EXT_ZIP="$ROOT_DIR/packages/chrome-extension/dist/markus-browser-extension.zip" +if [[ ! -f "$EXT_ZIP" ]]; then + info "Building Chrome extension zip..." + (cd "$ROOT_DIR/packages/chrome-extension" && npm run pack --silent 2>/dev/null) || true +fi +if [[ -f "$EXT_ZIP" ]]; then + mkdir -p "$STAGE_DIR/chrome-extension" + cp "$EXT_ZIP" "$STAGE_DIR/chrome-extension/" + ok "Chrome extension zip copied" +fi + # Create launcher wrappers if [[ "$PLATFORM" == "win" ]]; then cat > "$STAGE_DIR/markus.cmd" << 'LAUNCHER' diff --git a/templates/skills/chrome-devtools/SKILL.md b/templates/skills/chrome-devtools/SKILL.md index bc3967e1..bd8283a4 100644 --- a/templates/skills/chrome-devtools/SKILL.md +++ b/templates/skills/chrome-devtools/SKILL.md @@ -22,118 +22,129 @@ Use Chrome DevTools tools when you need to: Do NOT use these tools when `web_fetch` or `web_search` suffice (simple content retrieval or search). Chrome DevTools is for interactive browser sessions that require a real rendering engine. -## Installation & Setup +## Connection Modes -### 1. Check if Chrome is already installed +Markus supports three ways to connect to Chrome, listed from best to fallback: -Before installing, check if Chrome is available on the system: +### Mode 1: Markus Chrome Extension (recommended) -- **macOS**: `ls /Applications/Google\ Chrome.app` or `mdfind "kMDItemCFBundleIdentifier == com.google.Chrome"` -- **Linux**: `which google-chrome || which google-chrome-stable || which chromium-browser` -- **Windows**: `where chrome` or check `"C:\Program Files\Google\Chrome\Application\chrome.exe"` +The **Markus Browser Automation** Chrome extension provides the smoothest experience: +- **No debugging dialog** — the extension uses `chrome.debugger` API internally +- **Works when screen is locked or sleeping** — no OS-level interaction needed +- **Cross-platform** — works on macOS, Windows, and Linux identically +- **Instant startup** — no `npx` download, no child process spawn +- **No extra permissions** — no macOS Accessibility or Windows UI Automation needed -To check the installed version: -- **macOS**: `/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version` -- **Linux**: `google-chrome --version` +**How to install:** -If Chrome is already installed and version is **144+**, skip to step 2. +1. Build the extension (one-time): + ``` + cd packages/chrome-extension && pnpm install && pnpm run build + ``` +2. Open Chrome → `chrome://extensions/` +3. Enable **Developer mode** (toggle in top-right) +4. Click **Load unpacked** → select `packages/chrome-extension/dist` +5. The Markus icon appears in the toolbar -**Only if Chrome is NOT installed:** +**How it works:** -- **macOS**: `brew install --cask google-chrome` or download from https://www.google.com/chrome/ -- **Linux (Debian/Ubuntu)**: `wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - && sudo apt update && sudo apt install google-chrome-stable` -- **Windows**: Download from https://www.google.com/chrome/ +When Markus starts, it launches a WebSocket bridge on `ws://127.0.0.1:9333`. The extension +auto-connects to this bridge. All browser tool calls are routed through the extension instead +of spawning an external MCP process. -Chrome version **144+** is required (146+ recommended). +Check connection status in **Settings > Browser Automation > Chrome Extension**: +- Green dot = Connected (extension is active, all tools route through it) +- Gray dot = Not Connected (Markus falls back to Mode 2 or 3) -### 2. Launch Chrome with Remote Debugging +The extension reconnects automatically within 3 seconds if the connection drops. -To use DevTools automation you must start Chrome with remote debugging enabled: +**Note:** Chrome shows a yellow infobar ("Markus Browser Automation started debugging this +tab") on debugged tabs. This is cosmetic and doesn't affect functionality. To hide it, launch +Chrome with `--silent-debugger-extension-api`. -- **macOS**: - ``` - /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222 - ``` -- **Linux**: - ``` - google-chrome --remote-debugging-port=9222 - ``` -- **Windows**: - ``` - "C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222 - ``` +### Mode 2: Auto-Connect with Auto-Click (fallback) -Alternatively, skip the launch flag and use Auto-Connect (see Mode 1 below) — but you must -manually enable remote debugging in Chrome first. +If the extension is not installed, Markus falls back to `chrome-devtools-mcp` via `npx`. +Chrome shows an "Allow remote debugging?" dialog each time. Markus can auto-click this +dialog on supported platforms: -### 3. Verify Connection +**macOS:** Requires Accessibility permission. +1. Open System Settings > Privacy & Security > Accessibility +2. Add the app running Markus (Markus.app, Terminal, or iTerm) +3. Enable "Auto-Allow Chrome Debugging Dialog" in Settings > Browser Automation -Open `chrome://inspect/#remote-debugging` in Chrome. You should see your open tabs listed. -If the MCP server is running, it will appear as a connected client. +**Windows:** No additional permissions needed. Enable the toggle in Settings. -### Troubleshooting +**Linux:** Auto-click is not supported. Use Mode 1 (extension) or Mode 3 (debugging port). -- **Port conflict**: If port 9222 is in use, pick another (e.g. 9333) and update Settings > Browser Automation > Remote Debugging Port. -- **Firewall**: Ensure localhost access to the debugging port is not blocked. -- **Memory Saver**: Chrome's Memory Saver can freeze tabs and cause connection timeouts. Disable it at `chrome://settings/performance` or upgrade to Chrome 146+. -- **Permission dialog**: On first Auto-Connect, Chrome shows a permission dialog — click **Allow**. - If this blocks automation, see "Permission dialog auto-click" below. +Limitations of auto-click: +- Does not work when the screen is locked or display is sleeping +- Requires OS-specific permissions (Accessibility on macOS) +- Has timing dependencies on npx download and dialog detection -### Permission dialog auto-click +### Mode 3: Persistent Debugging Port (manual) -If the Chrome permission dialog blocks automation (connection timeout or failure), check and -advise the user based on their platform: +Launch Chrome with a fixed debugging port to bypass the dialog entirely: -**macOS / Windows:** -Suggest enabling "Auto-Allow Chrome Debugging Dialog" in Settings > Browser Automation. -On macOS, this requires Accessibility permission — guide the user: -1. Open System Settings > Privacy & Security > Accessibility -2. Add the application running Markus (Markus.app, Terminal, or iTerm) to the allowed list -3. Enable the toggle in Settings > Browser Automation +- **macOS**: `/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222` +- **Linux**: `google-chrome --remote-debugging-port=9222` +- **Windows**: `"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222` -On Windows, no additional permissions are needed. +Set the same port in **Settings > Browser Automation > Remote Debugging Port**. -**Linux:** -Auto-click is not supported on Linux. Recommend using Remote Debugging Port instead: -1. Launch Chrome with `--remote-debugging-port=9222` -2. Set port 9222 in Settings > Browser Automation > Remote Debugging Port +## Installation & Setup -**All platforms (alternative):** -If the user prefers not to grant accessibility permission, using a dedicated Chrome profile -with `--remote-debugging-port=9222 --user-data-dir=/path/to/markus-profile` eliminates -the permission dialog entirely. +### 1. Check if Chrome is installed -## Prerequisites +- **macOS**: `ls /Applications/Google\ Chrome.app` or `mdfind "kMDItemCFBundleIdentifier == com.google.Chrome"` +- **Linux**: `which google-chrome || which google-chrome-stable || which chromium-browser` +- **Windows**: `where chrome` or check `"C:\Program Files\Google\Chrome\Application\chrome.exe"` -The MCP server connects to the user's running Chrome via one of two modes: +Chrome version **144+** is required (146+ recommended). Check with: +- **macOS**: `/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version` +- **Linux**: `google-chrome --version` -### Mode 1: Auto-Connect (default) -Uses `--autoConnect` to discover Chrome automatically (Chrome 144+). -1. Chrome version must be 144 or newer (146+ recommended). -2. Open `chrome://inspect/#remote-debugging` in Chrome and enable remote debugging. -3. On first MCP connection, Chrome will show a permission dialog — the user must click **Allow**. -4. **Note:** This permission is per-connection. Each new agent or session may trigger a new dialog. +**If Chrome is NOT installed:** +- **macOS**: `brew install --cask google-chrome` or download from https://www.google.com/chrome/ +- **Linux (Debian/Ubuntu)**: `wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - && sudo apt update && sudo apt install google-chrome-stable` +- **Windows**: Download from https://www.google.com/chrome/ -### Mode 2: Persistent Debugging Port (recommended for unattended use) -Set **Remote Debugging Port** in Settings > Browser Automation (e.g. 9222) to skip the -permission dialog entirely. This requires launching Chrome with: -``` -chrome --remote-debugging-port=9222 -``` -Once configured, all agents reuse this connection without prompting for permission. +### 2. Choose a connection mode + +- **Best experience**: Install the Markus Chrome Extension (Mode 1 above) +- **Quick start**: Just ensure Chrome is running — Markus will auto-connect and prompt for permission +- **Unattended use without extension**: Launch Chrome with `--remote-debugging-port=9222` (Mode 3) + +### 3. Verify Connection + +- With extension: Click the Markus icon in Chrome toolbar — status should show "Connected to Markus" +- With auto-connect: Open `chrome://inspect/#remote-debugging` in Chrome and verify tabs are listed +- In Markus: Go to Settings > Browser Automation — check the extension status or run the auto-click test + +### Troubleshooting + +- **Extension shows "Not Connected"**: Ensure Markus is running (`pnpm dev`). The bridge starts on port 9333 by default. Check if another process is using that port. +- **Port conflict**: If port 9333 is in use, change `browser.extensionBridgePort` in Markus config. +- **Permission dialog keeps appearing**: Install the Chrome extension (Mode 1) to eliminate it entirely. +- **Memory Saver freezes tabs**: Disable at `chrome://settings/performance` or upgrade to Chrome 146+. +- **Connection timeout**: On first use without extension, `npx` may need to download `chrome-devtools-mcp`. Wait up to 60 seconds. Subsequent connections are faster. +- **Firewall**: Ensure localhost access to the debugging/bridge port is not blocked. -**Frozen/suspended tabs cause connection timeout:** When Chrome has frozen tabs (Memory Saver -or restored tabs), the MCP server may hang. If you encounter a timeout, inform the user to: -upgrade Chrome to 146+, disable Memory Saver at `chrome://settings/performance`, click suspended -tabs to wake them, or use a dedicated Chrome profile with few tabs. +**If browser automation fails, advise the user:** +1. First choice: Install the Markus Chrome Extension (Settings > Browser Automation shows install path) +2. Second choice: Enable auto-click in Settings > Browser Automation (macOS/Windows only) +3. Third choice: Launch Chrome with `--remote-debugging-port=9222` and set the port in Settings ## Configurable behavior (Settings > Browser Automation) | Setting | Default | Description | |---------|---------|-------------| +| **Chrome Extension** | — | Shows connection status of the Markus Chrome Extension (Connected / Not Connected). | | **Bring to Foreground** | Off | When on, Chrome tabs are brought to the foreground during agent operations. When off (default), agents operate silently in background tabs. | | **Auto-close Tabs** | On | When on, agent-owned tabs are closed when the agent task completes or the agent is removed. | +| **Auto-Allow Debugging Dialog** | Off | Auto-click Chrome's "Allow remote debugging?" dialog via OS APIs. Only needed when extension is not installed. macOS requires Accessibility permission; Windows works out of the box; Linux not supported. | | **Remote Debugging Port** | 0 (auto-connect) | Set to a port number (e.g. 9222) to use a persistent debugging connection instead of auto-connect, eliminating repeated permission dialogs. | +| **Extension Bridge Port** | 9333 | WebSocket port for communication between Markus and the Chrome extension. Change if 9333 conflicts with another service. | ## Tool reference From b0c47cebca95144805a37d831ec166ed9ae66389 Mon Sep 17 00:00:00 2001 From: Jason Carter Date: Wed, 20 May 2026 19:00:47 +0800 Subject: [PATCH 19/19] fix: resolve 3 lint errors (eqeqeq, consistent-type-imports, prefer-const) Co-authored-by: Cursor --- packages/core/src/tools/chrome-dialog-clicker.ts | 2 +- packages/remote/src/agent.ts | 2 +- packages/web-ui/src/hooks/useUnreadCounts.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tools/chrome-dialog-clicker.ts b/packages/core/src/tools/chrome-dialog-clicker.ts index a0f31557..69853344 100644 --- a/packages/core/src/tools/chrome-dialog-clicker.ts +++ b/packages/core/src/tools/chrome-dialog-clicker.ts @@ -212,7 +212,7 @@ async function runMcpTest(): Promise<{ navigated: boolean; title?: string }> { if (!trimmed) continue; try { const msg = JSON.parse(trimmed); - if (msg.id != null && pending.has(msg.id)) { + if (msg.id !== null && msg.id !== undefined && pending.has(msg.id)) { const p = pending.get(msg.id)!; pending.delete(msg.id); if (msg.error) p.reject(new Error(msg.error.message ?? JSON.stringify(msg.error))); diff --git a/packages/remote/src/agent.ts b/packages/remote/src/agent.ts index 0bc02a7b..78679e30 100644 --- a/packages/remote/src/agent.ts +++ b/packages/remote/src/agent.ts @@ -5,7 +5,7 @@ import { request as httpsRequest } from 'node:https'; import { createHmac } from 'node:crypto'; import { PeerConnection, - DataChannel, + type DataChannel, initLogger as initRtcLogger, type RtcConfig, type IceServer, diff --git a/packages/web-ui/src/hooks/useUnreadCounts.ts b/packages/web-ui/src/hooks/useUnreadCounts.ts index 813a7666..3c6d77be 100644 --- a/packages/web-ui/src/hooks/useUnreadCounts.ts +++ b/packages/web-ui/src/hooks/useUnreadCounts.ts @@ -4,7 +4,7 @@ import { api, wsClient } from '../api.ts'; const POLL_INTERVAL_MS = 60_000; let _globalCounts: Record = {}; -let _listeners = new Set<() => void>(); +const _listeners = new Set<() => void>(); function notify() { for (const fn of _listeners) fn();