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 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-05 17:50:41 +08:00
parent 056b9abff6
commit 874de766ec
3 changed files with 56 additions and 29 deletions

View file

@ -215,6 +215,11 @@ export class AsyncAgent {
this.agent.reloadSystemPrompt();
}
/** Ensure session messages are loaded from disk (idempotent) */
async ensureInitialized(): Promise<void> {
return this.agent.ensureInitialized();
}
/**
* Get all messages from the current session.
*/

View file

@ -324,34 +324,7 @@ export class Agent {
}
async run(prompt: string): Promise<AgentRunResult> {
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<void> {
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();

View file

@ -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<string, string>();
private readonly agentStreamIds = new Map<string, string>();
private readonly agentStreamCounters = new Map<string, number>();
private readonly localApprovalHandlers = new Map<string, (payload: ExecApprovalRequest) => void>();
private readonly rpc: RpcDispatcher;
private readonly approvalManager: ExecApprovalManager;
private client: GatewayClient;
readonly deviceStore: DeviceStore;
private _onConfirmDevice: ((deviceId: string, agentId: string, meta?: DeviceMeta) => Promise<boolean>) | 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");