From 874de766ec9f6489bdd36bc6caae0487067ba764 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:50:41 +0800 Subject: [PATCH] feat(hub): add local exec approval routing for desktop IPC chat Add localApprovalHandlers map so exec approval requests can be routed to the Electron renderer via IPC instead of requiring a Gateway connection. Expose setLocalApprovalHandler/removeLocalApprovalHandler and resolveExecApproval on Hub for the IPC layer to use. Co-Authored-By: Claude Opus 4.5 --- src/agent/async-agent.ts | 5 +++++ src/agent/runner.ts | 40 ++++++++++++---------------------------- src/hub/hub.ts | 40 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 56 insertions(+), 29 deletions(-) diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index 4fdfb616..c0195083 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -215,6 +215,11 @@ export class AsyncAgent { this.agent.reloadSystemPrompt(); } + /** Ensure session messages are loaded from disk (idempotent) */ + async ensureInitialized(): Promise { + return this.agent.ensureInitialized(); + } + /** * Get all messages from the current session. */ diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 98f233d9..310dabfb 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -324,34 +324,7 @@ export class Agent { } async run(prompt: string): Promise { - if (!this.initialized) { - await this.session.repairIfNeeded((msg) => console.error(msg)); - const restoredMessages = this.session.loadMessages(); - if (restoredMessages.length > 0) { - if (this.debug) { - console.error(`[debug] Restoring ${restoredMessages.length} messages from session`); - for (const msg of restoredMessages) { - const msgAny = msg as any; - const content = Array.isArray(msgAny.content) - ? msgAny.content.map((c: any) => c.type || "text").join(", ") - : typeof msgAny.content; - console.error(`[debug] ${msg.role}: ${content}`); - if (Array.isArray(msgAny.content)) { - for (const block of msgAny.content) { - if (block.type === "tool_use") { - console.error(`[debug] tool_use id: ${block.id}, name: ${block.name}`); - } - if (block.type === "tool_result") { - console.error(`[debug] tool_result tool_use_id: ${block.tool_use_id}`); - } - } - } - } - } - this.agent.replaceMessages(restoredMessages); - } - this.initialized = true; - } + await this.ensureInitialized(); this.output.state.lastAssistantText = ""; const canRotate = !this.pinnedProfile && this.profileCandidates.length > 1; @@ -484,6 +457,17 @@ export class Agent { return this.agent.state.tools?.map(t => t.name) ?? []; } + /** Ensure session messages are loaded from disk (idempotent) */ + async ensureInitialized(): Promise { + if (this.initialized) return; + await this.session.repairIfNeeded((msg) => console.error(msg)); + const restoredMessages = this.session.loadMessages(); + if (restoredMessages.length > 0) { + this.agent.replaceMessages(restoredMessages); + } + this.initialized = true; + } + /** Get all messages from the current session */ getMessages(): AgentMessage[] { return this.agent.state.messages.slice(); diff --git a/src/hub/hub.ts b/src/hub/hub.ts index ff87aee8..ca6b42d7 100644 --- a/src/hub/hub.ts +++ b/src/hub/hub.ts @@ -28,7 +28,7 @@ import { ExecApprovalManager } from "./exec-approval-manager.js"; import { createResolveExecApprovalHandler } from "./rpc/handlers/resolve-exec-approval.js"; import { evaluateCommandSafety, requiresApproval } from "../agent/tools/exec-safety.js"; import { addAllowlistEntry, recordAllowlistUse, matchAllowlist } from "../agent/tools/exec-allowlist.js"; -import type { ExecApprovalCallback, ExecApprovalConfig, ApprovalResult } from "../agent/tools/exec-approval-types.js"; +import type { ExecApprovalCallback, ExecApprovalConfig, ApprovalResult, ExecApprovalRequest } from "../agent/tools/exec-approval-types.js"; import { readProfileConfig, writeProfileConfig } from "../agent/profile/storage.js"; export class Hub { @@ -36,11 +36,13 @@ export class Hub { private readonly agentSenders = new Map(); private readonly agentStreamIds = new Map(); private readonly agentStreamCounters = new Map(); + private readonly localApprovalHandlers = new Map void>(); private readonly rpc: RpcDispatcher; private readonly approvalManager: ExecApprovalManager; private client: GatewayClient; readonly deviceStore: DeviceStore; private _onConfirmDevice: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise) | null = null; + private _stateChangeListeners: ((state: ConnectionState) => void)[] = []; url: string; readonly path: string; readonly hubId: string; @@ -77,6 +79,13 @@ export class Hub { // Initialize exec approval manager this.approvalManager = new ExecApprovalManager((agentId, payload) => { + // Check local IPC handler first (for desktop direct chat) + const localHandler = this.localApprovalHandlers.get(agentId); + if (localHandler) { + localHandler(payload); + return; + } + // Remote: send via Gateway const targetDeviceId = this.agentSenders.get(agentId); if (!targetDeviceId) { throw new Error(`No client device found for agent ${agentId}`); @@ -119,6 +128,9 @@ export class Hub { client.onStateChange((state) => { console.log(`[Hub] Connection state: ${state}`); + for (const listener of this._stateChangeListeners) { + listener(state); + } }); client.onRegistered((deviceId) => { @@ -193,6 +205,15 @@ export class Hub { this._onConfirmDevice = handler; } + /** Subscribe to connection state changes. Returns unsubscribe function. */ + onConnectionStateChange(callback: (state: ConnectionState) => void): () => void { + this._stateChangeListeners.push(callback); + return () => { + const idx = this._stateChangeListeners.indexOf(callback); + if (idx >= 0) this._stateChangeListeners.splice(idx, 1); + }; + } + /** Register a one-time token for device verification (called when QR code is generated) */ registerToken(token: string, agentId: string, expiresAt: number): void { this.deviceStore.registerToken(token, agentId, expiresAt); @@ -207,6 +228,21 @@ export class Hub { this.client.connect(); } + /** Register a local IPC handler for exec approval requests (desktop direct chat). */ + setLocalApprovalHandler(agentId: string, handler: (payload: ExecApprovalRequest) => void): void { + this.localApprovalHandlers.set(agentId, handler); + } + + /** Remove local approval handler for an agent. */ + removeLocalApprovalHandler(agentId: string): void { + this.localApprovalHandlers.delete(agentId); + } + + /** Resolve a pending exec approval (used by local IPC). */ + resolveExecApproval(approvalId: string, decision: "allow-once" | "allow-always" | "deny"): boolean { + return this.approvalManager.resolveApproval(approvalId, decision); + } + /** Create new Agent, or rebuild with existing ID */ createAgent(id?: string, options?: { persist?: boolean; profileId?: string }): AsyncAgent { if (id) { @@ -454,6 +490,7 @@ export class Hub { this.agentSenders.delete(id); this.agentStreamIds.delete(id); this.agentStreamCounters.delete(id); + this.localApprovalHandlers.delete(id); removeAgentRecord(id); return true; } @@ -468,6 +505,7 @@ export class Hub { this.agentSenders.delete(id); this.agentStreamIds.delete(id); this.agentStreamCounters.delete(id); + this.localApprovalHandlers.delete(id); } this.client.disconnect(); console.log("Hub shut down");