Files
NetMesh/infrastructure/ai/shared/codexAppServerInteractions.ts

199 lines
6.4 KiB
TypeScript
Raw Permalink Normal View History

import {
clearPendingApprovalIds,
registerExternalApproval,
} from './approvalGate';
export type CodexApprovalDecision = 'once' | 'session' | 'reject' | 'cancel';
export type CodexCommandApprovalDecision =
| 'accept'
| 'acceptForSession'
| 'decline'
| 'cancel'
| Record<string, unknown>;
export interface CodexUserInputQuestion {
id: string;
header: string;
question: string;
isOther: boolean;
isSecret: boolean;
options: Array<{ label: string; description: string }> | null;
}
export type CodexAppServerInteraction =
| {
interactionId: string;
source: 'codex-app-server';
kind: 'command' | 'file-change' | 'permissions';
requestId: string;
chatSessionId: string;
itemId?: string;
toolName: string;
args: Record<string, unknown>;
availableDecisions?: CodexCommandApprovalDecision[];
}
| {
interactionId: string;
source: 'codex-app-server';
kind: 'user-input';
requestId: string;
chatSessionId: string;
itemId?: string;
questions: CodexUserInputQuestion[];
autoResolutionMs?: number | null;
};
type InteractionListener = (interaction: CodexAppServerInteraction) => void;
type ClearedListener = (interactionIds: string[]) => void;
const pendingInteractions = new Map<string, CodexAppServerInteraction>();
const listeners = new Set<InteractionListener>();
const clearedListeners = new Set<ClearedListener>();
function registerCodexApproval(
interaction: Extract<CodexAppServerInteraction, { kind: 'command' | 'file-change' | 'permissions' }>,
): void {
const allowSession = interaction.kind === 'command'
? interaction.availableDecisions?.includes('acceptForSession') === true
: true;
registerExternalApproval({
toolCallId: interaction.interactionId,
itemId: interaction.itemId,
toolName: interaction.toolName,
args: interaction.args,
chatSessionId: interaction.chatSessionId,
source: 'codex-app-server',
approvalType: interaction.kind,
allowSession,
}, (resolution) => {
const decision: CodexApprovalDecision = resolution.cancelled
? 'cancel'
: resolution.approved
? resolution.scope
: 'reject';
void respondCodexApproval(interaction.interactionId, decision).catch((error) => {
console.error('[Codex App Server] Failed to respond to approval:', error);
if (pendingInteractions.has(interaction.interactionId)) registerCodexApproval(interaction);
});
});
}
function notifyCleared(interactionIds: string[]): void {
if (interactionIds.length === 0) return;
for (const listener of clearedListeners) {
try { listener(interactionIds); } catch { /* ignore listener failures */ }
}
}
let bridgeInstallCount = 0;
let bridgeTeardown: (() => void) | null = null;
function installCodexAppServerInteractionBridge(): () => void {
const bridge = (window as unknown as {
netcatty?: {
onCodexAppServerInteractionRequest?: (
cb: (payload: CodexAppServerInteraction) => void,
) => () => void;
onCodexAppServerInteractionCleared?: (
cb: (payload: { interactionIds: string[] }) => void,
) => () => void;
};
}).netcatty;
if (!bridge?.onCodexAppServerInteractionRequest) return () => {};
const unsubscribeRequest = bridge.onCodexAppServerInteractionRequest((interaction) => {
if (!interaction?.interactionId) return;
pendingInteractions.set(interaction.interactionId, interaction);
if (interaction.kind !== 'user-input') {
registerCodexApproval(interaction);
return;
}
for (const listener of listeners) {
try { listener(interaction); } catch { /* ignore listener failures */ }
}
});
const unsubscribeCleared = bridge.onCodexAppServerInteractionCleared?.((payload) => {
const cleared: string[] = [];
for (const interactionId of payload?.interactionIds || []) {
if (pendingInteractions.delete(interactionId)) cleared.push(interactionId);
}
clearPendingApprovalIds(cleared);
notifyCleared(cleared);
});
return () => {
unsubscribeRequest();
unsubscribeCleared?.();
};
}
/**
* App-singleton IPC bridge (same pattern as setupMcpApprovalBridge).
* Ref-counted so StrictMode remount / accidental multi-mount does not stack
* ipcRenderer listeners.
*/
export function setupCodexAppServerInteractionBridge(): () => void {
bridgeInstallCount += 1;
if (bridgeInstallCount === 1) {
bridgeTeardown = installCodexAppServerInteractionBridge();
}
return () => {
bridgeInstallCount = Math.max(0, bridgeInstallCount - 1);
if (bridgeInstallCount === 0) {
bridgeTeardown?.();
bridgeTeardown = null;
}
};
}
export function onCodexAppServerInteraction(listener: InteractionListener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
export function onCodexAppServerInteractionCleared(listener: ClearedListener): () => void {
clearedListeners.add(listener);
return () => { clearedListeners.delete(listener); };
}
export function replayPendingCodexAppServerInteractions(listener: InteractionListener): void {
for (const interaction of pendingInteractions.values()) {
if (interaction.kind !== 'user-input') continue;
try { listener(interaction); } catch { /* ignore listener failures */ }
}
}
async function respond(payload: Record<string, unknown>): Promise<void> {
const interactionId = String(payload.interactionId || '');
if (!interactionId) return;
const bridge = (window as unknown as {
netcatty?: {
respondCodexAppServerInteraction?: (
response: Record<string, unknown>,
) => Promise<unknown>;
};
}).netcatty;
if (!bridge?.respondCodexAppServerInteraction) {
throw new Error('Codex App Server interaction bridge is unavailable');
}
const result = await bridge.respondCodexAppServerInteraction(payload) as { ok?: boolean; error?: string } | undefined;
if (result?.ok === false) {
throw new Error(result.error || 'Failed to respond to Codex App Server interaction');
}
pendingInteractions.delete(interactionId);
notifyCleared([interactionId]);
}
export function respondCodexApproval(
interactionId: string,
decision: CodexApprovalDecision,
): Promise<void> {
return respond({ interactionId, decision });
}
export function respondCodexUserInput(
interactionId: string,
answers: Record<string, { answers: string[] }>,
): Promise<void> {
return respond({ interactionId, answers });
}