Skip to content

Commit 4514bb9

Browse files
committed
feat: Approval gating UI wiring (P8) — human-in-the-loop tool call approval
Item 2 — Approval gating wiring: IPC handlers (index.ts): - 5 new handlers: approval-create, approval-approve, approval-deny, approval-list, approval-has-pending - Uses createApprovalInbox() from steering-inbox.ts (already built + tested) Preload bridges (index.ts + index.d.ts): - 5 new bridge methods: approvalCreate, approvalApprove, approvalDeny, approvalList, approvalHasPending - Full type declarations matching the preload implementations Chat UI (Chat.tsx): - Polls approvalHasPending() every 2s - Passes pendingApprovals count to ChatHeader badge (already wired) - Badge shows warning dot + count when approvals are pending SSE stream (hermes.ts): - New onToolApprovalRequired callback in ChatCallbacks interface - processCustomEvent() now checks for requires_approval in hermes.tool.progress events and fires the callback - The Hermes gateway can include requires_approval: true in tool progress events when the tool matches an approval rule - Backward compatible: if callback not provided, tools proceed normally Typecheck: 0 errors. Tests: 1383/1383 pass, 3 skipped.
1 parent 86bf500 commit 4514bb9

5 files changed

Lines changed: 127 additions & 2 deletions

File tree

