UNPKG

consortium

Version:

Remote control and session sharing CLI for AI coding agents

206 lines (202 loc) 8.17 kB
'use strict'; var persistence = require('./types-B_i6lpTn.cjs'); class BasePermissionHandler { pendingRequests = /* @__PURE__ */ new Map(); session; isResetting = false; constructor(session) { this.session = session; this.setupRpcHandler(); } /** * Update the session reference (used after offline reconnection swaps sessions). * This is critical for avoiding stale session references after onSessionSwap. */ updateSession(newSession) { persistence.logger.debug(`${this.getLogPrefix()} Session reference updated`); this.session = newSession; this.setupRpcHandler(); } /** * Setup RPC handler for permission responses. */ setupRpcHandler() { this.session.rpcHandlerManager.registerHandler( "permission", async (response) => { const pending = this.pendingRequests.get(response.id); if (!pending) { persistence.logger.debug(`${this.getLogPrefix()} Permission request not found or already resolved`); return; } this.pendingRequests.delete(response.id); if (pending.timeoutHandle) clearTimeout(pending.timeoutHandle); const result = response.approved ? { decision: response.decision === "approved_for_session" ? "approved_for_session" : "approved" } : { decision: response.decision === "denied" ? "denied" : "abort" }; persistence.logger.debug(`${this.getLogPrefix()} PERM_RES id=${response.id} tool=${pending.toolName} decision=${result.decision} source=client`); pending.resolve(result); this.session.updateAgentState((currentState) => { const request = currentState.requests?.[response.id]; if (!request) return currentState; const { [response.id]: _, ...remainingRequests } = currentState.requests || {}; let res = { ...currentState, requests: remainingRequests, completedRequests: { ...currentState.completedRequests, [response.id]: { ...request, completedAt: Date.now(), status: response.approved ? "approved" : "denied", decision: result.decision } } }; return res; }); persistence.logger.debug(`${this.getLogPrefix()} Permission ${response.approved ? "approved" : "denied"} for ${pending.toolName}`); } ); } /** * Default timeout for a pending permission request. The opencode/ACP * binary blocks the JSON-RPC channel on a single in-flight * requestPermission call until it resolves — if the mobile/web * client never responds (backgrounded, lost socket, dismissed modal, * crashed), the binary freezes and the user sees only the last * tool-call "action verb" forever. This watchdog guarantees the * promise always settles within a bounded time. */ static DEFAULT_REQUEST_TIMEOUT_MS = 12e4; /** * Create and register a pending permission request with a built-in * timeout. Subclasses should call this instead of constructing the * Promise + pendingRequests.set pair themselves, so every permission * request is guaranteed to settle in bounded time (and so the * logging / dispatch lines are uniform across handlers). * * On timeout the request is resolved (not rejected) with * `defaultOnTimeout` (default `'denied'`). Resolving rather than * rejecting lets the agent see a clean tool-result and continue the * turn instead of treating the whole turn as failed. */ createPendingRequest(toolCallId, toolName, input, opts = {}) { const timeoutMs = opts.timeoutMs ?? BasePermissionHandler.DEFAULT_REQUEST_TIMEOUT_MS; const defaultOnTimeout = opts.defaultOnTimeout ?? "denied"; persistence.logger.debug(`${this.getLogPrefix()} PERM_REQ id=${toolCallId} tool=${toolName} timeoutMs=${timeoutMs}`); return new Promise((resolve, reject) => { const timeoutHandle = setTimeout(() => { const pending = this.pendingRequests.get(toolCallId); if (!pending) return; this.pendingRequests.delete(toolCallId); persistence.logger.debug(`${this.getLogPrefix()} PERM_RES id=${toolCallId} tool=${toolName} decision=${defaultOnTimeout} source=watchdog reason=client_did_not_respond_within_${timeoutMs}ms`); this.session.updateAgentState((currentState) => { const request = currentState.requests?.[toolCallId]; if (!request) return currentState; const { [toolCallId]: _, ...remaining } = currentState.requests ?? {}; return { ...currentState, requests: remaining, completedRequests: { ...currentState.completedRequests, [toolCallId]: { ...request, completedAt: Date.now(), status: "canceled", reason: "Client did not respond in time" } } }; }); resolve({ decision: defaultOnTimeout }); }, timeoutMs); this.pendingRequests.set(toolCallId, { resolve, reject, toolName, input, timeoutHandle }); this.addPendingRequestToState(toolCallId, toolName, input); }); } /** * Reject every pending permission request with the given reason. * Call this from the session-disconnect path so the agent stops * waiting on a client that's no longer present, and the turn * finishes with a real error instead of total silence. */ rejectAllPending(reason) { const snapshot = Array.from(this.pendingRequests.entries()); this.pendingRequests.clear(); for (const [id, pending] of snapshot) { if (pending.timeoutHandle) clearTimeout(pending.timeoutHandle); persistence.logger.debug(`${this.getLogPrefix()} PERM_RES id=${id} tool=${pending.toolName} decision=rejected source=disconnect reason=${reason}`); try { pending.reject(new Error(reason)); } catch (err) { persistence.logger.debug(`${this.getLogPrefix()} Error rejecting pending request ${id}:`, err); } } } /** * Add a pending request to the agent state. */ addPendingRequestToState(toolCallId, toolName, input) { this.session.updateAgentState((currentState) => ({ ...currentState, requests: { ...currentState.requests, [toolCallId]: { tool: toolName, arguments: input, createdAt: Date.now() } } })); } /** * Reset state for new sessions. * This method is idempotent - safe to call multiple times. */ reset() { if (this.isResetting) { persistence.logger.debug(`${this.getLogPrefix()} Reset already in progress, skipping`); return; } this.isResetting = true; try { const pendingSnapshot = Array.from(this.pendingRequests.entries()); this.pendingRequests.clear(); for (const [id, pending] of pendingSnapshot) { if (pending.timeoutHandle) clearTimeout(pending.timeoutHandle); persistence.logger.debug(`${this.getLogPrefix()} PERM_RES id=${id} tool=${pending.toolName} decision=rejected source=reset`); try { pending.reject(new Error("Session reset")); } catch (err) { persistence.logger.debug(`${this.getLogPrefix()} Error rejecting pending request ${id}:`, err); } } this.session.updateAgentState((currentState) => { const pendingRequests = currentState.requests || {}; const completedRequests = { ...currentState.completedRequests }; for (const [id, request] of Object.entries(pendingRequests)) { completedRequests[id] = { ...request, completedAt: Date.now(), status: "canceled", reason: "Session reset" }; } return { ...currentState, requests: {}, completedRequests }; }); persistence.logger.debug(`${this.getLogPrefix()} Permission handler reset`); } finally { this.isResetting = false; } } } exports.BasePermissionHandler = BasePermissionHandler;