From 9865c539fc85827374177925b53829771c98d671 Mon Sep 17 00:00:00 2001 From: credence-the-bot Date: Sun, 3 May 2026 13:03:56 +0000 Subject: [PATCH] feat(host-proxy): add targetClientId support to HostFileProxy and HostProxyBase - Add targetClientId to HostFileReadRequest, HostFileWriteRequest, HostFileEditRequest, HostFileCancelRequest - Add targetClientId to HostCuRequest and HostCuCancelRequest - HostFileProxy.request() resolves target client (auto-resolve single, validate explicit, undefined for untargeted) - Thread targetClientId through all broadcastMessage calls (request, abort cancel, dispose cancel) - Add targetClientId to HostProxyBase.PendingEntry and dispatchRequest (covers HostCuProxy via inheritance) - Update broadcastDynamic to accept and forward targetClientId as options to broadcastMessage - Register targetClientId in assistant-event-hub.ts for host_file and host_cu pending interactions Co-Authored-By: Claude Sonnet 4.6 --- assistant/src/daemon/host-cu-proxy.ts | 3 +- assistant/src/daemon/host-file-proxy.ts | 65 ++++++++++++++----- assistant/src/daemon/host-proxy-base.ts | 20 ++++-- assistant/src/daemon/message-types/host-cu.ts | 2 + .../src/daemon/message-types/host-file.ts | 4 ++ assistant/src/runtime/assistant-event-hub.ts | 2 + 6 files changed, 75 insertions(+), 21 deletions(-) diff --git a/assistant/src/daemon/host-cu-proxy.ts b/assistant/src/daemon/host-cu-proxy.ts index ab94231da22..6e55fc19f81 100644 --- a/assistant/src/daemon/host-cu-proxy.ts +++ b/assistant/src/daemon/host-cu-proxy.ts @@ -120,6 +120,7 @@ export class HostCuProxy extends HostProxyBase< stepNumber: number, reasoning?: string, signal?: AbortSignal, + targetClientId?: string, ): Promise { if (signal?.aborted) { return Promise.resolve({ @@ -139,7 +140,7 @@ export class HostCuProxy extends HostProxyBase< return this.dispatchRequest(toolName, input, conversationId, signal, { stepNumber, reasoning, - }) + }, targetClientId) .then((observation) => { // Capture pre-update state so formatObservation sees the correct // previous AX tree. diff --git a/assistant/src/daemon/host-file-proxy.ts b/assistant/src/daemon/host-file-proxy.ts index 59ad8ac57f0..5f51da2e8de 100644 --- a/assistant/src/daemon/host-file-proxy.ts +++ b/assistant/src/daemon/host-file-proxy.ts @@ -31,6 +31,7 @@ interface PendingRequest { operation: HostFileInput["operation"]; path: string; conversationId: string; + targetClientId?: string; /** Detach the abort listener from the caller's signal. No-op when no signal was passed. */ detachAbort: () => void; } @@ -85,6 +86,21 @@ export class HostFileProxy { return Promise.resolve({ content: "Aborted", isError: true }); } + const capableClients = assistantEventHub.listClientsByCapability("host_file"); + let resolvedTargetClientId: string | undefined; + if (input.targetClientId) { + const target = assistantEventHub.getClientById(input.targetClientId); + if (!target || !target.capabilities.includes("host_file")) { + return Promise.resolve({ + content: `Error: client "${input.targetClientId}" is not connected or does not support host_file. Run \`assistant clients list --capability host_file\` to see available clients.`, + isError: true, + }); + } + resolvedTargetClientId = input.targetClientId; + } else if (capableClients.length === 1) { + resolvedTargetClientId = capableClients[0].clientId; + } + const requestId = uuid(); return new Promise((resolve, reject) => { @@ -101,8 +117,11 @@ export class HostFileProxy { { requestId, operation: input.operation }, "Host file proxy request timed out", ); + const timeoutMessage = resolvedTargetClientId + ? `Host file proxy timed out waiting for response from client ${resolvedTargetClientId}` + : "Host file proxy timed out waiting for client response"; resolve({ - content: "Host file proxy timed out waiting for client response", + content: timeoutMessage, isError: true, }); }, timeoutSec * 1000); @@ -115,11 +134,16 @@ export class HostFileProxy { detachAbort(); pendingInteractions.resolve(requestId); try { - broadcastMessage({ - type: "host_file_cancel", - requestId, + broadcastMessage( + { + type: "host_file_cancel", + requestId, + conversationId, + targetClientId: resolvedTargetClientId, + }, conversationId, - }); + { targetClientId: resolvedTargetClientId }, + ); } catch { // Best-effort cancel notification — connection may already be closed. } @@ -137,16 +161,22 @@ export class HostFileProxy { operation: input.operation, path: input.path, conversationId, + targetClientId: resolvedTargetClientId, detachAbort, }); try { - broadcastMessage({ - ...input, - type: "host_file_request", - requestId, + broadcastMessage( + { + ...input, + type: "host_file_request", + requestId, + conversationId, + targetClientId: resolvedTargetClientId, + }, conversationId, - }); + { targetClientId: resolvedTargetClientId }, + ); } catch (err) { clearTimeout(timer); this.pending.delete(requestId); @@ -195,11 +225,16 @@ export class HostFileProxy { entry.detachAbort(); pendingInteractions.resolve(requestId); try { - broadcastMessage({ - type: "host_file_cancel", - requestId, - conversationId: entry.conversationId, - }); + broadcastMessage( + { + type: "host_file_cancel", + requestId, + conversationId: entry.conversationId, + targetClientId: entry.targetClientId, + }, + entry.conversationId, + { targetClientId: entry.targetClientId }, + ); } catch { // Best-effort cancel notification — connection may already be closed. } diff --git a/assistant/src/daemon/host-proxy-base.ts b/assistant/src/daemon/host-proxy-base.ts index a275307538c..1cba16a2414 100644 --- a/assistant/src/daemon/host-proxy-base.ts +++ b/assistant/src/daemon/host-proxy-base.ts @@ -34,8 +34,12 @@ const log = getLogger("host-proxy-base"); * narrowing is impossible — subclasses are responsible for passing event * names that match a real `ServerMessage` variant. */ -function broadcastDynamic(envelope: Record): void { - broadcastMessage(envelope as unknown as ServerMessage); +function broadcastDynamic(envelope: Record, targetClientId?: string): void { + broadcastMessage( + envelope as unknown as ServerMessage, + undefined, + targetClientId ? { targetClientId } : undefined, + ); } const DEFAULT_TIMEOUT_MS = 60_000; @@ -63,6 +67,7 @@ interface PendingEntry { reject: (err: Error) => void; timer: ReturnType; conversationId: string; + targetClientId?: string; /** Detach the abort listener from the caller's signal. No-op when no signal was passed. */ detachAbort: () => void; } @@ -131,6 +136,7 @@ export abstract class HostProxyBase { conversationId: string, signal?: AbortSignal, extraFields?: Record, + targetClientId?: string, ): Promise { const requestId = uuid(); @@ -162,7 +168,8 @@ export abstract class HostProxyBase { type: this.cancelEventName, requestId, conversationId, - }); + targetClientId, + }, targetClientId); } catch { // Best-effort cancel notification — connection may already be closed. } @@ -178,6 +185,7 @@ export abstract class HostProxyBase { reject, timer, conversationId, + targetClientId, detachAbort, }); @@ -188,8 +196,9 @@ export abstract class HostProxyBase { conversationId, toolName, input, + targetClientId, ...(extraFields ?? {}), - }); + }, targetClientId); } catch (err) { clearTimeout(timer); this.pending.delete(requestId); @@ -246,7 +255,8 @@ export abstract class HostProxyBase { type: this.cancelEventName, requestId, conversationId: entry.conversationId, - }); + targetClientId: entry.targetClientId, + }, entry.targetClientId); } catch { // Best-effort cancel notification — connection may already be closed. } diff --git a/assistant/src/daemon/message-types/host-cu.ts b/assistant/src/daemon/message-types/host-cu.ts index 0b69918d882..fd11902a102 100644 --- a/assistant/src/daemon/message-types/host-cu.ts +++ b/assistant/src/daemon/message-types/host-cu.ts @@ -8,6 +8,7 @@ export interface HostCuRequest { type: "host_cu_request"; requestId: string; conversationId: string; + targetClientId?: string; toolName: string; // "computer_use_click", "computer_use_type_text", etc. input: Record; stepNumber: number; @@ -18,6 +19,7 @@ export interface HostCuCancelRequest { type: "host_cu_cancel"; requestId: string; conversationId: string; + targetClientId?: string; } // --- Domain-level union aliases (consumed by the barrel file) --- diff --git a/assistant/src/daemon/message-types/host-file.ts b/assistant/src/daemon/message-types/host-file.ts index 40f3f31c358..49e0a408781 100644 --- a/assistant/src/daemon/message-types/host-file.ts +++ b/assistant/src/daemon/message-types/host-file.ts @@ -8,6 +8,7 @@ export interface HostFileReadRequest { type: "host_file_request"; requestId: string; conversationId: string; + targetClientId?: string; operation: "read"; path: string; offset?: number; @@ -18,6 +19,7 @@ export interface HostFileWriteRequest { type: "host_file_request"; requestId: string; conversationId: string; + targetClientId?: string; operation: "write"; path: string; content: string; @@ -27,6 +29,7 @@ export interface HostFileEditRequest { type: "host_file_request"; requestId: string; conversationId: string; + targetClientId?: string; operation: "edit"; path: string; old_string: string; @@ -43,6 +46,7 @@ export interface HostFileCancelRequest { type: "host_file_cancel"; requestId: string; conversationId: string; + targetClientId?: string; } // --- Domain-level union aliases (consumed by the barrel file) --- diff --git a/assistant/src/runtime/assistant-event-hub.ts b/assistant/src/runtime/assistant-event-hub.ts index 737b3b156d5..6d6ed83a393 100644 --- a/assistant/src/runtime/assistant-event-hub.ts +++ b/assistant/src/runtime/assistant-event-hub.ts @@ -627,11 +627,13 @@ function registerPendingInteraction( pendingInteractions.register(msg.requestId, { conversationId, kind: "host_file", + targetClientId, }); } else if (msg.type === "host_cu_request") { pendingInteractions.register(msg.requestId, { conversationId, kind: "host_cu", + targetClientId, }); } else if (msg.type === "host_app_control_request") { pendingInteractions.register(msg.requestId, {