+ Build internal tools that feel closer to native than web.
+
+
+ This pane is the one currently targeted by the mock
+ automation binding. The connect action lives in the browser
+ toolbar, so switching ownership is a single click and does
+ not require leaving the page context.
+
+
+
+
+
42%
+
Conversion lift
+
+
+
8.6m
+
Median setup time
+
+
+
3 tabs
+
Automation targets
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Connect browser automation
+ Choose which running LLM session should control this browser pane.
+
+
+
+
+
+
+
+
+
◎
+
+
Pricing QA
+
+ app.superset.sh/pricing
+
+
+
+
+
Running sessions
+
+
+
+
+
Session 14
+
Codex · feature/browser-cdp-map
+
+ Ready
+
+
+ Terminal
+ Bound to workspace
+ Last prompt 20s ago
+
+
+ Browser MCP is already configured. Connecting this session should
+ be immediate and should update the toolbar badge without any extra
+ setup flow.
+
+
+
+
+
+
+
Session 11
+
Claude · checkout-debug
+
+ Needs MCP
+
+
+ Terminal
+ Focus in current tab
+ Last prompt 2m ago
+
+
+ Good candidate, but this session does not currently expose the
+ required browser automation MCP entry. The right column explains
+ what needs to be added.
+
+
+
+
+
+
+
Session 09
+
Codex · release-notes-draft
+
+ No browser context
+
+
+ Chat
+ Different tab
+
+
+ Allowed, but probably not ideal. This shows the generic
+ “pick any LLM session later” model you described, without
+ splitting the first action by provider.
+
+
+
+
+
+
+
+
Selected session is ready to connect
+
+ This session already has the browser automation MCP entry, so
+ the connect action can immediately bind the pane to this owner.
+
+
+
+ Owner
+ Session 14 / Codex
+
+
+ Binding mode
+ Exclusive control
+
+
+ Pane
+ Pricing QA
+
+
+ Expected UI result
+ Toolbar badge updates instantly
+
+
+
+
+
+
What the user would feel
+
+ The main UX stays local to the pane. You click Connect, choose
+ a session, and the pane shows ownership immediately. The list
+ view on the right updates in parallel.
+
+
+
+
+
+
+
This session needs browser MCP setup
+
+ The connect action should not fail silently. If the selected
+ LLM session does not have the required MCP entry, the same
+ dialog should explain how to add it before allowing connect.
+
+
+
Open the agent config used by this session.
+
Add the managed superset-browser MCP server.
+
Reload the session or start a new one from this workspace.
+
+
+
+
+
+
+
+
+
+
Suggested copy
+
+ “Session 11 is missing the browser automation MCP. Add the
+ server below, then reopen or restart the session to connect
+ this pane.”
+
@@ -170,7 +213,11 @@ export function SessionConnectModal({
) : (
markSessionReady(session.id)}
+ mcpConfigPath={
+ session.provider === "Codex"
+ ? (mcpStatus?.codexConfigPath ?? null)
+ : (mcpStatus?.claudeConfigPath ?? null)
+ }
onCopy={handleCopySnippet}
/>
)
@@ -188,11 +235,20 @@ export function SessionConnectModal({
? assignedPaneIdForSelected
? `Connecting will reassign ${session.displayName} from ${panes[assignedPaneIdForSelected]?.name ?? "another pane"} to ${paneName}.`
: "Connecting binds this browser pane to the selected session only."
- : "Add the MCP entry first, then reopen or restart this session."}
+ : session
+ ? "Add the MCP entry first, then reopen or restart this session."
+ : sessions.length === 0
+ ? "Start an LLM session, then pick it here."
+ : "Select a session from the left."}
{currentSession && (
-
+
Disconnect {currentSession.displayName}
)}
@@ -205,7 +261,11 @@ export function SessionConnectModal({
{session?.mcpStatus === "ready"
@@ -248,7 +308,9 @@ function SessionCard({
? `${session.displayName} is currently controlling ${assignedElsewherePaneName}. Connecting here moves ownership.`
: session.mcpStatus === "ready"
? "Browser MCP is configured. Connect will be immediate."
- : "This session does not currently expose the required browser automation MCP entry.";
+ : session.mcpStatus === "missing"
+ ? "This session does not currently expose the required browser automation MCP entry."
+ : "Could not verify MCP status for this session.";
return (
{session.kind}{session.branchOrContextLabel}
- Last prompt {session.lastActiveAt}
+ Last active {session.lastActiveAt}
The connect action will not fail silently. Add the{" "}
superset-browser MCP
- server to this session and reload.
+ server to {session.provider}, then reload this session.
-
Open the agent config used by {session.displayName}.
+ Append the superset-browser MCP server block below.
+
+
+ Restart {session.displayName} (or run the agent again) so the new
+ entry is picked up.
-
Reload the session or start a new one from this workspace.
{snippet}
-
- Simulate MCP added
-
Copy snippet
+
+ MCP readiness is detected by inspecting the config file for the string{" "}
+ superset-browser. If you prefer a managed location, the
+ desktop app also ships the server at packages/desktop-mcp
+ .
+
- {currentSession && (
+ {currentBinding && (
- Disconnect {currentSession.displayName}
+ Disconnect
+ {currentSession ? ` ${currentSession.displayName}` : ""}
)}
Date: Mon, 20 Apr 2026 04:26:11 +0900
Subject: [PATCH 05/22] fix(desktop): restrict session list to live states and
wire subscription
- useBrowserAutomationData: only include sessions with live status
(running / preparing / verifying / waiting). queued / paused / terminal
are now filtered out so users cannot bind a pane to a dormant worker. (P2)
- onBindingsChanged: invalidate listBindings so other windows see updates
without waiting for the next poll. (P3)
---
.../hooks/useBrowserAutomationData.ts | 19 +++++++++++--------
1 file changed, 11 insertions(+), 8 deletions(-)
diff --git a/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts
index 5dd59172cef..917a012af6f 100644
--- a/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts
+++ b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts
@@ -28,22 +28,25 @@ export function useBrowserAutomationData() {
electronTrpc.browserAutomation.listBindings.useQuery(undefined, {
refetchInterval: 1000,
});
+ const utils = electronTrpc.useUtils();
electronTrpc.browserAutomation.onBindingsChanged.useSubscription(undefined, {
onData: () => {
- // The query above drives rendering; the subscription exists so the
- // query invalidates reactively when another window mutates state.
+ utils.browserAutomation.listBindings.invalidate();
},
});
const sessions: AutomationSession[] = useMemo(() => {
- const terminalStatuses = new Set([
- "done",
- "failed",
- "aborted",
- "escalated",
+ // Only sessions that have a live worker (or are actively scheduled to
+ // wake up) should be connectable. Queued/paused/aborted/done/failed/
+ // escalated sessions either never started or are terminal.
+ const liveStatuses = new Set([
+ "running",
+ "preparing",
+ "verifying",
+ "waiting",
]);
return todoSessions
- .filter((s) => !terminalStatuses.has(s.status))
+ .filter((s) => liveStatuses.has(s.status))
.map((s) => {
// Todo-agent rows always represent Claude Code workers (see
// todo-daemon/claude-code-runner.ts). We label them as Claude
From 3a2605a11855cfa3eca9cd885f429e99b54d0e37 Mon Sep 17 00:00:00 2001
From: MocA-Love <64681295+MocA-Love@users.noreply.github.com>
Date: Mon, 20 Apr 2026 04:46:31 +0900
Subject: [PATCH 06/22] fix(desktop): handle stale selection and EventEmitter
listener cap
- BindingStore: call setMaxListeners(0) so many browser panes don't trip
Node's default 10-listener warning. (coderabbit Minor)
- SessionConnectModal: clear stale selectedSessionId when the session drops
out of the live list so the right-column detail view is never empty with
a truthy selection. (coderabbit Minor)
---
.../src/lib/trpc/routers/browser-automation/index.ts | 6 ++++++
.../SessionConnectModal/SessionConnectModal.tsx | 10 +++++++---
apps/desktop/src/renderer/stores/browser-automation.ts | 2 +-
3 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts
index 141bb1c5e79..a0c09aefb71 100644
--- a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts
@@ -28,6 +28,12 @@ class BindingStore {
private readonly byPane = new Map();
private readonly emitter = new EventEmitter();
+ constructor() {
+ // One subscription per renderer hook instance; a workspace with many
+ // open panes can blow past Node's 10-listener default otherwise.
+ this.emitter.setMaxListeners(0);
+ }
+
list(): BrowserAutomationBinding[] {
return Array.from(this.byPane.values());
}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx
index f3896e14375..491d5d1f08a 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx
@@ -56,12 +56,16 @@ export function SessionConnectModal({
? (sessions.find((s) => s.id === currentBinding) ?? null)
: null;
- // Auto-select a sensible default when the modal opens with nothing picked.
+ // Auto-select a sensible default when the modal opens with nothing picked,
+ // and reset the selection when the chosen session drops out of the live
+ // set (e.g. it transitioned to `done` / `aborted`).
useEffect(() => {
if (!open || !paneId) return;
- if (selectedSessionId) return;
+ const selectedStillLive =
+ selectedSessionId && sessions.some((s) => s.id === selectedSessionId);
+ if (selectedStillLive) return;
const fallback = currentBinding ?? sessions[0]?.id ?? null;
- if (fallback) setSelectedSession(fallback);
+ setSelectedSession(fallback);
}, [
open,
paneId,
diff --git a/apps/desktop/src/renderer/stores/browser-automation.ts b/apps/desktop/src/renderer/stores/browser-automation.ts
index e55f6796050..650fdf166f1 100644
--- a/apps/desktop/src/renderer/stores/browser-automation.ts
+++ b/apps/desktop/src/renderer/stores/browser-automation.ts
@@ -32,7 +32,7 @@ interface BrowserAutomationUiState {
listViewOpen: boolean;
openConnectModal: (paneId: string, preselectSessionId?: string) => void;
closeConnectModal: () => void;
- setSelectedSession: (sessionId: string) => void;
+ setSelectedSession: (sessionId: string | null) => void;
setListViewOpen: (open: boolean) => void;
}
From 88547a9bcebe1e9745e44bc168995e6c81bc9d8b Mon Sep 17 00:00:00 2001
From: MocA-Love <64681295+MocA-Love@users.noreply.github.com>
Date: Mon, 20 Apr 2026 18:04:31 +0900
Subject: [PATCH 07/22] fix(desktop): stop polling sessions for every browser
pane
- useBrowserAutomationData takes an `enabled` flag. Sessions and MCP
status only poll while the Connect dialog / list view is open; the
binding query relies on the push subscription. Poll intervals relaxed
to 15s / 30s from 5s / 10s. (codex P2)
- SessionConnectModal: fallback selection only keeps currentBinding when
it still points at a live session, otherwise picks sessions[0]. Stops
the modal from opening with a truthy-but-dead selection. (codex P3)
- ConnectButton now only consumes the binding data and renders a static
"Connected" label instead of the full session name + provider.
---
.../hooks/useBrowserAutomationData.ts | 25 +++++++++++++++----
.../ConnectButton/ConnectButton.tsx | 15 ++++++-----
.../SessionConnectModal.tsx | 9 ++++++-
3 files changed, 35 insertions(+), 14 deletions(-)
diff --git a/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts
index 917a012af6f..d31a41c1c7f 100644
--- a/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts
+++ b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts
@@ -12,21 +12,36 @@ import {
* session list (each row is a running Claude/Codex worker), and their
* MCP readiness is resolved against the user's Claude/Codex config
* files.
+ *
+ * `enabled` controls the expensive queries (sessions + MCP status). The
+ * binding query and its subscription always run because `ConnectButton`
+ * is mounted for every browser pane and needs to reflect the binding
+ * state without expensive polling.
*/
-export function useBrowserAutomationData() {
+export function useBrowserAutomationData({
+ enabled = true,
+}: {
+ enabled?: boolean;
+} = {}) {
const { data: todoSessions = [], refetch: refetchSessions } =
electronTrpc.todoAgent.listAll.useQuery(undefined, {
- refetchOnWindowFocus: true,
- refetchInterval: 5000,
+ enabled,
+ refetchOnWindowFocus: enabled,
+ refetchInterval: enabled ? 15000 : false,
});
const { data: mcpStatus } =
electronTrpc.browserAutomation.getMcpStatus.useQuery(
{},
- { refetchOnWindowFocus: true, refetchInterval: 10000 },
+ {
+ enabled,
+ refetchOnWindowFocus: enabled,
+ refetchInterval: enabled ? 30000 : false,
+ },
);
const { data: bindings = [] } =
electronTrpc.browserAutomation.listBindings.useQuery(undefined, {
- refetchInterval: 1000,
+ // Binding changes are pushed via onBindingsChanged, so no polling.
+ refetchOnWindowFocus: false,
});
const utils = electronTrpc.useUtils();
electronTrpc.browserAutomation.onBindingsChanged.useSubscription(undefined, {
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/ConnectButton.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/ConnectButton.tsx
index 6daacd8c31a..7b5dbe54af9 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/ConnectButton.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/ConnectButton.tsx
@@ -8,17 +8,16 @@ interface ConnectButtonProps {
}
export function ConnectButton({ paneId }: ConnectButtonProps) {
- const { sessions, bindingsByPane } = useBrowserAutomationData();
+ // Do not fetch the session list / MCP status here; those are only
+ // needed inside the Connect dialog. The binding query is cheap and
+ // subscription-driven, so the badge still reflects connect/disconnect
+ // in real time.
+ const { bindingsByPane } = useBrowserAutomationData({ enabled: false });
const openConnectModal = useBrowserAutomationStore((s) => s.openConnectModal);
const sessionId = bindingsByPane[paneId];
- const session = sessionId
- ? (sessions.find((s) => s.id === sessionId) ?? null)
- : null;
- const connected = Boolean(session);
- const label = connected
- ? `${session?.displayName} · ${session?.provider}`
- : "Connect";
+ const connected = Boolean(sessionId);
+ const label = connected ? "Connected" : "Connect";
return (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx
index 491d5d1f08a..bc2d96e00d5 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx
@@ -64,7 +64,14 @@ export function SessionConnectModal({
const selectedStillLive =
selectedSessionId && sessions.some((s) => s.id === selectedSessionId);
if (selectedStillLive) return;
- const fallback = currentBinding ?? sessions[0]?.id ?? null;
+ // Only fall back to the currently-bound session if it is still in the
+ // live set — otherwise the modal would open with a truthy selection
+ // whose detail view can't render and whose Connect button stays
+ // blocked.
+ const bindingIsLive =
+ currentBinding && sessions.some((s) => s.id === currentBinding);
+ const fallback =
+ (bindingIsLive ? currentBinding : null) ?? sessions[0]?.id ?? null;
setSelectedSession(fallback);
}, [
open,
From b85c59e173a65d02cd14edda64a25b13b6ceebf9 Mon Sep 17 00:00:00 2001
From: MocA-Love <64681295+MocA-Love@users.noreply.github.com>
Date: Mon, 20 Apr 2026 18:13:46 +0900
Subject: [PATCH 08/22] feat(desktop): persist bindings in local-db and detect
terminal claude/codex
- packages/local-db: add browser_automation_bindings table + migration
- browserAutomation router: use localDb for bindings so they survive restart
(terminal daemon re-attaches panes, binding must survive too)
- Add listTerminalAgentSessions that walks each live PTY's process tree and
returns the ones running claude or codex. Hook merges them into the session
list keyed by `terminal:` so bindings self-heal across shell
re-spawns in the same pane.
- setBinding takes sessionKind ("todo-agent" | "terminal") for future
dispatch logic on the consumer side.
Addresses:
- Persist across restarts (user report)
- Bind to claude/codex started in an ordinary terminal tab (user report)
---
.../trpc/routers/browser-automation/index.ts | 164 +-
.../hooks/useBrowserAutomationData.ts | 65 +-
.../SessionConnectModal.tsx | 7 +-
.../0067_add_browser_automation_bindings.sql | 6 +
.../local-db/drizzle/meta/0067_snapshot.json | 2332 +++++++++++++++++
packages/local-db/drizzle/meta/_journal.json | 9 +-
.../src/schema/browser-automation-bindings.ts | 28 +
packages/local-db/src/schema/index.ts | 1 +
packages/local-db/src/schema/schema.ts | 1 +
9 files changed, 2566 insertions(+), 47 deletions(-)
create mode 100644 packages/local-db/drizzle/0067_add_browser_automation_bindings.sql
create mode 100644 packages/local-db/drizzle/meta/0067_snapshot.json
create mode 100644 packages/local-db/src/schema/browser-automation-bindings.ts
diff --git a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts
index a0c09aefb71..161a9147fa1 100644
--- a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts
+++ b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts
@@ -2,30 +2,32 @@ import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
+import {
+ browserAutomationBindings,
+ type SelectBrowserAutomationBinding,
+} from "@superset/local-db";
import { observable } from "@trpc/server/observable";
+import { and, eq, ne } from "drizzle-orm";
+import { localDb } from "main/lib/local-db";
+import { getProcessName, getProcessTree } from "main/lib/terminal/port-scanner";
+import { getTerminalHostClient } from "main/lib/terminal-host/client";
import { z } from "zod";
import { publicProcedure, router } from "../..";
/**
* Browser automation bindings router.
*
- * Stores the paneId -> sessionId assignment for browser pane automation,
- * and exposes MCP-readiness detection by reading the user's agent config
- * files (Claude Code / Codex) for the `superset-browser` entry.
+ * Bindings persist in local-db so they survive app restarts: the terminal
+ * daemon re-attaches terminal panes and TODO-Agent sessions keep running,
+ * so losing the binding would force a re-connect on every launch.
*
- * State is in-memory and process-local. Persistence across app restarts
- * is intentionally out of scope for Phase 1; the binding is re-established
- * by the user next time they open the Connect dialog.
+ * Also exposes MCP-readiness detection by reading the user's agent config
+ * files (Claude Code / Codex) for the `superset-browser` entry.
*/
-export interface BrowserAutomationBinding {
- paneId: string;
- sessionId: string;
- connectedAt: number;
-}
+export type BrowserAutomationBinding = SelectBrowserAutomationBinding;
class BindingStore {
- private readonly byPane = new Map();
private readonly emitter = new EventEmitter();
constructor() {
@@ -35,41 +37,86 @@ class BindingStore {
}
list(): BrowserAutomationBinding[] {
- return Array.from(this.byPane.values());
+ return localDb.select().from(browserAutomationBindings).all();
}
get(paneId: string): BrowserAutomationBinding | null {
- return this.byPane.get(paneId) ?? null;
+ return (
+ localDb
+ .select()
+ .from(browserAutomationBindings)
+ .where(eq(browserAutomationBindings.paneId, paneId))
+ .get() ?? null
+ );
}
getBySessionId(sessionId: string): BrowserAutomationBinding | null {
- for (const b of this.byPane.values()) {
- if (b.sessionId === sessionId) return b;
- }
- return null;
+ return (
+ localDb
+ .select()
+ .from(browserAutomationBindings)
+ .where(eq(browserAutomationBindings.sessionId, sessionId))
+ .get() ?? null
+ );
}
- set(paneId: string, sessionId: string): { previousPaneId: string | null } {
- let previousPaneId: string | null = null;
- for (const [pid, binding] of this.byPane.entries()) {
- if (binding.sessionId === sessionId && pid !== paneId) {
- previousPaneId = pid;
- this.byPane.delete(pid);
- }
+ set(
+ paneId: string,
+ sessionId: string,
+ sessionKind: string,
+ ): { previousPaneId: string | null } {
+ // Remove any existing binding that points at the same session on a
+ // different pane so we enforce 1 session ↔ 1 pane.
+ const existingOtherPane = localDb
+ .select()
+ .from(browserAutomationBindings)
+ .where(
+ and(
+ eq(browserAutomationBindings.sessionId, sessionId),
+ ne(browserAutomationBindings.paneId, paneId),
+ ),
+ )
+ .get();
+ const previousPaneId = existingOtherPane?.paneId ?? null;
+ if (previousPaneId) {
+ localDb
+ .delete(browserAutomationBindings)
+ .where(eq(browserAutomationBindings.paneId, previousPaneId))
+ .run();
}
- this.byPane.set(paneId, {
+ const row = {
paneId,
sessionId,
+ sessionKind,
connectedAt: Date.now(),
- });
+ };
+ // Drizzle SQLite upsert via onConflictDoUpdate
+ localDb
+ .insert(browserAutomationBindings)
+ .values(row)
+ .onConflictDoUpdate({
+ target: browserAutomationBindings.paneId,
+ set: {
+ sessionId: row.sessionId,
+ sessionKind: row.sessionKind,
+ connectedAt: row.connectedAt,
+ },
+ })
+ .run();
this.emitChange();
return { previousPaneId };
}
remove(paneId: string): boolean {
- const existed = this.byPane.delete(paneId);
- if (existed) this.emitChange();
- return existed;
+ const result = localDb
+ .delete(browserAutomationBindings)
+ .where(eq(browserAutomationBindings.paneId, paneId))
+ .run();
+ if (result.changes > 0) {
+ this.emitChange();
+ return true;
+ }
+ return false;
}
private emitChange() {
@@ -104,6 +151,54 @@ function detectSupersetBrowserMcp(filePath: string): boolean {
const CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
const CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml");
+export interface TerminalAgentSession {
+ paneId: string;
+ workspaceId: string;
+ pid: number;
+ provider: "Claude" | "Codex";
+ command: string;
+ lastAttachedAt?: string;
+}
+
+/**
+ * Walk every live terminal session's PTY process tree and return the ones
+ * that currently have a `claude` or `codex` child process. Used so the
+ * Browser Automation UI can treat "the claude I started in this terminal
+ * tab" as an LLM session that is connectable to a browser pane.
+ */
+async function detectTerminalAgentSessions(): Promise {
+ const client = getTerminalHostClient();
+ const { sessions } = await client.listSessions();
+ const out: TerminalAgentSession[] = [];
+ await Promise.all(
+ sessions.map(async (s) => {
+ if (!s.isAlive || typeof s.pid !== "number") return;
+ const pids = await getProcessTree(s.pid);
+ // Skip the shell itself (root pid) when matching names so typing
+ // `claude` at the prompt inside zsh does not cause the shell's
+ // argv to trigger a match.
+ const names = await Promise.all(
+ pids
+ .filter((p) => p !== s.pid)
+ .map(async (p) => ({ pid: p, name: await getProcessName(p) })),
+ );
+ const match = names.find(
+ ({ name }) => name === "claude" || name === "codex",
+ );
+ if (!match) return;
+ out.push({
+ paneId: s.paneId,
+ workspaceId: s.workspaceId,
+ pid: match.pid,
+ provider: match.name === "codex" ? "Codex" : "Claude",
+ command: match.name,
+ lastAttachedAt: s.lastAttachedAt,
+ });
+ }),
+ );
+ return out;
+}
+
export const createBrowserAutomationRouter = () => {
return router({
getMcpStatus: publicProcedure
@@ -130,6 +225,10 @@ export const createBrowserAutomationRouter = () => {
};
}),
+ listTerminalAgentSessions: publicProcedure.query(() =>
+ detectTerminalAgentSessions(),
+ ),
+
listBindings: publicProcedure.query(() => bindingStore.list()),
getBindingByPane: publicProcedure
@@ -145,9 +244,12 @@ export const createBrowserAutomationRouter = () => {
z.object({
paneId: z.string(),
sessionId: z.string(),
+ sessionKind: z.enum(["todo-agent", "terminal"]).default("todo-agent"),
}),
)
- .mutation(({ input }) => bindingStore.set(input.paneId, input.sessionId)),
+ .mutation(({ input }) =>
+ bindingStore.set(input.paneId, input.sessionId, input.sessionKind),
+ ),
removeBinding: publicProcedure
.input(z.object({ paneId: z.string() }))
diff --git a/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts
index d31a41c1c7f..21a97a9f518 100644
--- a/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts
+++ b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts
@@ -5,30 +5,45 @@ import {
formatRelativeTime,
type McpStatus,
} from "renderer/stores/browser-automation";
+import { useTabsStore } from "renderer/stores/tabs/store";
/**
* Aggregates browser-automation session and binding data from the
- * main-process tRPC routers. Sessions are derived from the TODO-agent
- * session list (each row is a running Claude/Codex worker), and their
- * MCP readiness is resolved against the user's Claude/Codex config
- * files.
+ * main-process tRPC routers. Sessions come from two sources:
*
- * `enabled` controls the expensive queries (sessions + MCP status). The
- * binding query and its subscription always run because `ConnectButton`
- * is mounted for every browser pane and needs to reflect the binding
- * state without expensive polling.
+ * 1. TODO-Agent sessions (Claude Code workers the supervisor is running).
+ * 2. Terminal panes that currently have a `claude` or `codex` child
+ * process. The binding key for this kind is the terminal's paneId so
+ * that it self-heals across shell re-spawns inside the same pane.
+ *
+ * MCP readiness is resolved against the user's Claude/Codex config files.
+ *
+ * `enabled` controls the expensive queries. The binding query and its
+ * subscription always run because `ConnectButton` is mounted for every
+ * browser pane and needs to reflect the binding state without polling.
*/
export function useBrowserAutomationData({
enabled = true,
}: {
enabled?: boolean;
} = {}) {
+ const panes = useTabsStore((s) => s.panes);
+
const { data: todoSessions = [], refetch: refetchSessions } =
electronTrpc.todoAgent.listAll.useQuery(undefined, {
enabled,
refetchOnWindowFocus: enabled,
refetchInterval: enabled ? 15000 : false,
});
+ const { data: terminalAgents = [] } =
+ electronTrpc.browserAutomation.listTerminalAgentSessions.useQuery(
+ undefined,
+ {
+ enabled,
+ refetchOnWindowFocus: enabled,
+ refetchInterval: enabled ? 10000 : false,
+ },
+ );
const { data: mcpStatus } =
electronTrpc.browserAutomation.getMcpStatus.useQuery(
{},
@@ -60,13 +75,11 @@ export function useBrowserAutomationData({
"verifying",
"waiting",
]);
- return todoSessions
+ const todo: AutomationSession[] = todoSessions
.filter((s) => liveStatuses.has(s.status))
.map((s) => {
// Todo-agent rows always represent Claude Code workers (see
- // todo-daemon/claude-code-runner.ts). We label them as Claude
- // here; Codex workers would be represented by a different row
- // type if/when they land.
+ // todo-daemon/claude-code-runner.ts).
const provider = "Claude" as const;
const mcp: McpStatus = mcpStatus
? mcpStatus.claudeReady
@@ -88,7 +101,33 @@ export function useBrowserAutomationData({
mcpStatus: mcp,
};
});
- }, [todoSessions, mcpStatus]);
+
+ const terminal: AutomationSession[] = terminalAgents.map((t) => {
+ const pane = panes[t.paneId];
+ const mcp: McpStatus = mcpStatus
+ ? t.provider === "Codex"
+ ? mcpStatus.codexReady
+ ? "ready"
+ : "missing"
+ : mcpStatus.claudeReady
+ ? "ready"
+ : "missing"
+ : "unknown";
+ return {
+ id: `terminal:${t.paneId}`,
+ displayName: pane?.userTitle || pane?.name || `Terminal ${t.command}`,
+ provider: t.provider,
+ kind: "Terminal",
+ branchOrContextLabel: t.command,
+ lastActiveAt: t.lastAttachedAt
+ ? formatRelativeTime(Date.parse(t.lastAttachedAt))
+ : "active",
+ mcpStatus: mcp,
+ };
+ });
+
+ return [...todo, ...terminal];
+ }, [todoSessions, terminalAgents, mcpStatus, panes]);
const bindingsByPane = useMemo(() => {
const map: Record = {};
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx
index bc2d96e00d5..44fb27064f9 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx
@@ -99,6 +99,9 @@ export function SessionConnectModal({
const result = await setBinding.mutateAsync({
paneId,
sessionId: session.id,
+ sessionKind: session.id.startsWith("terminal:")
+ ? "terminal"
+ : "todo-agent",
});
await utils.browserAutomation.listBindings.invalidate();
if (result.previousPaneId) {
@@ -183,8 +186,8 @@ export function SessionConnectModal({
{sessions.length === 0 ? (
- No running LLM sessions found in this app. Start a TODO-Agent
- session (Claude) or open a chat pane, then return here.
+ No running LLM sessions found. Start a TODO-Agent session or run
+ `claude` / `codex` in any terminal pane, then return here.
- Append the superset-browser MCP server block below.
-
-
- Restart {session.displayName} (or run the agent again) so the new
- entry is picked up.
-
+ {session.provider === "Claude" ? (
+ <>
+
+ Run the command below in a terminal that has the{" "}
+ claude CLI installed. It will register{" "}
+ superset-browser in{" "}
+ ~/.claude.json{" "}
+ without hand-editing JSON.
+
+
+ Restart {session.displayName} (or run /mcp in the
+ session) so the new entry is picked up.
+