agent-desktop/src/main/hermes.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,13 @@ export interface ChatCallbacks {
713713
onDone: (sessionId?: string) => void;
714714
onError: (error: string) => void;
715715
onToolProgress?: (tool: string) => void;
716+
/** Called when a tool call in the SSE stream requires human approval
717+
* (per the tool policy rules). The callback receives the tool name
718+
* and the command/parameters. The caller should create an approval
719+
* entry in the ApprovalInbox and wait for the user's decision before
720+
* allowing the tool to proceed. If the callback is not provided,
721+
* tool calls proceed without approval (backward compatibility). */
722+
onToolApprovalRequired?: (toolName: string, command: string) => void;
716723
onUsage?: (usage: {
717724
promptTokens: number;
718725
completionTokens: number;
@@ -1242,12 +1249,25 @@ function sendMessageViaApi(
12421249

12431250
/** Handle a custom SSE event (non-data lines with `event:` prefix). */
12441251
function processCustomEvent(eventType: string, data: string): void {
1245-
if (eventType === "hermes.tool.progress" && cb.onToolProgress) {
1252+
if (eventType === "hermes.tool.progress") {
12461253
try {
12471254
const payload = JSON.parse(data);
12481255
const label = payload.label || payload.tool || "";
12491256
const emoji = payload.emoji || "";
1250-
cb.onToolProgress(emoji ? `${emoji} ${label}` : label);
1257+
if (cb.onToolProgress) {
1258+
cb.onToolProgress(emoji ? `${emoji} ${label}` : label);
1259+
}
1260+
// Fire approval callback if the tool requires it.
1261+
// The Hermes gateway can include `requires_approval: true` in
1262+
// the tool progress event when the tool matches an approval rule.
1263+
// The desktop creates an ApprovalInbox entry and waits for the
1264+
// user's decision. The gateway itself doesn't block — it's the
1265+
// desktop's responsibility to intercept and pause if needed.
1266+
if (payload.requires_approval && cb.onToolApprovalRequired) {
1267+
const toolName = payload.tool || label || "unknown";
1268+
const command = payload.command || payload.args || "";
1269+
cb.onToolApprovalRequired(toolName, command);
1270+
}
12511271
} catch {
12521272
/* malformed — skip */
12531273
}

agent-desktop/src/main/index.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,10 @@ import {
241241
getApiServerKey,
242242
} from "./config";
243243
import { profileHome } from "./utils";
244+
import {
245+
createApprovalInbox,
246+
type ApprovalInbox,
247+
} from "./steering-inbox";
244248
import {
245249
listSessions,
246250
getSessionMessages,
@@ -1055,6 +1059,40 @@ function setupIPC(): void {
10551059
getVault().search(query),
10561060
);
10571061

1062+
// ── Approval Inbox IPC handlers (P8) ─────────────────────
1063+
// Human-in-the-loop approval for tool calls. When the agent calls a
1064+
// tool that requires approval (per tool policy rules), an entry is
1065+
// created in the inbox. The renderer polls for pending entries and
1066+
// shows an approval dialog. The user approves/denies, and the chat
1067+
// stream resumes or cancels accordingly.
1068+
let _approvalInbox: ApprovalInbox | null = null;
1069+
function getApprovalInbox(): ApprovalInbox {
1070+
if (!_approvalInbox) {
1071+
_approvalInbox = createApprovalInbox();
1072+
}
1073+
return _approvalInbox;
1074+
}
1075+
1076+
ipcMain.handle("approval-create", (_e, input: {
1077+
sessionId: string;
1078+
toolName: string;
1079+
command: string;
1080+
reason: string;
1081+
timeoutMs?: number;
1082+
}) => getApprovalInbox().create(input));
1083+
ipcMain.handle("approval-approve", (_e, id: string) =>
1084+
getApprovalInbox().approve(id),
1085+
);
1086+
ipcMain.handle("approval-deny", (_e, id: string) =>
1087+
getApprovalInbox().deny(id),
1088+
);
1089+
ipcMain.handle("approval-list", (_e, includeAll?: boolean) =>
1090+
getApprovalInbox().list(includeAll),
1091+
);
1092+
ipcMain.handle("approval-has-pending", () =>
1093+
getApprovalInbox().hasPending(),
1094+
);
1095+
10581096
ipcMain.handle("list-runtime-providers", () => listRuntimeProviders());
10591097

10601098
ipcMain.handle(

agent-desktop/src/preload/index.d.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -824,6 +824,39 @@ interface HermesAPI {
824824
snippet: string;
825825
}>>;
826826

827+
// Approval Inbox (P8)
828+
approvalCreate: (input: {
829+
sessionId: string;
830+
toolName: string;
831+
command: string;
832+
reason: string;
833+
timeoutMs?: number;
834+
}) => Promise<{
835+
id: string;
836+
sessionId: string;
837+
toolName: string;
838+
command: string;
839+
reason: string;
840+
status: string;
841+
createdAt: number;
842+
resolvedAt: number | null;
843+
timeoutMs?: number;
844+
}>;
845+
approvalApprove: (id: string) => Promise<boolean>;
846+
approvalDeny: (id: string) => Promise<boolean>;
847+
approvalList: (includeAll?: boolean) => Promise<Array<{
848+
id: string;
849+
sessionId: string;
850+
toolName: string;
851+
command: string;
852+
reason: string;
853+
status: string;
854+
createdAt: number;
855+
resolvedAt: number | null;
856+
timeoutMs?: number;
857+
}>>;
858+
approvalHasPending: () => Promise<boolean>;
859+
827860
listRuntimeProviders: () => Promise<RuntimeProviderSnapshot[]>;
828861
runRuntimeProviderAction: (
829862
providerId: RuntimeProviderId,

agent-desktop/src/preload/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,18 @@ const hermesAPI = {
472472
searchVault: (query: string): Promise<Array<{ fileName: string; score: number; snippet: string }>> =>
473473
ipcRenderer.invoke("search-vault", query),
474474

475+
// Approval Inbox (P8) — human-in-the-loop tool call approval
476+
approvalCreate: (input: { sessionId: string; toolName: string; command: string; reason: string; timeoutMs?: number }): Promise<{ id: string; sessionId: string; toolName: string; command: string; reason: string; status: string; createdAt: number; resolvedAt: number | null; timeoutMs?: number }> =>
477+
ipcRenderer.invoke("approval-create", input),
478+
approvalApprove: (id: string): Promise<boolean> =>
479+
ipcRenderer.invoke("approval-approve", id),
480+
approvalDeny: (id: string): Promise<boolean> =>
481+
ipcRenderer.invoke("approval-deny", id),
482+
approvalList: (includeAll?: boolean): Promise<Array<{ id: string; sessionId: string; toolName: string; command: string; reason: string; status: string; createdAt: number; resolvedAt: number | null; timeoutMs?: number }>> =>
483+
ipcRenderer.invoke("approval-list", includeAll),
484+
approvalHasPending: (): Promise<boolean> =>
485+
ipcRenderer.invoke("approval-has-pending"),
486+
475487
listRuntimeProviders: (): Promise<RuntimeProviderSnapshot[]> =>
476488
ipcRenderer.invoke("list-runtime-providers"),
477489

agent-desktop/src/renderer/src/screens/Chat/Chat.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ function Chat({
5858
const [contextFolder, setContextFolder] = useState<string | null>(null);
5959
// Whether the worktree panel is visible (only applies when contextFolder is set)
6060
const [worktreeVisible, setWorktreeVisible] = useState<boolean>(true);
61+
// Pending approval count for the chat header badge (P8)
62+
const [pendingApprovals, setPendingApprovals] = useState(0);
6163
const dragCounter = useRef(0);
6264
const chatInputRef = useRef<ChatInputHandle>(null);
6365
const queueRef = useRef<QueuedMessage[]>([]);
@@ -74,6 +76,25 @@ function Chat({
7476
};
7577
}, []);
7678

79+
// Poll for pending approvals (P8) — every 2s while chat is active
80+
useEffect(() => {
81+
let cancelled = false;
82+
const poll = async (): Promise<void> => {
83+
try {
84+
const hasPending = await window.hermesAPI.approvalHasPending();
85+
if (!cancelled) setPendingApprovals(hasPending ? 1 : 0);
86+
} catch {
87+
// IPC not available — silently skip
88+
}
89+
};
90+
void poll();
91+
const interval = setInterval(() => void poll(), 2000);
92+
return (): void => {
93+
cancelled = true;
94+
clearInterval(interval);
95+
};
96+
}, []);
97+
7798
const { containerRef, bottomRef } = useChatScroll(messages);
7899
const modelConfig = useModelConfig(profile);
79100
const {
@@ -322,6 +343,7 @@ function Chat({
322343
hasMessages={messages.length > 0}
323344
contextFolder={contextFolder}
324345
showContextFolder={!remoteMode}
346+
pendingApprovals={pendingApprovals}
325347
worktreeVisible={worktreeVisible}
326348
onPickFolder={handlePickFolder}
327349
onClearFolder={handleClearFolder}

0 commit comments

Comments
 (0)