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 @@
-
\ 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 @@
-
\ 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.