consortium
Version:
Remote control and session sharing CLI for AI coding agents
206 lines (202 loc) • 8.17 kB
JavaScript
;
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;