diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index a618a8d0d87..bb21e3b09fd 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -36,6 +36,7 @@ import { TRPCError } from "@trpc/server"; import { app } from "electron"; import { env } from "main/env.main"; import { exitImmediately } from "main/index"; +import { setupSingleAgent } from "main/lib/agent-setup"; import { hasCustomRingtone } from "main/lib/custom-ringtones"; import { getHostServiceCoordinator } from "main/lib/host-service-coordinator"; import { localDb } from "main/lib/local-db"; @@ -1008,6 +1009,17 @@ export const createSettingsRouter = () => { return { success: true }; }), + /** + * Re-runs wrapper/settings/hook setup for one agent. Safety net for + * the settings-UI Add flow; returns `{ ran: false }` for unknown ids. + */ + setupAgent: publicProcedure + .input(z.object({ agentId: z.string().min(1) })) + .mutation(({ input }) => { + const ran = setupSingleAgent(input.agentId); + return { ran }; + }), + // TODO: remove telemetry procedures once telemetry_enabled column is dropped getTelemetryEnabled: publicProcedure.query(() => { return true; diff --git a/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts b/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts index 12c001e6295..f34ad9bc816 100644 --- a/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts +++ b/apps/desktop/src/main/lib/agent-setup/desktop-agent-capabilities.ts @@ -1,6 +1,6 @@ import type { AgentType } from "@superset/shared/agent-command"; -export type SupersetManagedBinary = AgentType | "droid"; +export type SupersetManagedBinary = AgentType; export const DESKTOP_AGENT_SETUP_ACTIONS = [ "notify-script", @@ -32,7 +32,7 @@ export type DesktopAgentSetupAction = (typeof DESKTOP_AGENT_SETUP_ACTIONS)[number]; interface DesktopAgentSetupTarget { - id: AgentType | "droid"; + id: AgentType; setupActions: readonly DesktopAgentSetupAction[]; managedBinary?: boolean; } diff --git a/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts b/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts index 362a95b40ba..5a4cb69ab89 100644 --- a/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts +++ b/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts @@ -67,3 +67,20 @@ export function setupDesktopAgentCapabilities(): void { } } } + +/** + * Re-run setupActions for one agent. Bootstrap actions run first because + * per-agent hooks reference the shared notify script — without them the + * per-agent setup isn't self-sufficient. Returns `false` for unknown ids. + */ +export function setupSingleAgent(agentId: string): boolean { + const target = DESKTOP_AGENT_SETUP_TARGETS.find((t) => t.id === agentId); + if (!target) return false; + for (const action of DESKTOP_AGENT_SETUP_BOOTSTRAP_ACTIONS) { + DESKTOP_AGENT_SETUP_RUNNERS[action](); + } + for (const action of target.setupActions) { + DESKTOP_AGENT_SETUP_RUNNERS[action](); + } + return true; +} diff --git a/apps/desktop/src/main/lib/agent-setup/index.ts b/apps/desktop/src/main/lib/agent-setup/index.ts index 3b8ddd33324..d30cf46e828 100644 --- a/apps/desktop/src/main/lib/agent-setup/index.ts +++ b/apps/desktop/src/main/lib/agent-setup/index.ts @@ -1,5 +1,8 @@ import fs from "node:fs"; -import { setupDesktopAgentCapabilities } from "./desktop-agent-setup"; +import { + setupDesktopAgentCapabilities, + setupSingleAgent, +} from "./desktop-agent-setup"; import { BASH_DIR, BIN_DIR, @@ -36,4 +39,6 @@ export function getSupersetBinDir(): string { return BIN_DIR; } +export { setupSingleAgent }; + export { getCommandShellArgs, getShellArgs, getShellEnv }; diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/claude.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/claude.svg deleted file mode 100644 index 62dc0db12da..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/claude.svg +++ /dev/null @@ -1 +0,0 @@ -Claude \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/codex-white.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/codex-white.svg deleted file mode 100644 index ba36fc2aa74..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/codex-white.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/codex.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/codex.svg deleted file mode 100644 index 832fa6a5f9b..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/codex.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/cursor.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/cursor.svg deleted file mode 100644 index 1f8a7c338a5..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/cursor.svg +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/gemini.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/gemini.svg deleted file mode 100644 index f1cf357573d..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/gemini.svg +++ /dev/null @@ -1 +0,0 @@ -Gemini \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode-white.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode-white.svg deleted file mode 100644 index b79c7332e20..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode-white.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode.svg deleted file mode 100644 index b79140a5070..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/desktop/src/renderer/hooks/host-service/useTerminalAgentBindings/index.ts b/apps/desktop/src/renderer/hooks/host-service/useTerminalAgentBindings/index.ts new file mode 100644 index 00000000000..97abfbcc521 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useTerminalAgentBindings/index.ts @@ -0,0 +1,5 @@ +export { + type TerminalAgentBinding, + useTerminalAgentBinding, + useTerminalAgentBindings, +} from "./useTerminalAgentBindings"; diff --git a/apps/desktop/src/renderer/hooks/host-service/useTerminalAgentBindings/useTerminalAgentBindings.ts b/apps/desktop/src/renderer/hooks/host-service/useTerminalAgentBindings/useTerminalAgentBindings.ts new file mode 100644 index 00000000000..bcfa737f376 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useTerminalAgentBindings/useTerminalAgentBindings.ts @@ -0,0 +1,66 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { useWorkspaceEvent } from "../useWorkspaceEvent"; +import { useWorkspaceHostUrl } from "../useWorkspaceHostUrl"; + +type ListByWorkspaceClient = ReturnType< + typeof getHostServiceClientByUrl +>["terminalAgents"]["listByWorkspace"]; +type TerminalAgentBindings = Awaited< + ReturnType +>; +export type TerminalAgentBinding = TerminalAgentBindings[number]; + +/** + * Map of `terminalId → agent binding` for a workspace, read from the host + * store and invalidated on `agent:lifecycle` / `terminal:lifecycle` events. + */ +export function useTerminalAgentBindings( + workspaceId: string, +): Map { + const hostUrl = useWorkspaceHostUrl(workspaceId); + const queryClient = useQueryClient(); + const queryKey = useMemo( + () => ["terminal-agent-bindings", hostUrl, workspaceId] as const, + [hostUrl, workspaceId], + ); + + const enabled = Boolean(workspaceId) && Boolean(hostUrl); + + const { data } = useQuery({ + queryKey, + enabled, + queryFn: () => { + if (!hostUrl) return [] as TerminalAgentBindings; + return getHostServiceClientByUrl( + hostUrl, + ).terminalAgents.listByWorkspace.query({ workspaceId }); + }, + refetchOnWindowFocus: false, + staleTime: Number.POSITIVE_INFINITY, + }); + + const invalidate = useCallback(() => { + void queryClient.invalidateQueries({ queryKey }); + }, [queryClient, queryKey]); + + useWorkspaceEvent("agent:lifecycle", workspaceId, invalidate, enabled); + useWorkspaceEvent("terminal:lifecycle", workspaceId, invalidate, enabled); + + return useMemo(() => { + const map = new Map(); + for (const binding of data ?? []) { + map.set(binding.terminalId, binding); + } + return map; + }, [data]); +} + +export function useTerminalAgentBinding( + workspaceId: string, + terminalId: string, +): TerminalAgentBinding | undefined { + const bindings = useTerminalAgentBindings(workspaceId); + return bindings.get(terminalId); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalPaneIcon/TerminalPaneIcon.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalPaneIcon/TerminalPaneIcon.tsx index a6dab14929c..444d3ab4951 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalPaneIcon/TerminalPaneIcon.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalPaneIcon/TerminalPaneIcon.tsx @@ -1,23 +1,25 @@ import { BUILTIN_AGENT_LABELS } from "@superset/shared/agent-catalog"; import { TerminalSquare } from "lucide-react"; import { usePresetIcon } from "renderer/assets/app-icons/preset-icons"; -import { - selectV2AgentBinding, - useV2AgentBindingStore, -} from "renderer/stores/v2-agent-bindings"; +import { useTerminalAgentBinding } from "renderer/hooks/host-service/useTerminalAgentBindings"; interface TerminalPaneIconProps { + workspaceId: string; terminalId: string; } /** - * Pane icon that swaps in the running agent's logo when the v2 lifecycle hook - * has detected one in this terminal. Falls back to the generic terminal glyph - * when no agent is bound or the agent id has no preset icon. + * Pane icon that swaps in the running agent's logo when the host-service + * `terminalAgents` tracker has detected one in this terminal. Falls back + * to the generic terminal glyph when no agent is bound or the agent id + * has no preset icon. */ -export function TerminalPaneIcon({ terminalId }: TerminalPaneIconProps) { - const binding = useV2AgentBindingStore(selectV2AgentBinding(terminalId)); - const agentId = binding?.identity.agentId; +export function TerminalPaneIcon({ + workspaceId, + terminalId, +}: TerminalPaneIconProps) { + const binding = useTerminalAgentBinding(workspaceId, terminalId); + const agentId = binding?.agentId; const iconSrc = usePresetIcon(agentId ?? ""); if (agentId && iconSrc) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index 440e94f402d..09187ac5275 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -290,7 +290,7 @@ export function TerminalSessionDropdown({ onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} > - + {workspaceRunState && ( { const { terminalId } = ctx.pane.data as TerminalPaneData; - return ; + return ( + + ); }, getTitle: () => "Terminal", titleSource: (pane) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx index ff1390bd1dc..364d47d5ebc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/V2NotificationController/components/HostNotificationSubscriber/HostNotificationSubscriber.tsx @@ -8,7 +8,6 @@ import { useEffect, useEffectEvent, useMemo } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceWsToken } from "renderer/lib/host-service-auth"; import type { PaneViewerData } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; -import { useV2AgentBindingStore } from "renderer/stores/v2-agent-bindings"; import { handleV2AgentLifecycleEvent, handleV2TerminalLifecycleEvent, @@ -41,15 +40,6 @@ export function HostNotificationSubscriber({ const handleAgentLifecycle = useEffectEvent( (workspaceId: string, payload: AgentLifecyclePayload) => { - if (payload.eventType === "Detached") { - useV2AgentBindingStore.getState().clearBinding(payload.terminalId); - } else if (payload.agent) { - useV2AgentBindingStore - .getState() - .setBinding(payload.terminalId, payload.agent, payload.occurredAt); - } else { - useV2AgentBindingStore.getState().clearBinding(payload.terminalId); - } const workspace = workspacesById.get(workspaceId); if (!workspace) return; handleV2AgentLifecycleEvent({ @@ -65,9 +55,6 @@ export function HostNotificationSubscriber({ const handleTerminalLifecycle = useEffectEvent( (workspaceId: string, payload: TerminalLifecyclePayload) => { - if (payload.eventType === "exit") { - useV2AgentBindingStore.getState().clearBinding(payload.terminalId); - } const workspace = workspacesById.get(workspaceId); if (!workspace) return; handleV2TerminalLifecycleEvent({ diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx index 6725402cb7f..1763eec72cb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx @@ -12,6 +12,7 @@ import { V2_AGENT_CONFIGS_QUERY_KEY as QUERY_KEY, useV2AgentConfigs, } from "renderer/hooks/useV2AgentConfigs"; +import { electronTrpc } from "renderer/lib/electron-trpc"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { getHostServiceUnavailableMessage } from "renderer/lib/host-service-unavailable"; import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; @@ -61,8 +62,10 @@ export function V2AgentsSettings({ ); }; + const setupAgentMutation = electronTrpc.settings.setupAgent.useMutation(); + const addMutation = useMutation({ - mutationFn: (preset: HostAgentPreset) => { + mutationFn: async (preset: HostAgentPreset) => { if (!activeHostUrl) { throw new Error( getHostServiceUnavailableMessage(hostService, { @@ -71,9 +74,23 @@ export function V2AgentsSettings({ ); } const { description: _description, ...body } = preset; - return getHostServiceClientByUrl( - activeHostUrl, - ).settings.agentConfigs.add.mutate(body); + const added = + await getHostServiceClientByUrl( + activeHostUrl, + ).settings.agentConfigs.add.mutate(body); + // Safety net: re-run wrapper/hook setup so Add guarantees the hooks + // are wired even if boot setup failed or the wrapper was wiped. + setupAgentMutation.mutate( + { agentId: preset.presetId }, + { + onError: (err) => + console.warn( + `[agents] setupAgent failed for ${preset.presetId}`, + err, + ), + }, + ); + return added; }, onSuccess: (added) => { invalidate(); diff --git a/apps/desktop/src/renderer/stores/v2-agent-bindings/index.ts b/apps/desktop/src/renderer/stores/v2-agent-bindings/index.ts deleted file mode 100644 index 6f631f41cbe..00000000000 --- a/apps/desktop/src/renderer/stores/v2-agent-bindings/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - selectV2AgentBinding, - useV2AgentBindingStore, - type V2AgentBinding, - type V2AgentBindingState, -} from "./store"; diff --git a/apps/desktop/src/renderer/stores/v2-agent-bindings/store.test.ts b/apps/desktop/src/renderer/stores/v2-agent-bindings/store.test.ts deleted file mode 100644 index 8aea0b0e58c..00000000000 --- a/apps/desktop/src/renderer/stores/v2-agent-bindings/store.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { beforeEach, describe, expect, it } from "bun:test"; -import { useV2AgentBindingStore } from "./store"; - -function reset() { - useV2AgentBindingStore.setState({ byTerminalId: {} }); -} - -describe("useV2AgentBindingStore", () => { - beforeEach(reset); - - it("stores and clears identity per terminal", () => { - const { setBinding, clearBinding } = useV2AgentBindingStore.getState(); - - setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 100); - expect(useV2AgentBindingStore.getState().byTerminalId["term-1"]).toEqual({ - identity: { agentId: "claude", sessionId: "s1" }, - lastEventAt: 100, - }); - - clearBinding("term-1"); - expect( - useV2AgentBindingStore.getState().byTerminalId["term-1"], - ).toBeUndefined(); - }); - - it("retains the binding across repeated events for the same session", () => { - const { setBinding } = useV2AgentBindingStore.getState(); - - setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 100); - const firstRef = useV2AgentBindingStore.getState().byTerminalId["term-1"]; - setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 50); - setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 200); - - // Identical identity events are no-ops; the icon does not need churn. - expect(useV2AgentBindingStore.getState().byTerminalId["term-1"]).toBe( - firstRef, - ); - }); - - it("replaces the binding when sessionId changes", () => { - const { setBinding } = useV2AgentBindingStore.getState(); - - setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 100); - setBinding("term-1", { agentId: "claude", sessionId: "s2" }, 200); - - expect( - useV2AgentBindingStore.getState().byTerminalId["term-1"]?.identity, - ).toEqual({ agentId: "claude", sessionId: "s2" }); - }); - - it("replaces the binding when agentId changes", () => { - const { setBinding } = useV2AgentBindingStore.getState(); - - setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 100); - setBinding("term-1", { agentId: "codex", sessionId: "s1" }, 200); - - expect( - useV2AgentBindingStore.getState().byTerminalId["term-1"]?.identity - .agentId, - ).toBe("codex"); - }); - - it("ignores stale events for a different identity", () => { - const { setBinding } = useV2AgentBindingStore.getState(); - - setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 100); - setBinding("term-1", { agentId: "codex", sessionId: "s2" }, 200); - setBinding("term-1", { agentId: "claude", sessionId: "s1" }, 150); - - expect( - useV2AgentBindingStore.getState().byTerminalId["term-1"]?.identity, - ).toEqual({ agentId: "codex", sessionId: "s2" }); - }); - - it("isolates bindings per terminal", () => { - const { setBinding, clearBinding } = useV2AgentBindingStore.getState(); - - setBinding("term-1", { agentId: "claude" }, 100); - setBinding("term-2", { agentId: "codex" }, 100); - clearBinding("term-1"); - - expect(useV2AgentBindingStore.getState().byTerminalId).toEqual({ - "term-2": { identity: { agentId: "codex" }, lastEventAt: 100 }, - }); - }); -}); diff --git a/apps/desktop/src/renderer/stores/v2-agent-bindings/store.ts b/apps/desktop/src/renderer/stores/v2-agent-bindings/store.ts deleted file mode 100644 index 0f610a9db91..00000000000 --- a/apps/desktop/src/renderer/stores/v2-agent-bindings/store.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { AgentIdentity } from "@superset/workspace-client"; -import { create } from "zustand"; - -export interface V2AgentBinding { - identity: AgentIdentity; - lastEventAt: number; -} - -export interface V2AgentBindingState { - byTerminalId: Record; - setBinding: ( - terminalId: string, - identity: AgentIdentity, - occurredAt: number, - ) => void; - clearBinding: (terminalId: string) => void; -} - -/** - * Live `terminalId → AgentIdentity` map populated from `agent:lifecycle` - * events. Replaced on a different `agentId`/`sessionId` (e.g. `claude` → - * `/exit` → `codex`), cleared on terminal exit. Not persisted — the worst - * case is a brief icon flicker until the next event. - */ -export const useV2AgentBindingStore = create((set) => ({ - byTerminalId: {}, - setBinding: (terminalId, identity, occurredAt) => - set((state) => { - const existing = state.byTerminalId[terminalId]; - if (existing && existing.lastEventAt > occurredAt) { - return state; - } - if ( - existing && - existing.identity.agentId === identity.agentId && - existing.identity.sessionId === identity.sessionId && - existing.identity.definitionId === identity.definitionId - ) { - return state; - } - return { - byTerminalId: { - ...state.byTerminalId, - [terminalId]: { identity, lastEventAt: occurredAt }, - }, - }; - }), - clearBinding: (terminalId) => - set((state) => { - if (!(terminalId in state.byTerminalId)) return state; - const next = { ...state.byTerminalId }; - delete next[terminalId]; - return { byTerminalId: next }; - }), -})); - -export function selectV2AgentBinding( - terminalId: string, -): (state: V2AgentBindingState) => V2AgentBinding | undefined { - return (state) => state.byTerminalId[terminalId]; -} diff --git a/packages/host-service/src/app.ts b/packages/host-service/src/app.ts index 41e8d331f56..607ee5f1d52 100644 --- a/packages/host-service/src/app.ts +++ b/packages/host-service/src/app.ts @@ -25,6 +25,7 @@ import { stopRemoteControlExpirySweep, } from "./terminal/remote-control/session-manager"; import { registerWorkspaceTerminalRoute } from "./terminal/terminal"; +import { TerminalAgentStore } from "./terminal-agents"; import { appRouter } from "./trpc/router"; import { execGh as defaultExecGh, @@ -136,6 +137,8 @@ export function createApp(options: CreateAppOptions): CreateAppResult { const eventBus = new EventBus({ db, filesystem, gitWatcher }); eventBus.start(); + const terminalAgentStore = new TerminalAgentStore(); + // Backfill `kind='main'` v2 workspaces for projects already set up before // this column shipped. Idempotent; runs in the background so it doesn't // block server startup. @@ -192,6 +195,7 @@ export function createApp(options: CreateAppOptions): CreateAppResult { db, runtime, eventBus, + terminalAgentStore, organizationId: config.organizationId, isAuthenticated, } as Record; diff --git a/packages/host-service/src/terminal-agents/index.ts b/packages/host-service/src/terminal-agents/index.ts new file mode 100644 index 00000000000..d1423f0dacc --- /dev/null +++ b/packages/host-service/src/terminal-agents/index.ts @@ -0,0 +1,2 @@ +export { TerminalAgentStore } from "./store"; +export type { TerminalAgentBinding, TerminalAgentId } from "./types"; diff --git a/packages/host-service/src/terminal-agents/store.test.ts b/packages/host-service/src/terminal-agents/store.test.ts new file mode 100644 index 00000000000..35c64661c9e --- /dev/null +++ b/packages/host-service/src/terminal-agents/store.test.ts @@ -0,0 +1,218 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { TerminalAgentStore } from "./store"; + +const WORKSPACE = "ws-1"; + +describe("TerminalAgentStore", () => { + let store: TerminalAgentStore; + + beforeEach(() => { + store = new TerminalAgentStore(); + }); + + it("creates a binding on first event and exposes it via get/list/findActive", () => { + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Attached", + agentId: "claude", + agentSessionId: "s1", + occurredAt: 100, + }); + + const binding = store.get("t1"); + expect(binding).toBeDefined(); + expect(binding?.terminalId).toBe("t1"); + expect(binding?.agentId).toBe("claude"); + expect(binding?.agentSessionId).toBe("s1"); + expect(binding?.startedAt).toBe(100); + expect(binding?.lastEventAt).toBe(100); + + expect(store.listByWorkspace(WORKSPACE)).toHaveLength(1); + expect(store.findActive(WORKSPACE, "claude")?.terminalId).toBe("t1"); + }); + + it("updates lastEventAt/lastEventType on intermediate events without resetting startedAt", () => { + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Attached", + agentId: "claude", + occurredAt: 100, + }); + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Start", + occurredAt: 200, + }); + + const binding = store.get("t1"); + expect(binding?.startedAt).toBe(100); + expect(binding?.lastEventAt).toBe(200); + expect(binding?.lastEventType).toBe("Start"); + expect(binding?.agentId).toBe("claude"); + }); + + it("deletes the binding on Detached/exit/error", () => { + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Attached", + agentId: "claude", + occurredAt: 100, + }); + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Detached", + occurredAt: 200, + }); + + expect(store.get("t1")).toBeUndefined(); + expect(store.listByWorkspace(WORKSPACE)).toHaveLength(0); + }); + + it("drops stale identity metadata on agent swap even when the new event omits it", () => { + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Attached", + agentId: "claude", + agentSessionId: "s1", + definitionId: "claude", + occurredAt: 100, + }); + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Attached", + agentId: "codex", + occurredAt: 200, + }); + + const binding = store.get("t1"); + expect(binding?.agentId).toBe("codex"); + expect(binding?.agentSessionId).toBeUndefined(); + expect(binding?.definitionId).toBeUndefined(); + expect(binding?.startedAt).toBe(200); + }); + + it("overwrites the binding on agent swap inside the same terminal", () => { + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Attached", + agentId: "claude", + agentSessionId: "s1", + occurredAt: 100, + }); + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Attached", + agentId: "codex", + agentSessionId: "s2", + occurredAt: 300, + }); + + const binding = store.get("t1"); + expect(binding?.agentId).toBe("codex"); + expect(binding?.agentSessionId).toBe("s2"); + expect(binding?.startedAt).toBe(300); + }); + + it("findActive tie-breaks on latest lastEventAt", () => { + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Attached", + agentId: "claude", + occurredAt: 100, + }); + store.recordEvent({ + terminalId: "t2", + workspaceId: WORKSPACE, + eventType: "Attached", + agentId: "claude", + occurredAt: 200, + }); + + expect(store.findActive(WORKSPACE, "claude")?.terminalId).toBe("t2"); + + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Start", + occurredAt: 300, + }); + expect(store.findActive(WORKSPACE, "claude")?.terminalId).toBe("t1"); + }); + + it("markTerminalExited removes the binding", () => { + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Attached", + agentId: "claude", + occurredAt: 100, + }); + store.markTerminalExited("t1"); + expect(store.get("t1")).toBeUndefined(); + }); + + it("emits 'change' with workspaceId on mutation", () => { + const events: string[] = []; + store.on("change", (workspaceId: string) => { + events.push(workspaceId); + }); + + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Attached", + agentId: "claude", + occurredAt: 100, + }); + store.markTerminalExited("t1"); + + expect(events).toEqual([WORKSPACE, WORKSPACE]); + }); + + it("filters listByWorkspace by agentId and definitionId", () => { + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Attached", + agentId: "claude", + definitionId: "claude", + occurredAt: 100, + }); + store.recordEvent({ + terminalId: "t2", + workspaceId: WORKSPACE, + eventType: "Attached", + agentId: "codex", + definitionId: "codex", + occurredAt: 200, + }); + + expect( + store.listByWorkspace(WORKSPACE, { agentId: "claude" }), + ).toHaveLength(1); + expect( + store.listByWorkspace(WORKSPACE, { definitionId: "codex" }), + ).toHaveLength(1); + expect(store.listByWorkspace("other")).toHaveLength(0); + }); + + it("ignores events with no agentId when no binding exists", () => { + store.recordEvent({ + terminalId: "t1", + workspaceId: WORKSPACE, + eventType: "Start", + occurredAt: 100, + }); + expect(store.get("t1")).toBeUndefined(); + }); +}); diff --git a/packages/host-service/src/terminal-agents/store.ts b/packages/host-service/src/terminal-agents/store.ts new file mode 100644 index 00000000000..7ec7fc30680 --- /dev/null +++ b/packages/host-service/src/terminal-agents/store.ts @@ -0,0 +1,130 @@ +import { EventEmitter } from "node:events"; +import type { AgentDefinitionId } from "@superset/shared/agent-catalog"; +import type { TerminalAgentBinding, TerminalAgentId } from "./types"; + +interface RecordEventInput { + terminalId: string; + workspaceId: string; + eventType: string; + agentId?: TerminalAgentId; + agentSessionId?: string; + definitionId?: AgentDefinitionId; + occurredAt: number; +} + +interface ListFilter { + agentId?: TerminalAgentId; + definitionId?: AgentDefinitionId; +} + +const EXIT_EVENT_TYPES = new Set(["Detached", "exit", "error"]); + +/** + * In-process tracker for which agent is alive in which terminal. Populated + * by the hook receiver, drained on terminal exit. Absence is the only + * signal — no history is retained. + * + * Emits `"change"` with the affected workspaceId after every mutation. + */ +export class TerminalAgentStore extends EventEmitter { + private readonly byTerminal = new Map(); + + recordEvent(input: RecordEventInput): void { + const { + terminalId, + workspaceId, + eventType, + agentId, + agentSessionId, + definitionId, + occurredAt, + } = input; + + if (EXIT_EVENT_TYPES.has(eventType)) { + this.deleteTerminal(terminalId); + return; + } + + const existing = this.byTerminal.get(terminalId); + if (!agentId && !existing) return; + + const nextAgentId = agentId ?? existing?.agentId; + if (!nextAgentId) return; + + // Only inherit identity metadata when agentId hasn't changed; otherwise + // a swap event that omits agentSessionId/definitionId would inherit the + // prior agent's values and corrupt definitionId-filtered reads. + const prior = + existing !== undefined && existing.agentId === nextAgentId + ? existing + : undefined; + + const sessionChanged = + prior !== undefined && + agentSessionId !== undefined && + prior.agentSessionId !== agentSessionId; + + const next: TerminalAgentBinding = { + terminalId, + workspaceId, + agentId: nextAgentId, + agentSessionId: agentSessionId ?? prior?.agentSessionId, + definitionId: definitionId ?? prior?.definitionId, + startedAt: + prior !== undefined && !sessionChanged ? prior.startedAt : occurredAt, + lastEventAt: occurredAt, + lastEventType: eventType, + }; + + this.byTerminal.set(terminalId, next); + this.emit("change", workspaceId); + } + + markTerminalExited(terminalId: string): void { + this.deleteTerminal(terminalId); + } + + get(terminalId: string): TerminalAgentBinding | undefined { + return this.byTerminal.get(terminalId); + } + + listByWorkspace( + workspaceId: string, + filter?: ListFilter, + ): TerminalAgentBinding[] { + const out: TerminalAgentBinding[] = []; + for (const binding of this.byTerminal.values()) { + if (binding.workspaceId !== workspaceId) continue; + if (filter?.agentId && binding.agentId !== filter.agentId) continue; + if (filter?.definitionId && binding.definitionId !== filter.definitionId) + continue; + out.push(binding); + } + return out; + } + + findActive( + workspaceId: string, + agentId: TerminalAgentId, + definitionId?: AgentDefinitionId, + ): TerminalAgentBinding | undefined { + let best: TerminalAgentBinding | undefined; + for (const binding of this.byTerminal.values()) { + if (binding.workspaceId !== workspaceId) continue; + if (binding.agentId !== agentId) continue; + if (definitionId !== undefined && binding.definitionId !== definitionId) + continue; + if (!best || binding.lastEventAt > best.lastEventAt) { + best = binding; + } + } + return best; + } + + private deleteTerminal(terminalId: string): void { + const existing = this.byTerminal.get(terminalId); + if (!existing) return; + this.byTerminal.delete(terminalId); + this.emit("change", existing.workspaceId); + } +} diff --git a/packages/host-service/src/terminal-agents/types.ts b/packages/host-service/src/terminal-agents/types.ts new file mode 100644 index 00000000000..576b9404c2a --- /dev/null +++ b/packages/host-service/src/terminal-agents/types.ts @@ -0,0 +1,22 @@ +import type { + AgentDefinitionId, + BuiltinAgentId, +} from "@superset/shared/agent-catalog"; + +export type TerminalAgentId = BuiltinAgentId; + +/** + * One live agent process bound to a terminal. Created on the first hook + * event we receive for the terminal, deleted when the terminal exits or + * the agent process exits. + */ +export interface TerminalAgentBinding { + terminalId: string; + workspaceId: string; + agentId: TerminalAgentId; + agentSessionId?: string; + definitionId?: AgentDefinitionId; + startedAt: number; + lastEventAt: number; + lastEventType: string; +} diff --git a/packages/host-service/src/trpc/router/notifications/notifications.test.ts b/packages/host-service/src/trpc/router/notifications/notifications.test.ts index 220f84e2efa..d121d59dd48 100644 --- a/packages/host-service/src/trpc/router/notifications/notifications.test.ts +++ b/packages/host-service/src/trpc/router/notifications/notifications.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, mock } from "bun:test"; import type { AgentIdentity } from "@superset/shared/agent-identity"; import type { AgentLifecycleEventType } from "../../../events"; +import { TerminalAgentStore } from "../../../terminal-agents"; import type { HostServiceContext } from "../../../types"; import { notificationsRouter } from "./notifications"; @@ -18,6 +19,7 @@ function createContext(originWorkspaceId: string | null): { typeof mock<(event: BroadcastedAgentLifecycleEvent) => void> >; findFirst: ReturnType; + terminalAgentStore: TerminalAgentStore; } { const broadcastAgentLifecycle = mock( (_event: BroadcastedAgentLifecycleEvent) => {}, @@ -30,6 +32,7 @@ function createContext(originWorkspaceId: string | null): { originWorkspaceId, }, })); + const terminalAgentStore = new TerminalAgentStore(); const ctx = { db: { @@ -42,9 +45,10 @@ function createContext(originWorkspaceId: string | null): { eventBus: { broadcastAgentLifecycle, }, + terminalAgentStore, } as unknown as HostServiceContext; - return { ctx, broadcastAgentLifecycle, findFirst }; + return { ctx, broadcastAgentLifecycle, findFirst, terminalAgentStore }; } describe("notificationsRouter.hook", () => { @@ -137,6 +141,22 @@ describe("notificationsRouter.hook", () => { expect(broadcast?.agent).toEqual({ agentId: "claude" }); }); + it("records the event onto the terminal agent store", async () => { + const { ctx, terminalAgentStore } = createContext("workspace-1"); + + await notificationsRouter.createCaller(ctx).hook({ + terminalId: "terminal-1", + eventType: "SessionStart", + agent: { agentId: "claude", sessionId: "session-abc" }, + }); + + const binding = terminalAgentStore.get("terminal-1"); + expect(binding?.agentId).toBe("claude"); + expect(binding?.agentSessionId).toBe("session-abc"); + expect(binding?.workspaceId).toBe("workspace-1"); + expect(binding?.lastEventType).toBe("Attached"); + }); + it("drops agent identity entirely when agentId is missing", async () => { const { ctx, broadcastAgentLifecycle } = createContext("workspace-1"); diff --git a/packages/host-service/src/trpc/router/notifications/notifications.ts b/packages/host-service/src/trpc/router/notifications/notifications.ts index 38378e0e318..09bc86443cc 100644 --- a/packages/host-service/src/trpc/router/notifications/notifications.ts +++ b/packages/host-service/src/trpc/router/notifications/notifications.ts @@ -72,13 +72,24 @@ export const notificationsRouter = router({ } const agent = normalizeAgentIdentity(input.agent); + const occurredAt = Date.now(); ctx.eventBus.broadcastAgentLifecycle({ workspaceId: terminalSession.originWorkspaceId, eventType, terminalId: input.terminalId, ...(agent ? { agent } : {}), - occurredAt: Date.now(), + occurredAt, + }); + + ctx.terminalAgentStore.recordEvent({ + terminalId: input.terminalId, + workspaceId: terminalSession.originWorkspaceId, + eventType, + ...(agent?.agentId ? { agentId: agent.agentId } : {}), + ...(agent?.sessionId ? { agentSessionId: agent.sessionId } : {}), + ...(agent?.definitionId ? { definitionId: agent.definitionId } : {}), + occurredAt, }); return { success: true, ignored: false as const }; diff --git a/packages/host-service/src/trpc/router/router.ts b/packages/host-service/src/trpc/router/router.ts index fd890b778bb..e42e0d1c7c4 100644 --- a/packages/host-service/src/trpc/router/router.ts +++ b/packages/host-service/src/trpc/router/router.ts @@ -17,6 +17,7 @@ import { projectRouter } from "./project"; import { pullRequestsRouter } from "./pull-requests"; import { settingsRouter } from "./settings"; import { terminalRouter } from "./terminal"; +import { terminalAgentsRouter } from "./terminal-agents"; import { workspaceRouter } from "./workspace"; import { workspaceCleanupRouter } from "./workspace-cleanup"; import { workspaceCreationRouter } from "./workspace-creation"; @@ -41,6 +42,7 @@ export const appRouter = router({ ports: portsRouter, settings: settingsRouter, terminal: terminalRouter, + terminalAgents: terminalAgentsRouter, workspace: workspaceRouter, workspaces: workspacesRouter, workspaceCleanup: workspaceCleanupRouter, diff --git a/packages/host-service/src/trpc/router/settings/agent-configs.test.ts b/packages/host-service/src/trpc/router/settings/agent-configs.test.ts index af53d5f7915..9a2f91bbd5d 100644 --- a/packages/host-service/src/trpc/router/settings/agent-configs.test.ts +++ b/packages/host-service/src/trpc/router/settings/agent-configs.test.ts @@ -1,7 +1,10 @@ import { Database } from "bun:sqlite"; import { describe, expect, it } from "bun:test"; import { resolve } from "node:path"; -import { getPresetById } from "@superset/shared/host-agent-presets"; +import { + getDefaultSeedPresets, + getPresetById, +} from "@superset/shared/host-agent-presets"; import { drizzle } from "drizzle-orm/bun-sqlite"; import { migrate } from "drizzle-orm/bun-sqlite/migrator"; import * as schema from "../../../db/schema"; @@ -39,7 +42,8 @@ async function listFirst( return first; } -const DEFAULT_PRESET_IDS = ["claude", "amp", "codex", "gemini", "copilot"]; +const DEFAULT_PRESET_IDS = getDefaultSeedPresets().map((p) => p.presetId); +const DEFAULT_PRESET_ORDERS = DEFAULT_PRESET_IDS.map((_, i) => i); describe("agentConfigsRouter", () => { describe("list()", () => { @@ -49,7 +53,7 @@ describe("agentConfigsRouter", () => { const result = await caller.list(); expect(result.map((row) => row.presetId)).toEqual(DEFAULT_PRESET_IDS); - expect(result.map((row) => row.order)).toEqual([0, 1, 2, 3, 4]); + expect(result.map((row) => row.order)).toEqual(DEFAULT_PRESET_ORDERS); }); it("does not seed Superset", async () => { @@ -99,7 +103,7 @@ describe("agentConfigsRouter", () => { expect(reordered.map((row) => row.presetId)).toEqual( [...DEFAULT_PRESET_IDS].reverse(), ); - expect(reordered.map((row) => row.order)).toEqual([0, 1, 2, 3, 4]); + expect(reordered.map((row) => row.order)).toEqual(DEFAULT_PRESET_ORDERS); }); }); @@ -113,10 +117,12 @@ describe("agentConfigsRouter", () => { expect(created.presetId).toBe("pi"); expect(created.command).toBe("pi"); expect(created.promptTransport).toBe("argv"); - expect(created.order).toBe(5); + expect(created.order).toBe(DEFAULT_PRESET_IDS.length); const all = await caller.list(); - expect(all).toHaveLength(6); - expect(new Set(all.map((row) => row.id)).size).toBe(6); + expect(all).toHaveLength(DEFAULT_PRESET_IDS.length + 1); + expect(new Set(all.map((row) => row.id)).size).toBe( + DEFAULT_PRESET_IDS.length + 1, + ); }); it("allows duplicate presetId tags with distinct ids", async () => { @@ -300,7 +306,7 @@ describe("agentConfigsRouter", () => { const result = await caller.reorder({ ids: reversed }); expect(result.map((row) => row.id)).toEqual(reversed); - expect(result.map((row) => row.order)).toEqual([0, 1, 2, 3, 4]); + expect(result.map((row) => row.order)).toEqual(DEFAULT_PRESET_ORDERS); }); it("rejects when ids do not match existing configs", async () => { @@ -337,7 +343,9 @@ describe("agentConfigsRouter", () => { expect(result.map((row) => row.presetId)).toEqual(DEFAULT_PRESET_IDS); expect(result.find((row) => row.label === "Renamed")).toBeUndefined(); - expect(result.find((row) => row.presetId === "pi")).toBeUndefined(); + // `pi` is in defaults now, so reset re-seeds exactly one — the + // extra row added above is dropped. + expect(result.filter((row) => row.presetId === "pi")).toHaveLength(1); }); }); }); diff --git a/packages/host-service/src/trpc/router/terminal-agents/index.ts b/packages/host-service/src/trpc/router/terminal-agents/index.ts new file mode 100644 index 00000000000..a98aa6168c6 --- /dev/null +++ b/packages/host-service/src/trpc/router/terminal-agents/index.ts @@ -0,0 +1 @@ +export { terminalAgentsRouter } from "./terminal-agents"; diff --git a/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts b/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts new file mode 100644 index 00000000000..440a9f41263 --- /dev/null +++ b/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts @@ -0,0 +1,249 @@ +import { + type AgentDefinitionId, + BUILTIN_AGENT_IDS, +} from "@superset/shared/agent-catalog"; +import { TRPCError } from "@trpc/server"; +import { observable } from "@trpc/server/observable"; +import { z } from "zod"; +import { + createTerminalSessionInternal, + disposeSessionAndWait, +} from "../../../terminal/terminal"; +import type { + TerminalAgentBinding, + TerminalAgentId, +} from "../../../terminal-agents"; +import { protectedProcedure, router } from "../../index"; + +type GetOrCreateResult = { + binding: TerminalAgentBinding; + created: boolean; +}; + +const inflight = new Map>(); + +function inflightKey( + workspaceId: string, + agentId: TerminalAgentId, + definitionId: AgentDefinitionId | undefined, +): string { + return `${workspaceId}::${agentId}::${definitionId ?? ""}`; +} + +const terminalAgentIdSchema = z.enum(BUILTIN_AGENT_IDS); +const agentDefinitionIdSchema = z.union([ + z.enum(BUILTIN_AGENT_IDS), + z.string().regex(/^custom:.+$/, "must be a builtin id or `custom:`"), +]) as z.ZodType; + +const GET_OR_CREATE_TIMEOUT_MS = 10_000; + +export const terminalAgentsRouter = router({ + listByWorkspace: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + agentId: terminalAgentIdSchema.optional(), + definitionId: agentDefinitionIdSchema.optional(), + }), + ) + .query(({ ctx, input }) => { + const { workspaceId, agentId, definitionId } = input; + return ctx.terminalAgentStore.listByWorkspace(workspaceId, { + ...(agentId ? { agentId } : {}), + ...(definitionId ? { definitionId } : {}), + }); + }), + + findActive: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + agentId: terminalAgentIdSchema, + definitionId: agentDefinitionIdSchema.optional(), + }), + ) + .query(({ ctx, input }) => { + return ( + ctx.terminalAgentStore.findActive( + input.workspaceId, + input.agentId, + input.definitionId, + ) ?? null + ); + }), + + /** + * Reuse-or-launch primitive. Returns an existing active binding for the + * `(workspaceId, agentId, definitionId)` triple, or spawns a fresh + * terminal and waits up to 10s for the agent's hook to register. + * + * Resolves on the first lifecycle hook — not on REPL prompt-readiness. + * Callers that need to `terminal.writeInput` immediately should add + * their own readiness wait. Input formatting also lives in the caller. + */ + getOrCreate: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + agentId: terminalAgentIdSchema, + definitionId: agentDefinitionIdSchema.optional(), + initialCommand: z.string().trim().min(1).optional(), + cwd: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { workspaceId, agentId, definitionId } = input; + const existing = ctx.terminalAgentStore.findActive( + workspaceId, + agentId, + definitionId, + ); + if (existing) { + return { binding: existing, created: false }; + } + + // Coalesce concurrent callers so the same triple doesn't spawn twice. + const key = inflightKey(workspaceId, agentId, definitionId); + const pending = inflight.get(key); + if (pending) return pending; + + const promise = (async (): Promise => { + const terminalId = crypto.randomUUID(); + const created = await createTerminalSessionInternal({ + terminalId, + workspaceId, + db: ctx.db, + eventBus: ctx.eventBus, + ...(input.initialCommand + ? { initialCommand: input.initialCommand } + : {}), + ...(input.cwd ? { cwd: input.cwd } : {}), + }); + + if ("error" in created) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: created.error, + }); + } + + try { + const binding = await waitForBinding({ + store: ctx.terminalAgentStore, + workspaceId, + agentId, + definitionId, + terminalId: created.terminalId, + timeoutMs: GET_OR_CREATE_TIMEOUT_MS, + }); + return { binding, created: true }; + } catch (err) { + // Hook never landed — tear down the orphaned pty so retries + // don't pile up zombies. + await disposeSessionAndWait(created.terminalId, ctx.db).catch( + (cleanupError) => { + console.warn( + "[terminal-agents] failed to dispose timed-out terminal", + { terminalId: created.terminalId, cleanupError }, + ); + }, + ); + throw err; + } + })(); + + inflight.set(key, promise); + try { + return await promise; + } finally { + inflight.delete(key); + } + }), + + /** + * Snapshot-then-deltas stream of bindings for a workspace. For host-side + * consumers; the renderer reads via `listByWorkspace` since its tRPC + * client is httpLink-only. + */ + onWorkspaceChange: protectedProcedure + .input(z.object({ workspaceId: z.string() })) + .subscription(({ ctx, input }) => { + return observable<{ + kind: "snapshot" | "change"; + bindings: TerminalAgentBinding[]; + }>((emit) => { + const snapshot = () => ({ + bindings: ctx.terminalAgentStore.listByWorkspace(input.workspaceId), + }); + emit.next({ kind: "snapshot", ...snapshot() }); + + const handler = (workspaceId: string) => { + if (workspaceId !== input.workspaceId) return; + emit.next({ kind: "change", ...snapshot() }); + }; + ctx.terminalAgentStore.on("change", handler); + return () => { + ctx.terminalAgentStore.off("change", handler); + }; + }); + }), +}); + +interface WaitForBindingArgs { + store: import("../../../terminal-agents").TerminalAgentStore; + workspaceId: string; + agentId: TerminalAgentId; + definitionId?: AgentDefinitionId; + terminalId: string; + timeoutMs: number; +} + +function waitForBinding({ + store, + workspaceId, + agentId, + definitionId, + terminalId, + timeoutMs, +}: WaitForBindingArgs): Promise { + return new Promise((resolve, reject) => { + const match = (): TerminalAgentBinding | undefined => { + const binding = store.get(terminalId); + if (!binding) return undefined; + if (binding.workspaceId !== workspaceId) return undefined; + if (binding.agentId !== agentId) return undefined; + if (definitionId !== undefined && binding.definitionId !== definitionId) + return undefined; + return binding; + }; + + const immediate = match(); + if (immediate) { + resolve(immediate); + return; + } + + const onChange = () => { + const hit = match(); + if (!hit) return; + cleanup(); + resolve(hit); + }; + const cleanup = () => { + clearTimeout(timer); + store.off("change", onChange); + }; + const timer = setTimeout(() => { + cleanup(); + reject( + new TRPCError({ + code: "TIMEOUT", + message: `Timed out after ${timeoutMs}ms waiting for ${agentId} to attach to ${terminalId}`, + }), + ); + }, timeoutMs); + + store.on("change", onChange); + }); +} diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index 45eccd3175c..8023530ab1d 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -192,6 +192,7 @@ export const terminalRouter = router({ } await disposeSessionAndWait(input.terminalId, ctx.db); + ctx.terminalAgentStore.markTerminalExited(input.terminalId); return { terminalId: input.terminalId, status: "disposed" as const }; }), diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index 6a054eb2a0c..243662d0328 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -8,6 +8,7 @@ import type { ChatRuntimeManager } from "./runtime/chat"; import type { WorkspaceFilesystemManager } from "./runtime/filesystem"; import type { GitFactory } from "./runtime/git"; import type { PullRequestRuntimeManager } from "./runtime/pull-requests"; +import type { TerminalAgentStore } from "./terminal-agents"; import type { ExecGh } from "./trpc/router/workspace-creation/utils/exec-gh"; export type ApiClient = TRPCClient; @@ -27,6 +28,7 @@ export interface HostServiceContext { db: HostDb; runtime: HostServiceRuntime; eventBus: EventBus; + terminalAgentStore: TerminalAgentStore; organizationId: string; isAuthenticated: boolean; } diff --git a/packages/shared/src/agent-identity.ts b/packages/shared/src/agent-identity.ts index 5156abde871..948469d6c96 100644 --- a/packages/shared/src/agent-identity.ts +++ b/packages/shared/src/agent-identity.ts @@ -6,14 +6,13 @@ import type { AgentDefinitionId, BuiltinAgentId } from "./agent-catalog"; * Reported by the in-shell `notify-hook.sh` script, broadcast over the * host-service event bus, and stored in renderer state keyed by terminalId. * - * `agentId` is the wrapper-level id. Most values match `BuiltinAgentId` and - * `PRESET_ICONS`; `droid` is managed by desktop setup but is not currently a - * built-in terminal preset. `definitionId` is the user-customized id when the - * launch path stamps it; it's reserved for a future PR — wrappers can't - * distinguish user definitions on their own. + * `agentId` is the wrapper-level id and matches `BuiltinAgentId` / + * `PRESET_ICONS`. `definitionId` is the user-customized id when the launch + * path stamps it; it's reserved for a future PR — wrappers can't distinguish + * user definitions on their own. */ export interface AgentIdentity { - agentId: BuiltinAgentId | "droid"; + agentId: BuiltinAgentId; sessionId?: string; definitionId?: AgentDefinitionId; } diff --git a/packages/shared/src/builtin-terminal-agents.ts b/packages/shared/src/builtin-terminal-agents.ts index 8eee237f876..01fb1ab489e 100644 --- a/packages/shared/src/builtin-terminal-agents.ts +++ b/packages/shared/src/builtin-terminal-agents.ts @@ -62,7 +62,7 @@ export const BUILTIN_TERMINAL_AGENTS = [ label: "Claude", description: "Anthropic's coding agent for reading code, editing files, and running terminal workflows.", - command: "claude --permission-mode acceptEdits", + command: "claude --dangerously-skip-permissions", includeInDefaultTerminalPresets: true, }), createBuiltinTerminalAgent({ @@ -131,6 +131,12 @@ export const BUILTIN_TERMINAL_AGENTS = [ "Cursor's coding agent for editing, running, and debugging code in parallel.", command: "cursor-agent", }), + createBuiltinTerminalAgent({ + id: "droid", + label: "Droid", + description: "Factory's autonomous coding agent for terminal workflows.", + command: "droid", + }), ] as const; export type BuiltinTerminalAgentType = diff --git a/packages/shared/src/host-agent-presets.ts b/packages/shared/src/host-agent-presets.ts index a76a27a555f..805eb76e492 100644 --- a/packages/shared/src/host-agent-presets.ts +++ b/packages/shared/src/host-agent-presets.ts @@ -1,4 +1,5 @@ import type { PromptTransport } from "./agent-prompt-launch"; +import { BUILTIN_TERMINAL_AGENTS } from "./builtin-terminal-agents"; export interface HostAgentPreset { presetId: string; @@ -11,152 +12,62 @@ export interface HostAgentPreset { env: Record; } +function tokenize(commandString: string): string[] { + return commandString.split(/\s+/).filter(Boolean); +} + +function derivePromptArgs( + commandTokens: string[], + promptCommand: string | undefined, +): string[] { + if (!promptCommand) return []; + // promptCommand includes the base command; strip the shared prefix to + // get just the prompt-only args (e.g. "codex --flag --" → ["--"]). + return tokenize(promptCommand).slice(commandTokens.length); +} + /** - * Hardcoded terminal agent presets. Used as the seed list when a host's - * agent table is empty, and as the install catalog the desktop picker - * renders. Lives here (not on the host service) because it's static - * configuration that ships with the binary, not data the API owns. + * Terminal agent presets derived from `BUILTIN_TERMINAL_AGENTS`. Used as + * the seed list when a host's agent table is empty and as the install + * catalog the desktop picker renders. * * Launch resolution: * prompt * ? [command, ...args, ...promptArgs, ...(promptTransport === "argv" ? [prompt] : [])] * : [command, ...args] * - * `promptArgs` is only included when launching with a prompt — codex's - * trailing `--`, opencode's `--prompt`, and copilot's `-i` therefore do - * not appear in promptless launches. Stdin transport pipes the prompt to - * the spawned process's stdin instead of pushing it to argv. - * - * Superset is intentionally excluded — its model/provider config - * lives in chat settings, not in terminal-agent configs. + * Stdin transport pipes the prompt to stdin instead of pushing it to argv. */ -export const HOST_AGENT_PRESETS = [ - { - presetId: "claude", - label: "Claude", - description: - "Anthropic's coding agent for reading code, editing files, and running terminal workflows.", - command: "claude", - args: ["--dangerously-skip-permissions"], - promptTransport: "argv", - promptArgs: [], - env: {}, - }, - { - presetId: "amp", - label: "Amp", - description: - "Amp's coding agent for terminal-first coding, subagents, and task work.", - command: "amp", - args: [], - promptTransport: "stdin", - promptArgs: [], - env: {}, - }, - { - presetId: "codex", - label: "Codex", - description: - "OpenAI's coding agent for reading, modifying, and running code across tasks.", - command: "codex", - args: ["--dangerously-bypass-approvals-and-sandbox"], - promptTransport: "argv", - promptArgs: ["--"], - env: {}, - }, - { - presetId: "gemini", - label: "Gemini", - description: - "Google's open-source terminal agent for coding, problem-solving, and task work.", - command: "gemini", - args: ["--approval-mode=auto_edit"], - promptTransport: "argv", - promptArgs: [], - env: {}, - }, - { - presetId: "mastracode", - label: "Mastracode", - description: - "Mastra's coding agent for building, debugging, and shipping code from the terminal.", - command: "mastracode", - args: [], - promptTransport: "argv", - promptArgs: ["--prompt"], - env: {}, - }, - { - presetId: "opencode", - label: "OpenCode", - description: "Open-source coding agent for the terminal, IDE, and desktop.", - command: "opencode", - args: [], - promptTransport: "argv", - promptArgs: ["--prompt"], - env: {}, - }, - { - presetId: "pi", - label: "Pi", - description: - "Minimal terminal coding harness for flexible coding workflows.", - command: "pi", - args: [], - promptTransport: "argv", - promptArgs: [], - env: {}, - }, - { - presetId: "copilot", - label: "Copilot", - description: - "GitHub's coding agent for planning, editing, and building in your repo.", - command: "copilot", - args: ["--allow-tool=write"], - promptTransport: "argv", - promptArgs: ["-i"], - env: {}, - }, - { - presetId: "cursor-agent", - label: "Cursor Agent", - description: - "Cursor's coding agent for editing, running, and debugging code in parallel.", - command: "cursor-agent", - args: [], - promptTransport: "argv", - promptArgs: [], - env: {}, - }, -] as const satisfies readonly HostAgentPreset[]; +export const HOST_AGENT_PRESETS: readonly HostAgentPreset[] = + BUILTIN_TERMINAL_AGENTS.map((agent) => { + const commandTokens = tokenize(agent.command); + const [bin = agent.id, ...args] = commandTokens; + return { + presetId: agent.id, + label: agent.label, + description: agent.description, + command: bin, + args, + promptTransport: agent.promptTransport ?? "argv", + promptArgs: derivePromptArgs(commandTokens, agent.promptCommand), + env: {}, + }; + }); -const DEFAULT_PRESET_IDS = new Set([ - "claude", - "amp", - "codex", - "gemini", - "copilot", -]); - -export function getDefaultSeedPresets(): HostAgentPreset[] { - return HOST_AGENT_PRESETS.filter((preset) => - DEFAULT_PRESET_IDS.has(preset.presetId), - ).map((preset) => ({ +function clonePreset(preset: HostAgentPreset): HostAgentPreset { + return { ...preset, args: [...preset.args], promptArgs: [...preset.promptArgs], env: { ...preset.env }, - })); + }; +} + +export function getDefaultSeedPresets(): HostAgentPreset[] { + return HOST_AGENT_PRESETS.map(clonePreset); } export function getPresetById(presetId: string): HostAgentPreset | undefined { const preset = HOST_AGENT_PRESETS.find((item) => item.presetId === presetId); - if (!preset) return undefined; - return { - ...preset, - args: [...preset.args], - promptArgs: [...preset.promptArgs], - env: { ...preset.env }, - }; + return preset ? clonePreset(preset) : undefined; } diff --git a/packages/ui/src/assets/icons/preset-icons/droid-white.svg b/packages/ui/src/assets/icons/preset-icons/droid-white.svg new file mode 100644 index 00000000000..45adbc81a2f --- /dev/null +++ b/packages/ui/src/assets/icons/preset-icons/droid-white.svg @@ -0,0 +1 @@ + diff --git a/packages/ui/src/assets/icons/preset-icons/droid.svg b/packages/ui/src/assets/icons/preset-icons/droid.svg new file mode 100644 index 00000000000..c92b13fa97a --- /dev/null +++ b/packages/ui/src/assets/icons/preset-icons/droid.svg @@ -0,0 +1 @@ + diff --git a/packages/ui/src/assets/icons/preset-icons/index.ts b/packages/ui/src/assets/icons/preset-icons/index.ts index a34d2db5f41..4c9489096f6 100644 --- a/packages/ui/src/assets/icons/preset-icons/index.ts +++ b/packages/ui/src/assets/icons/preset-icons/index.ts @@ -5,6 +5,8 @@ import codexWhiteIcon from "./codex-white.svg"; import copilotIcon from "./copilot.svg"; import copilotWhiteIcon from "./copilot-white.svg"; import cursorAgentIcon from "./cursor.svg"; +import droidIcon from "./droid.svg"; +import droidWhiteIcon from "./droid-white.svg"; import geminiIcon from "./gemini.svg"; import mastracodeIcon from "./mastracode.svg"; import mastracodeWhiteIcon from "./mastracode-white.svg"; @@ -28,6 +30,7 @@ export const PRESET_ICONS: Record = { pi: { light: piIcon, dark: piWhiteIcon }, superset: { light: supersetIcon, dark: supersetIcon }, "cursor-agent": { light: cursorAgentIcon, dark: cursorAgentIcon }, + droid: { light: droidIcon, dark: droidWhiteIcon }, mastracode: { light: mastracodeIcon, dark: mastracodeWhiteIcon }, opencode: { light: opencodeIcon, dark: opencodeWhiteIcon }, }; @@ -50,6 +53,8 @@ export { copilotIcon, copilotWhiteIcon, cursorAgentIcon, + droidIcon, + droidWhiteIcon, geminiIcon, mastracodeIcon, mastracodeWhiteIcon, diff --git a/plans/done/20260523-agent-session-tracking.md b/plans/done/20260523-agent-session-tracking.md new file mode 100644 index 00000000000..d5ce7459886 --- /dev/null +++ b/plans/done/20260523-agent-session-tracking.md @@ -0,0 +1,186 @@ +# terminalAgents (host-service module) + +Branch: `agent-session-tracking` + +## Scope + +In-process tracker on host-service for which agent (claude/codex/cursor/opencode/droid/custom) is currently alive in which terminal. No consumers wired yet — this PR builds the module and the tRPC surface only. Consumers (renderer "send another message" button, automation reuse) come later. + +## Decisions + +| # | Decision | Choice | +|---|---|---| +| 1 | Storage | In-mem `Map`. No SQLite, no migration. | +| 2 | Granularity | One binding per `terminalId`. Agent swap overwrites. | +| 3 | Exit | Delete on exit. Absence is the only signal. | +| 4 | Ambiguous lookup | Tie-break by latest `lastEventAt`. | +| 5 | API shape | Primitives only: `findActive` + `getOrCreate`. Callers compose with existing `terminal.writeInput`. | +| 6 | Name | `terminalAgents`. | + +## What exists (do not touch) + +- `notifications.hook` (`packages/host-service/src/trpc/router/notifications/notifications.ts:54`) — normalizes hook POST, broadcasts `agent:lifecycle`. This PR adds a sibling call to `store.recordEvent`. +- Terminal-exit path (`packages/host-service/src/trpc/router/terminal/terminal.ts:157`, `disposeSessionAndWait`) — this PR adds a sibling call to `store.markTerminalExited`. +- `terminal.createSession` (`packages/host-service/src/trpc/router/terminal/terminal.ts:98`) — used by `getOrCreate`. +- `terminal.writeInput` (`packages/host-service/src/trpc/router/terminal/terminal.ts:138`) — not called by this module; callers use it directly. + +## Surface + +```ts +// packages/host-service/src/terminal-agents/types.ts +export interface TerminalAgentBinding { + terminalId: string; + workspaceId: string; + agentId: BuiltinAgentId | "droid"; + agentSessionId?: string; + definitionId?: AgentDefinitionId; + startedAt: number; + lastEventAt: number; + lastEventType: string; +} +``` + +```ts +// packages/host-service/src/terminal-agents/store.ts +export class TerminalAgentStore extends EventEmitter { + // Write — called by hook receiver and terminal-exit path. + recordEvent(input: { + terminalId: string; + workspaceId: string; + eventType: string; + agentId?: BuiltinAgentId | "droid"; + agentSessionId?: string; + definitionId?: AgentDefinitionId; + occurredAt: number; + }): void; + markTerminalExited(terminalId: string): void; + + // Read — called by tRPC router. + get(terminalId: string): TerminalAgentBinding | undefined; + listByWorkspace(workspaceId: string, filter?: { + agentId?: BuiltinAgentId | "droid"; + definitionId?: AgentDefinitionId; + }): TerminalAgentBinding[]; + findActive( + workspaceId: string, + agentId: BuiltinAgentId | "droid", + definitionId?: AgentDefinitionId, + ): TerminalAgentBinding | undefined; // tie-break: latest lastEventAt + + // Subscribe — emits "change", workspaceId after every mutation. +} +``` + +```ts +// packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts +terminalAgents.listByWorkspace({ workspaceId, agentId?, definitionId? }) + → TerminalAgentBinding[] + +terminalAgents.findActive({ workspaceId, agentId, definitionId? }) + → TerminalAgentBinding | null + +terminalAgents.getOrCreate({ + workspaceId, + agentId, + definitionId?, + // launch params used only if no active binding matches: + initialCommand?: string, + cwd?: string, +}) → { binding: TerminalAgentBinding, created: boolean } + // Reuses findActive; otherwise calls existing terminal.createSession and + // returns once the row appears (or after a 10s timeout). + +terminalAgents.onWorkspaceChange({ workspaceId }) + → observable<{ kind: "snapshot" | "change", bindings: TerminalAgentBinding[] }> + // observable (not async generator) per apps/desktop/AGENTS.md +``` + +Module also exports the bare `TerminalAgentStore` and `getOrCreate` helper for host-side callers (future automation) to use without a tRPC round-trip. + +## Behavior + +`recordEvent`: +- `start` / `attach` → upsert binding, set `startedAt` if new, update `lastEventAt`/`lastEventType`. If existing binding has a different `agentId` or `agentSessionId`, overwrite (decision #2). +- intermediate (`tool_use`, `awaiting_input`, …) → update `lastEventAt` + `lastEventType` only. +- `exit` / `error` → delete the binding (decision #3). +- Event-type mapping reuses existing `mapEventType` from `packages/host-service/src/events`. + +`markTerminalExited(terminalId)` → delete the binding if present. + +`getOrCreate`: +1. `findActive(workspaceId, agentId, definitionId)` — return if hit, `created: false`. +2. Else, call `terminal.createSession` (existing) with `initialCommand` and `cwd`. +3. Wait for `store.emit("change", workspaceId)` until a binding matching `(workspaceId, agentId, definitionId, terminalId === newTerminalId)` appears. Timeout: 10s → throw typed `AgentStartTimeout`. +4. Return `{ binding, created: true }`. + +## Wire-points + +- `notifications.hook` — after the existing `broadcastAgentLifecycle`, also call `ctx.terminalAgentStore.recordEvent(...)` with the same fields. Same trigger, same payload shape. +- Terminal-exit path — wherever `disposeSessionAndWait` finalizes, call `ctx.terminalAgentStore.markTerminalExited(terminalId)`. One line. +- tRPC root — register `terminalAgents` router. +- Store instantiation — single instance on `ctx`, alongside `eventBus`. + +## Edge cases + +- **Hook never fires** — no binding appears; `findActive` returns null and `getOrCreate` falls through to create. +- **Host-service restart** — Map empties. Active agents reappear on their next event; idle agents stay unknown until they emit. Accepted. +- **Agent swap inside same pty** (claude `/exit` → codex) — second `start` event overwrites the binding's `agentId`/`agentSessionId`/`startedAt`. Old identity is gone (decision #3 — absence is the signal). +- **Multiple matches for `findActive`** — tie-break by latest `lastEventAt` (decision #4). +- **Two agents in same pty** (tmux split) — out of scope; one foreground agent per terminal. +- **Cross-machine** — out of scope; host-service-local. + +## Files + +New: +- `packages/host-service/src/terminal-agents/store.ts` +- `packages/host-service/src/terminal-agents/store.test.ts` +- `packages/host-service/src/terminal-agents/types.ts` +- `packages/host-service/src/terminal-agents/index.ts` +- `packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts` +- `packages/host-service/src/trpc/router/terminal-agents/terminal-agents.test.ts` +- `packages/host-service/src/trpc/router/terminal-agents/index.ts` + +Touched: +- `packages/host-service/src/trpc/router/notifications/notifications.ts` — one call after `broadcastAgentLifecycle`. +- `packages/host-service/src/trpc/router/terminal/terminal.ts` — one call in the exit path. +- tRPC root router file — register `terminalAgents`. +- ctx factory — instantiate the singleton store. + +## Tests + +Store unit tests: +- start → binding visible to `get` / `listByWorkspace` / `findActive`. +- intermediate event updates `lastEventAt` / `lastEventType` only. +- exit → binding gone. +- agent swap overwrites in place. +- `findActive` tie-break picks latest `lastEventAt`. +- `markTerminalExited` removes binding. + +Router integration tests: +- `notifications.hook` → row appears in `listByWorkspace`. +- terminal exit → row removed. +- `getOrCreate` reuse path returns existing without spawning. +- `getOrCreate` miss path calls `terminal.createSession` and resolves when the binding appears. +- `getOrCreate` miss path times out cleanly when no hook fires. +- `onWorkspaceChange` emits snapshot then deltas. + +## Out of scope + +- Automation rewire (`runTerminalAgent` keeps always-create for now). +- SQLite persistence. +- History / audit of past bindings. +- Per-agent `supportsReuse` flag (decide when first reuse-consumer ships). +- Submit-sequence map (callers write whatever terminator they want; this module doesn't format input). + +## For the next consumer ("send message to active agent") + +When wiring the first reuse consumer, expect to make these calls. Update this list as decisions land. + +1. **Input formatting** — `terminal.writeInput` is raw bytes. Each agent has its own submit sequence (claude: text + `\r`; codex/cursor/opencode may differ; some need a leading clear). First consumer ships per-agent formatting; second consumer = extract to `formatAgentInput(agentId, text)`. Decide whether it lives in `terminal-agents/` or `@superset/shared/agent-catalog`. +2. **Readiness vs. attached** — `getOrCreate` resolves on the first lifecycle hook (`Attached`/`SessionStart`), not on prompt-readiness. Sending input the next tick can race the REPL. Either confirm the hook fires post-ready or add a "ready" event type and a second wait. +3. **Busy/idle signal** — `lastEventType` is recorded but consumers don't know the catalog. If "send message" should queue or refuse while the agent is mid-turn, add a derived `state: "idle" | "working" | "awaiting_input"` on `TerminalAgentBinding` (or an `isIdle` helper) rather than re-deriving in each caller. + +Already handled in this PR (no action needed): + +- Concurrent `getOrCreate` for the same `(workspaceId, agentId, definitionId)` is coalesced via an in-flight map — second caller awaits the first. +- `getOrCreate` timeout disposes the spawned terminal so retries don't leak ptys.