From e05a754bd182304f27046c3e7aeed2b061d2b794 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 02:39:34 -0800 Subject: [PATCH 1/2] Add agent hooks for chat gui --- .../src/lib/trpc/routers/ai-chat/index.ts | 8 +++ .../agent-provider/claude-sdk-provider.ts | 19 +++++- .../ai-chat/utils/agent-provider/types.ts | 3 + .../utils/session-manager/session-manager.ts | 13 ++++ .../ChatPane/ChatInterface/ChatInterface.tsx | 10 ++- .../TabsContent/TabView/ChatPane/ChatPane.tsx | 2 + apps/streams/src/claude-agent.ts | 67 ++++++++++++++++++- apps/streams/src/sdk-to-ai-chunks.ts | 2 +- 8 files changed, 117 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts index 6c5a8296c6d..9557f511fe9 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts @@ -27,6 +27,8 @@ export const createAiChatRouter = () => { sessionId: z.string(), workspaceId: z.string(), cwd: z.string(), + paneId: z.string().optional(), + tabId: z.string().optional(), }), ) .mutation(async ({ input }) => { @@ -34,6 +36,8 @@ export const createAiChatRouter = () => { sessionId: input.sessionId, workspaceId: input.workspaceId, cwd: input.cwd, + paneId: input.paneId, + tabId: input.tabId, }); return { success: true }; }), @@ -43,12 +47,16 @@ export const createAiChatRouter = () => { z.object({ sessionId: z.string(), cwd: z.string(), + paneId: z.string().optional(), + tabId: z.string().optional(), }), ) .mutation(async ({ input }) => { await chatSessionManager.restoreSession({ sessionId: input.sessionId, cwd: input.cwd, + paneId: input.paneId, + tabId: input.tabId, }); return { success: true }; }), diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/claude-sdk-provider.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/claude-sdk-provider.ts index 9f81f42a36b..86f049c5f6b 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/claude-sdk-provider.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/claude-sdk-provider.ts @@ -1,3 +1,5 @@ +import { PORTS } from "shared/constants"; +import { env } from "shared/env.shared"; import { buildClaudeEnv } from "../auth"; import type { AgentProvider, @@ -17,11 +19,17 @@ export class ClaudeSdkProvider implements AgentProvider { getAgentRegistration({ sessionId, cwd, + paneId, + tabId, + workspaceId, }: { sessionId: string; cwd: string; + paneId?: string; + tabId?: string; + workspaceId?: string; }): AgentRegistration { - const env = buildClaudeEnv(); + const claudeEnv = buildClaudeEnv(); return { id: "claude", @@ -30,7 +38,14 @@ export class ClaudeSdkProvider implements AgentProvider { bodyTemplate: { sessionId, cwd, - env, + env: claudeEnv, + notification: { + port: PORTS.NOTIFICATIONS, + paneId, + tabId, + workspaceId, + env: env.NODE_ENV === "development" ? "development" : "production", + }, }, }; } diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/types.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/types.ts index 3990a3332eb..8d4674529a6 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/types.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/agent-provider/types.ts @@ -16,6 +16,9 @@ export interface AgentProvider { getAgentRegistration(opts: { sessionId: string; cwd: string; + paneId?: string; + tabId?: string; + workspaceId?: string; }): AgentRegistration; getProviderSessionId(sessionId: string): Promise; diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts index 67acd460401..c3daa54f72c 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts @@ -57,10 +57,14 @@ export class ChatSessionManager extends EventEmitter { sessionId, workspaceId, cwd, + paneId, + tabId, }: { sessionId: string; workspaceId: string; cwd: string; + paneId?: string; + tabId?: string; }): Promise { if (this.sessions.has(sessionId)) { console.warn(`[chat/session] Session ${sessionId} already active`); @@ -84,6 +88,9 @@ export class ChatSessionManager extends EventEmitter { const registration = this.provider.getAgentRegistration({ sessionId, cwd, + paneId, + tabId, + workspaceId, }); const registerRes = await fetch( `${PROXY_URL}/v1/sessions/${sessionId}/agents`, @@ -131,9 +138,13 @@ export class ChatSessionManager extends EventEmitter { async restoreSession({ sessionId, cwd, + paneId, + tabId, }: { sessionId: string; cwd: string; + paneId?: string; + tabId?: string; }): Promise { if (this.sessions.has(sessionId)) { return; @@ -156,6 +167,8 @@ export class ChatSessionManager extends EventEmitter { const registration = this.provider.getAgentRegistration({ sessionId, cwd, + paneId, + tabId, }); const registerRes = await fetch( `${PROXY_URL}/v1/sessions/${sessionId}/agents`, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx index cc7c08925d5..861be2b4b76 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx @@ -35,12 +35,16 @@ interface ChatInterfaceProps { sessionId: string; workspaceId: string; cwd: string; + paneId: string; + tabId: string; } export function ChatInterface({ sessionId, workspaceId, cwd, + paneId, + tabId, }: ChatInterfaceProps) { const [selectedModel, setSelectedModel] = useState(MODELS[1]); const [modelSelectorOpen, setModelSelectorOpen] = useState(false); @@ -124,19 +128,21 @@ export function ChatInterface({ setSessionReady(false); if (existingSession) { - restoreSessionRef.current.mutate({ sessionId, cwd }); + restoreSessionRef.current.mutate({ sessionId, cwd, paneId, tabId }); } else { startSessionRef.current.mutate({ sessionId, workspaceId, cwd, + paneId, + tabId, }); } return () => { stopSessionRef.current.mutate({ sessionId }); }; - }, [sessionId, cwd, workspaceId, existingSession]); + }, [sessionId, cwd, workspaceId, existingSession, paneId, tabId]); useEffect(() => { if (sessionReady && config?.proxyUrl) { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx index 0173706082e..95039dcde03 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx @@ -99,6 +99,8 @@ export function ChatPane({ sessionId={sessionId} workspaceId={workspaceId} cwd={workspace?.worktreePath ?? ""} + paneId={paneId} + tabId={tabId} /> ); diff --git a/apps/streams/src/claude-agent.ts b/apps/streams/src/claude-agent.ts index 6c277ad1a14..71a5e6879e6 100644 --- a/apps/streams/src/claude-agent.ts +++ b/apps/streams/src/claude-agent.ts @@ -1,7 +1,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { query } from "@anthropic-ai/claude-agent-sdk"; +import { + type HookCallbackMatcher, + type HookEvent, + query, +} from "@anthropic-ai/claude-agent-sdk"; import { Hono } from "hono"; import { z } from "zod"; import { createConverter } from "./sdk-to-ai-chunks"; @@ -11,6 +15,14 @@ const MAX_AGENT_TURNS = 25; const SESSION_MAX_SIZE = 1000; const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +const notificationSchema = z.object({ + port: z.number(), + paneId: z.string().optional(), + tabId: z.string().optional(), + workspaceId: z.string().optional(), + env: z.string().optional(), +}); + const agentRequestSchema = z.object({ messages: z .array(z.object({ role: z.string(), content: z.string() })) @@ -19,6 +31,7 @@ const agentRequestSchema = z.object({ sessionId: z.string().optional(), cwd: z.string().optional(), env: z.record(z.string(), z.string()).optional(), + notification: notificationSchema.optional(), }); interface SessionEntry { @@ -98,6 +111,51 @@ function setClaudeSessionId(sessionId: string, claudeSessionId: string): void { persistSessions(); } +type NotificationContext = z.infer; + +/** + * Build SDK hooks that notify the desktop app's notification server + * about agent lifecycle events (start/stop). + * + * This mirrors the terminal shell wrapper hooks that call + * GET /hook/complete?eventType=...&paneId=...&tabId=...&workspaceId=... + */ +function buildNotificationHooks({ + notification, +}: { + notification: NotificationContext; +}): Partial> { + const baseUrl = `http://localhost:${notification.port}/hook/complete`; + + const buildUrl = (eventType: string): string => { + const params = new URLSearchParams({ eventType }); + if (notification.paneId) params.set("paneId", notification.paneId); + if (notification.tabId) params.set("tabId", notification.tabId); + if (notification.workspaceId) + params.set("workspaceId", notification.workspaceId); + if (notification.env) params.set("env", notification.env); + return `${baseUrl}?${params.toString()}`; + }; + + const createHookMatcher = (eventType: string): HookCallbackMatcher => ({ + hooks: [ + async () => { + try { + await fetch(buildUrl(eventType)); + } catch (err) { + console.warn(`[claude-agent] Failed to notify ${eventType}:`, err); + } + return { continue: true }; + }, + ], + }); + + return { + UserPromptSubmit: [createHookMatcher("UserPromptSubmit")], + Stop: [createHookMatcher("Stop")], + }; +} + const app = new Hono(); app.post("/", async (c) => { @@ -117,7 +175,7 @@ app.post("/", async (c) => { ); } - const { messages, sessionId, cwd, env: agentEnv } = parsed.data; + const { messages, sessionId, cwd, env: agentEnv, notification } = parsed.data; const latestUserMessage = messages?.filter((m) => m.role === "user").pop(); @@ -135,6 +193,10 @@ app.post("/", async (c) => { const binaryPath = process.env.CLAUDE_BINARY_PATH; + const hooks = notification + ? buildNotificationHooks({ notification }) + : undefined; + const abortController = new AbortController(); const result = query({ prompt, @@ -148,6 +210,7 @@ app.post("/", async (c) => { ...(binaryPath && { pathToClaudeCodeExecutable: binaryPath }), env: queryEnv, abortController, + ...(hooks && { hooks }), }, }); diff --git a/apps/streams/src/sdk-to-ai-chunks.ts b/apps/streams/src/sdk-to-ai-chunks.ts index a2300987c5a..4434481fee2 100644 --- a/apps/streams/src/sdk-to-ai-chunks.ts +++ b/apps/streams/src/sdk-to-ai-chunks.ts @@ -15,8 +15,8 @@ * - RUN_ERROR — error during execution */ -import type { StreamChunk } from "@tanstack/ai"; import { createTextSegmentEnricher } from "@superset/durable-session"; +import type { StreamChunk } from "@tanstack/ai"; // ============================================================================ // Claude SDK Types (subset used for conversion) From b44bf0c42ce620476442a9b2ea85075566ef8765 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 8 Feb 2026 02:40:48 -0800 Subject: [PATCH 2/2] Docs --- apps/streams/src/claude-agent.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/streams/src/claude-agent.ts b/apps/streams/src/claude-agent.ts index 71a5e6879e6..9f5ecec9cfd 100644 --- a/apps/streams/src/claude-agent.ts +++ b/apps/streams/src/claude-agent.ts @@ -113,13 +113,7 @@ function setClaudeSessionId(sessionId: string, claudeSessionId: string): void { type NotificationContext = z.infer; -/** - * Build SDK hooks that notify the desktop app's notification server - * about agent lifecycle events (start/stop). - * - * This mirrors the terminal shell wrapper hooks that call - * GET /hook/complete?eventType=...&paneId=...&tabId=...&workspaceId=... - */ +// Mirrors the terminal shell wrapper hooks that call GET /hook/complete function buildNotificationHooks({ notification, }: {