From 6caa3bcbc73c7790f668d47bf77f89ec76727596 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 23 May 2026 23:18:10 -0700 Subject: [PATCH 01/12] docs(host-service): plan for terminalAgents tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-process per-terminal agent binding tracker on host-service, plus tRPC surface (listByWorkspace, findActive, getOrCreate, onWorkspaceChange). No consumers wired yet — module + API only. Decisions logged: in-mem Map, per-terminal granularity, delete on exit, latest-event tie-break, primitives over bundled send. --- plans/20260523-agent-session-tracking.md | 174 +++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 plans/20260523-agent-session-tracking.md diff --git a/plans/20260523-agent-session-tracking.md b/plans/20260523-agent-session-tracking.md new file mode 100644 index 00000000000..4cbdc2c8367 --- /dev/null +++ b/plans/20260523-agent-session-tracking.md @@ -0,0 +1,174 @@ +# 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 + +- Renderer migration (existing `useV2AgentBindingStore` stays; will be rewritten when the first renderer consumer lands). +- 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). From 17ee0bfc95a441886b05499a78646b18dbefb869 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 23 May 2026 23:37:20 -0700 Subject: [PATCH 02/12] feat(host-service): add terminalAgents tracker In-process per-terminal agent binding store on host-service, populated by the existing notifications.hook event receiver and drained on terminal exit. Exposes a tRPC surface (listByWorkspace, findActive, getOrCreate, onWorkspaceChange observable) so non-renderer consumers (automations) can reuse live agent sessions instead of always spawning new ones. No persistence: Map clears on host-service restart and rehydrates from the next hook event. Per-terminal granularity; agent swap inside the same pty overwrites in place. --- packages/host-service/src/app.ts | 4 + .../host-service/src/terminal-agents/index.ts | 2 + .../src/terminal-agents/store.test.ts | 193 +++++++++++++++++ .../host-service/src/terminal-agents/store.ts | 133 ++++++++++++ .../host-service/src/terminal-agents/types.ts | 22 ++ .../notifications/notifications.test.ts | 22 +- .../router/notifications/notifications.ts | 13 +- .../host-service/src/trpc/router/router.ts | 2 + .../src/trpc/router/terminal-agents/index.ts | 1 + .../router/terminal-agents/terminal-agents.ts | 203 ++++++++++++++++++ .../src/trpc/router/terminal/terminal.ts | 1 + packages/host-service/src/types.ts | 2 + 12 files changed, 596 insertions(+), 2 deletions(-) create mode 100644 packages/host-service/src/terminal-agents/index.ts create mode 100644 packages/host-service/src/terminal-agents/store.test.ts create mode 100644 packages/host-service/src/terminal-agents/store.ts create mode 100644 packages/host-service/src/terminal-agents/types.ts create mode 100644 packages/host-service/src/trpc/router/terminal-agents/index.ts create mode 100644 packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts 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..95b32bf6188 --- /dev/null +++ b/packages/host-service/src/terminal-agents/store.test.ts @@ -0,0 +1,193 @@ +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("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..d37168093a3 --- /dev/null +++ b/packages/host-service/src/terminal-agents/store.ts @@ -0,0 +1,133 @@ +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 (claude/codex/cursor/opencode/droid/…) + * is alive in which terminal. Populated by the hook receiver, drained on + * terminal exit. Absence is the only signal — no history is retained + * (plan decision #3). + * + * Emits `"change"` with the affected workspaceId after every mutation so + * tRPC subscribers can re-snapshot. + */ +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) { + // First sighting of the terminal with no agent identity attached — + // nothing to bind to. Skip. + return; + } + + const nextAgentId = agentId ?? existing?.agentId; + if (!nextAgentId) return; + + const next: TerminalAgentBinding = { + terminalId, + workspaceId, + agentId: nextAgentId, + agentSessionId: agentSessionId ?? existing?.agentSessionId, + definitionId: definitionId ?? existing?.definitionId, + startedAt: existing ? existing.startedAt : occurredAt, + lastEventAt: occurredAt, + lastEventType: eventType, + }; + + // Agent swap inside the same pty (e.g. claude /exit → codex) overwrites + // in place — start time resets so callers see the new identity's lifetime. + if ( + existing && + (existing.agentId !== next.agentId || + (agentSessionId !== undefined && + existing.agentSessionId !== agentSessionId)) + ) { + next.startedAt = occurredAt; + } + + 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..adaa752057a --- /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 | "droid"; + +/** + * 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/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..f7ce6fbe761 --- /dev/null +++ b/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts @@ -0,0 +1,203 @@ +import type { AgentDefinitionId } from "@superset/shared/agent-catalog"; +import { TRPCError } from "@trpc/server"; +import { observable } from "@trpc/server/observable"; +import { z } from "zod"; +import { createTerminalSessionInternal } from "../../../terminal/terminal"; +import type { + TerminalAgentBinding, + TerminalAgentId, +} from "../../../terminal-agents"; +import { protectedProcedure, router } from "../../index"; + +const terminalAgentIdSchema = z.string().min(1) as z.ZodType; +const agentDefinitionIdSchema = z + .string() + .min(1) 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 if one exists; otherwise + * spawns a fresh terminal with `initialCommand`/`cwd` and waits for the + * agent's hook to register a binding (10s budget). + * + * Callers compose with `terminal.writeInput` after this resolves — this + * module does not format input. + */ + 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 as const }; + } + + 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, + }); + } + + const binding = await waitForBinding({ + store: ctx.terminalAgentStore, + workspaceId, + agentId, + definitionId, + terminalId: created.terminalId, + timeoutMs: GET_OR_CREATE_TIMEOUT_MS, + }); + + return { binding, created: true as const }; + }), + + /** + * Snapshot-then-deltas stream of bindings for a workspace. Emits a + * snapshot on subscribe and after every store mutation. Renderer + * clients can drop their own diffing — re-render from the latest array. + * + * Uses `observable` rather than an async generator (required by + * `trpc-electron`; harmless for HTTP/SSE transports). + */ + 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; } From d6afee0d89afcf57f5b303d4ef609e4f0e3daefa Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sat, 23 May 2026 23:37:33 -0700 Subject: [PATCH 03/12] refactor(desktop): wire TerminalPaneIcon to host-service terminalAgents Replace the renderer-side useV2AgentBindingStore (Zustand) with a React Query hook against host-service terminalAgents.listByWorkspace, invalidated by agent:lifecycle and terminal:lifecycle workspace events. Host store is now the single source of truth; the renderer just mirrors it. TerminalPaneIcon takes a new workspaceId prop; HostNotificationSubscriber no longer mutates the (now-deleted) Zustand store. --- .../useTerminalAgentBindings/index.ts | 5 ++ .../useTerminalAgentBindings.ts | 68 +++++++++++++++ .../TerminalPaneIcon/TerminalPaneIcon.tsx | 22 ++--- .../TerminalSessionDropdown.tsx | 2 +- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 7 +- .../HostNotificationSubscriber.tsx | 13 --- .../stores/v2-agent-bindings/index.ts | 6 -- .../stores/v2-agent-bindings/store.test.ts | 86 ------------------- .../stores/v2-agent-bindings/store.ts | 61 ------------- 9 files changed, 92 insertions(+), 178 deletions(-) create mode 100644 apps/desktop/src/renderer/hooks/host-service/useTerminalAgentBindings/index.ts create mode 100644 apps/desktop/src/renderer/hooks/host-service/useTerminalAgentBindings/useTerminalAgentBindings.ts delete mode 100644 apps/desktop/src/renderer/stores/v2-agent-bindings/index.ts delete mode 100644 apps/desktop/src/renderer/stores/v2-agent-bindings/store.test.ts delete mode 100644 apps/desktop/src/renderer/stores/v2-agent-bindings/store.ts 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..6a413c59a2b --- /dev/null +++ b/apps/desktop/src/renderer/hooks/host-service/useTerminalAgentBindings/useTerminalAgentBindings.ts @@ -0,0 +1,68 @@ +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]; + +/** + * Host-service-backed map of `terminalId → agent binding` for a workspace. + * The host store is the source of truth; this hook just surfaces the + * current snapshot and refetches on `agent:lifecycle` / `terminal:lifecycle` + * deltas pushed over the workspace event bus. + */ +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/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]; -} From e7b3c1ba8ec5498cdbc28efb629b6f6140227d47 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Sun, 24 May 2026 13:55:36 -0700 Subject: [PATCH 04/12] chore(plans): move agent-session-tracking plan to done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan shipped in this PR — module + tRPC surface + renderer pane wiring. --- plans/{ => done}/20260523-agent-session-tracking.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename plans/{ => done}/20260523-agent-session-tracking.md (100%) diff --git a/plans/20260523-agent-session-tracking.md b/plans/done/20260523-agent-session-tracking.md similarity index 100% rename from plans/20260523-agent-session-tracking.md rename to plans/done/20260523-agent-session-tracking.md From 9c3c4a7837e4111c1d30ba50e32ee808d86078a2 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 27 May 2026 16:12:41 -0700 Subject: [PATCH 05/12] fix(host-service): address PR review on terminalAgents - Coalesce concurrent getOrCreate via in-flight map; dispose orphaned terminal when waitForBinding times out. - Stop carrying stale agentSessionId/definitionId across agent swaps in TerminalAgentStore; add regression test. - Replace Zod `as` casts with z.enum(BUILTIN_AGENT_IDS) so invalid agent ids reject at the boundary instead of timing out downstream. - Trim doc/comments to reflect httpLink-only renderer transport. --- .../useTerminalAgentBindings.ts | 6 +- .../src/terminal-agents/store.test.ts | 25 ++++ .../host-service/src/terminal-agents/store.ts | 32 +++-- .../host-service/src/terminal-agents/types.ts | 2 +- .../router/terminal-agents/terminal-agents.ts | 126 ++++++++++++------ plans/done/20260523-agent-session-tracking.md | 14 +- 6 files changed, 145 insertions(+), 60 deletions(-) diff --git a/apps/desktop/src/renderer/hooks/host-service/useTerminalAgentBindings/useTerminalAgentBindings.ts b/apps/desktop/src/renderer/hooks/host-service/useTerminalAgentBindings/useTerminalAgentBindings.ts index 6a413c59a2b..bcfa737f376 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useTerminalAgentBindings/useTerminalAgentBindings.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useTerminalAgentBindings/useTerminalAgentBindings.ts @@ -13,10 +13,8 @@ type TerminalAgentBindings = Awaited< export type TerminalAgentBinding = TerminalAgentBindings[number]; /** - * Host-service-backed map of `terminalId → agent binding` for a workspace. - * The host store is the source of truth; this hook just surfaces the - * current snapshot and refetches on `agent:lifecycle` / `terminal:lifecycle` - * deltas pushed over the workspace event bus. + * 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, diff --git a/packages/host-service/src/terminal-agents/store.test.ts b/packages/host-service/src/terminal-agents/store.test.ts index 95b32bf6188..35c64661c9e 100644 --- a/packages/host-service/src/terminal-agents/store.test.ts +++ b/packages/host-service/src/terminal-agents/store.test.ts @@ -73,6 +73,31 @@ describe("TerminalAgentStore", () => { 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", diff --git a/packages/host-service/src/terminal-agents/store.ts b/packages/host-service/src/terminal-agents/store.ts index d37168093a3..76e76f72851 100644 --- a/packages/host-service/src/terminal-agents/store.ts +++ b/packages/host-service/src/terminal-agents/store.ts @@ -57,28 +57,32 @@ export class TerminalAgentStore extends EventEmitter { const nextAgentId = agentId ?? existing?.agentId; if (!nextAgentId) return; + // Only inherit identity metadata when the agentId hasn't changed — + // otherwise a swap event (claude → codex) that omits agentSessionId + // or definitionId would carry over 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 ?? existing?.agentSessionId, - definitionId: definitionId ?? existing?.definitionId, - startedAt: existing ? existing.startedAt : occurredAt, + agentSessionId: agentSessionId ?? prior?.agentSessionId, + definitionId: definitionId ?? prior?.definitionId, + startedAt: + prior !== undefined && !sessionChanged ? prior.startedAt : occurredAt, lastEventAt: occurredAt, lastEventType: eventType, }; - // Agent swap inside the same pty (e.g. claude /exit → codex) overwrites - // in place — start time resets so callers see the new identity's lifetime. - if ( - existing && - (existing.agentId !== next.agentId || - (agentSessionId !== undefined && - existing.agentSessionId !== agentSessionId)) - ) { - next.startedAt = occurredAt; - } - this.byTerminal.set(terminalId, next); this.emit("change", workspaceId); } diff --git a/packages/host-service/src/terminal-agents/types.ts b/packages/host-service/src/terminal-agents/types.ts index adaa752057a..576b9404c2a 100644 --- a/packages/host-service/src/terminal-agents/types.ts +++ b/packages/host-service/src/terminal-agents/types.ts @@ -3,7 +3,7 @@ import type { BuiltinAgentId, } from "@superset/shared/agent-catalog"; -export type TerminalAgentId = BuiltinAgentId | "droid"; +export type TerminalAgentId = BuiltinAgentId; /** * One live agent process bound to a terminal. Created on the first hook 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 index f7ce6fbe761..c3befc1ed4e 100644 --- a/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts +++ b/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts @@ -1,18 +1,40 @@ -import type { AgentDefinitionId } from "@superset/shared/agent-catalog"; +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 } from "../../../terminal/terminal"; +import { + createTerminalSessionInternal, + disposeSessionAndWait, +} from "../../../terminal/terminal"; import type { TerminalAgentBinding, TerminalAgentId, } from "../../../terminal-agents"; import { protectedProcedure, router } from "../../index"; -const terminalAgentIdSchema = z.string().min(1) as z.ZodType; -const agentDefinitionIdSchema = z - .string() - .min(1) as z.ZodType; +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; @@ -57,6 +79,10 @@ export const terminalAgentsRouter = router({ * spawns a fresh terminal with `initialCommand`/`cwd` and waits for the * agent's hook to register a binding (10s budget). * + * Resolves on the first lifecycle hook — not on agent prompt-readiness. + * Callers that immediately `terminal.writeInput` can race the agent's + * REPL initialization; add a readiness wait if that matters. + * * Callers compose with `terminal.writeInput` after this resolves — this * module does not format input. */ @@ -78,47 +104,67 @@ export const terminalAgentsRouter = router({ definitionId, ); if (existing) { - return { binding: existing, created: false as const }; + return { binding: existing, created: false }; } - 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, + // Coalesce concurrent callers for the same triple so we don't spawn + // duplicate terminals. + 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 } : {}), }); - } - - const binding = await waitForBinding({ - store: ctx.terminalAgentStore, - workspaceId, - agentId, - definitionId, - terminalId: created.terminalId, - timeoutMs: GET_OR_CREATE_TIMEOUT_MS, - }); - return { binding, created: true as const }; + 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( + () => undefined, + ); + throw err; + } + })(); + + inflight.set(key, promise); + try { + return await promise; + } finally { + inflight.delete(key); + } }), /** - * Snapshot-then-deltas stream of bindings for a workspace. Emits a - * snapshot on subscribe and after every store mutation. Renderer - * clients can drop their own diffing — re-render from the latest array. - * - * Uses `observable` rather than an async generator (required by - * `trpc-electron`; harmless for HTTP/SSE transports). + * 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() })) diff --git a/plans/done/20260523-agent-session-tracking.md b/plans/done/20260523-agent-session-tracking.md index 4cbdc2c8367..d5ce7459886 100644 --- a/plans/done/20260523-agent-session-tracking.md +++ b/plans/done/20260523-agent-session-tracking.md @@ -166,9 +166,21 @@ Router integration tests: ## Out of scope -- Renderer migration (existing `useV2AgentBindingStore` stays; will be rewritten when the first renderer consumer lands). - 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. From 1d207b1ac2cc42d14ec9e4cc430fa9b3429339a6 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 27 May 2026 16:12:52 -0700 Subject: [PATCH 06/12] feat(catalog): promote droid to a first-class builtin agent Droid was previously a stringly-typed exception (`BuiltinAgentId | "droid"`) even though desktop setup already treated it as a managed binary. Add it to BUILTIN_TERMINAL_AGENTS and remove the union exception from AgentIdentity, TerminalAgentId, and SupersetManagedBinary. Also adds the Factory mark as droid.svg / droid-white.svg so the pane icon and settings picker render the brand instead of falling back to the terminal glyph. --- .../lib/agent-setup/desktop-agent-capabilities.ts | 4 ++-- packages/shared/src/agent-identity.ts | 11 +++++------ packages/shared/src/builtin-terminal-agents.ts | 8 +++++++- .../ui/src/assets/icons/preset-icons/droid-white.svg | 1 + packages/ui/src/assets/icons/preset-icons/droid.svg | 1 + packages/ui/src/assets/icons/preset-icons/index.ts | 5 +++++ 6 files changed, 21 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/assets/icons/preset-icons/droid-white.svg create mode 100644 packages/ui/src/assets/icons/preset-icons/droid.svg 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/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/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..1f624c956bf --- /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..29ab3ec471d --- /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, From e670857228d26cd06b3c966cba670b9fbe605594 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 27 May 2026 16:13:01 -0700 Subject: [PATCH 07/12] refactor(catalog): derive HOST_AGENT_PRESETS from BUILTIN, seed all by default - Replace the parallel HOST_AGENT_PRESETS static array with a derivation from BUILTIN_TERMINAL_AGENTS. Tokenizes `command` into [bin, ...args] and computes `promptArgs` as the tail of `promptCommand` after the shared prefix. - Align v1 and v2 launch flags by updating BUILTIN claude to --dangerously-skip-permissions (matches what HOST_AGENT_PRESETS had). - Drop DEFAULT_PRESET_IDS allowlist; getDefaultSeedPresets() now returns every preset so new hosts seed with the full catalog (droid included). - Update agent-configs tests to derive expected ids from the source rather than the prior hard-coded 5-item list. --- .../router/settings/agent-configs.test.ts | 26 ++- packages/shared/src/host-agent-presets.ts | 176 +++++------------- 2 files changed, 64 insertions(+), 138 deletions(-) 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/shared/src/host-agent-presets.ts b/packages/shared/src/host-agent-presets.ts index a76a27a555f..c013b30aaa6 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; @@ -12,151 +13,68 @@ export interface HostAgentPreset { } /** - * 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` so the + * catalog has a single source of truth. 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. + * `promptArgs` is only included when launching with a prompt. 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. + * Superset is intentionally excluded — its model/provider config lives + * in chat settings, not in terminal-agent configs. It never appears in + * `BUILTIN_TERMINAL_AGENTS`. */ -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[]; +function tokenize(commandString: string): string[] { + return commandString.split(/\s+/).filter(Boolean); +} + +function derivePromptArgs( + commandTokens: string[], + promptCommand: string | undefined, +): string[] { + if (!promptCommand) return []; + // promptCommand is the full prompt-launch string (e.g. "codex --flag --"). + // The tail after the shared command-token prefix is the prompt-only args. + return tokenize(promptCommand).slice(commandTokens.length); +} -const DEFAULT_PRESET_IDS = new Set([ - "claude", - "amp", - "codex", - "gemini", - "copilot", -]); +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: {}, + }; + }); -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; } From 80f50c252f5dbffead62841071bf3acfea4fef5d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 27 May 2026 16:13:10 -0700 Subject: [PATCH 08/12] feat(agents): re-run per-agent setup on Add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings UI's Add button now triggers the agent's wrapper/settings/hook setupActions in addition to inserting the agent_configs row. Boot still runs setup for every known agent, but this guarantees the hooks are in place even if boot-time setup failed or the wrapper was wiped. Adds setupSingleAgent(agentId) helper and an electron-trpc settings.setupAgent mutation. Fire-and-forget — Add doesn't fail if setup misbehaves. --- .../src/lib/trpc/routers/settings/index.ts | 15 +++++++++++ .../lib/agent-setup/desktop-agent-setup.ts | 14 ++++++++++ .../desktop/src/main/lib/agent-setup/index.ts | 7 ++++- .../V2AgentsSettings/V2AgentsSettings.tsx | 27 ++++++++++++++++--- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index a618a8d0d87..50effff018f 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,20 @@ export const createSettingsRouter = () => { return { success: true }; }), + /** + * Re-runs the wrapper/settings/hook setup for a single agent. + * Boot already does this for every known agent, so this is a safety + * net for the settings-UI "Add" flow — guarantees the agent's hooks + * are wired even if boot-time setup failed or the wrapper was wiped. + * Returns `{ ran: false }` for unknown agent 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-setup.ts b/apps/desktop/src/main/lib/agent-setup/desktop-agent-setup.ts index 362a95b40ba..5312b378064 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,17 @@ export function setupDesktopAgentCapabilities(): void { } } } + +/** + * Re-run setupActions for a single agent (e.g. when the user installs it + * from the settings UI). Idempotent — runners are safe to invoke + * repeatedly. Returns whether the agent was a known setup target. + */ +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 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/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/agents/components/V2AgentsSettings/V2AgentsSettings.tsx index 6725402cb7f..d01f47c4b1a 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,25 @@ 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); + // Re-run the per-agent wrapper/hook setup as a safety net. Idempotent; + // boot already runs it for every known agent, but the user-facing Add + // flow is the right moment to guarantee the hooks are in place. + // Fire-and-forget — don't fail the add if setup misbehaves. + setupAgentMutation.mutate( + { agentId: preset.presetId }, + { + onError: (err) => + console.warn( + `[agents] setupAgent failed for ${preset.presetId}`, + err, + ), + }, + ); + return added; }, onSuccess: (added) => { invalidate(); From a9ca3748c4bf70f9270c64fdcbf15f2982cb6393 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 27 May 2026 16:57:41 -0700 Subject: [PATCH 09/12] chore(renderer): drop dead preset-icon SVG duplicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These 7 SVGs lived in the renderer alongside an index.ts that only re-exports from @superset/ui/icons/preset-icons. None were imported anywhere — leftovers from before the icons moved into packages/ui. --- .../assets/app-icons/preset-icons/claude.svg | 1 - .../app-icons/preset-icons/codex-white.svg | 15 --------- .../assets/app-icons/preset-icons/codex.svg | 15 --------- .../assets/app-icons/preset-icons/cursor.svg | 32 ------------------- .../assets/app-icons/preset-icons/gemini.svg | 1 - .../app-icons/preset-icons/opencode-white.svg | 1 - .../app-icons/preset-icons/opencode.svg | 1 - 7 files changed, 66 deletions(-) delete mode 100644 apps/desktop/src/renderer/assets/app-icons/preset-icons/claude.svg delete mode 100644 apps/desktop/src/renderer/assets/app-icons/preset-icons/codex-white.svg delete mode 100644 apps/desktop/src/renderer/assets/app-icons/preset-icons/codex.svg delete mode 100644 apps/desktop/src/renderer/assets/app-icons/preset-icons/cursor.svg delete mode 100644 apps/desktop/src/renderer/assets/app-icons/preset-icons/gemini.svg delete mode 100644 apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode-white.svg delete mode 100644 apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode.svg diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/claude.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/claude.svg deleted file mode 100644 index 62dc0db12da..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/claude.svg +++ /dev/null @@ -1 +0,0 @@ -Claude \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/codex-white.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/codex-white.svg deleted file mode 100644 index ba36fc2aa74..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/codex-white.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/codex.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/codex.svg deleted file mode 100644 index 832fa6a5f9b..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/codex.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/cursor.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/cursor.svg deleted file mode 100644 index 1f8a7c338a5..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/cursor.svg +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/gemini.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/gemini.svg deleted file mode 100644 index f1cf357573d..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/gemini.svg +++ /dev/null @@ -1 +0,0 @@ -Gemini \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode-white.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode-white.svg deleted file mode 100644 index b79c7332e20..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode-white.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode.svg b/apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode.svg deleted file mode 100644 index b79140a5070..00000000000 --- a/apps/desktop/src/renderer/assets/app-icons/preset-icons/opencode.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From cc24aecb1665e9c86a5f737c1c03446a19bea164 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 27 May 2026 17:00:31 -0700 Subject: [PATCH 10/12] fix(ui): tighten droid icon viewBox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Favicon source had ~85px of empty padding (16%) on all sides of the 508×508 canvas. Crop the viewBox to the path bbox so droid renders at the same visual weight as the other agent icons. --- packages/ui/src/assets/icons/preset-icons/droid-white.svg | 2 +- packages/ui/src/assets/icons/preset-icons/droid.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/assets/icons/preset-icons/droid-white.svg b/packages/ui/src/assets/icons/preset-icons/droid-white.svg index 1f624c956bf..45adbc81a2f 100644 --- a/packages/ui/src/assets/icons/preset-icons/droid-white.svg +++ b/packages/ui/src/assets/icons/preset-icons/droid-white.svg @@ -1 +1 @@ - + diff --git a/packages/ui/src/assets/icons/preset-icons/droid.svg b/packages/ui/src/assets/icons/preset-icons/droid.svg index 29ab3ec471d..c92b13fa97a 100644 --- a/packages/ui/src/assets/icons/preset-icons/droid.svg +++ b/packages/ui/src/assets/icons/preset-icons/droid.svg @@ -1 +1 @@ - + From 23d6f46015ff08674cf5236311748b8bf21d2c63 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 27 May 2026 17:05:17 -0700 Subject: [PATCH 11/12] fix: address follow-up review comments - terminal-agents.ts: log disposeSessionAndWait failures from the orphaned-pty cleanup path so observability isn't a black hole. - desktop-agent-setup.ts: run bootstrap setup actions (notify-script, etc.) in setupSingleAgent too. Per-agent hooks reference the shared notify script; if boot setup didn't run, per-agent setup needs to be self-sufficient. --- .../src/main/lib/agent-setup/desktop-agent-setup.ts | 8 ++++++++ .../src/trpc/router/terminal-agents/terminal-agents.ts | 7 ++++++- 2 files changed, 14 insertions(+), 1 deletion(-) 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 5312b378064..f366bef9678 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 @@ -72,10 +72,18 @@ export function setupDesktopAgentCapabilities(): void { * Re-run setupActions for a single agent (e.g. when the user installs it * from the settings UI). Idempotent — runners are safe to invoke * repeatedly. Returns whether the agent was a known setup target. + * + * Runs the bootstrap actions (notify-script, etc.) first because they + * are shared prerequisites that per-agent hooks reference. If + * boot-time setup somehow didn't run or the script got wiped, this + * keeps the per-agent setup self-sufficient. */ 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](); } 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 index c3befc1ed4e..2c64959f1ed 100644 --- a/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts +++ b/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts @@ -147,7 +147,12 @@ export const terminalAgentsRouter = router({ // Hook never landed — tear down the orphaned pty so retries // don't pile up zombies. await disposeSessionAndWait(created.terminalId, ctx.db).catch( - () => undefined, + (cleanupError) => { + console.warn( + "[terminal-agents] failed to dispose timed-out terminal", + { terminalId: created.terminalId, cleanupError }, + ); + }, ); throw err; } From 5f6ecc8c8cf8b18f2cc3c292dff9bb6be8faa3d3 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 27 May 2026 17:13:04 -0700 Subject: [PATCH 12/12] chore: trim restated comments and docstrings Pass focused on removing restatement and stale references while keeping intent comments. Net -29 lines across 6 files. - store.ts: drop stale "(plan decision #3)" reference; collapse class docstring. - terminal-agents.ts: tighten getOrCreate docstring; trim coalesce comment. - host-agent-presets.ts: move HOST_AGENT_PRESETS docstring from above tokenize() to above the export it actually documents. - desktop-agent-setup.ts, settings/index.ts, V2AgentsSettings.tsx: compress 4-line "why" comments to 1-2 lines without losing intent. --- .../src/lib/trpc/routers/settings/index.ts | 7 ++-- .../lib/agent-setup/desktop-agent-setup.ts | 11 ++---- .../V2AgentsSettings/V2AgentsSettings.tsx | 6 ++-- .../host-service/src/terminal-agents/store.ts | 23 +++++------- .../router/terminal-agents/terminal-agents.ts | 17 ++++----- packages/shared/src/host-agent-presets.ts | 35 ++++++++----------- 6 files changed, 35 insertions(+), 64 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 50effff018f..bb21e3b09fd 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -1010,11 +1010,8 @@ export const createSettingsRouter = () => { }), /** - * Re-runs the wrapper/settings/hook setup for a single agent. - * Boot already does this for every known agent, so this is a safety - * net for the settings-UI "Add" flow — guarantees the agent's hooks - * are wired even if boot-time setup failed or the wrapper was wiped. - * Returns `{ ran: false }` for unknown agent ids. + * 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) })) 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 f366bef9678..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 @@ -69,14 +69,9 @@ export function setupDesktopAgentCapabilities(): void { } /** - * Re-run setupActions for a single agent (e.g. when the user installs it - * from the settings UI). Idempotent — runners are safe to invoke - * repeatedly. Returns whether the agent was a known setup target. - * - * Runs the bootstrap actions (notify-script, etc.) first because they - * are shared prerequisites that per-agent hooks reference. If - * boot-time setup somehow didn't run or the script got wiped, this - * keeps the per-agent setup self-sufficient. + * 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); 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 d01f47c4b1a..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 @@ -78,10 +78,8 @@ export function V2AgentsSettings({ await getHostServiceClientByUrl( activeHostUrl, ).settings.agentConfigs.add.mutate(body); - // Re-run the per-agent wrapper/hook setup as a safety net. Idempotent; - // boot already runs it for every known agent, but the user-facing Add - // flow is the right moment to guarantee the hooks are in place. - // Fire-and-forget — don't fail the add if setup misbehaves. + // 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 }, { diff --git a/packages/host-service/src/terminal-agents/store.ts b/packages/host-service/src/terminal-agents/store.ts index 76e76f72851..7ec7fc30680 100644 --- a/packages/host-service/src/terminal-agents/store.ts +++ b/packages/host-service/src/terminal-agents/store.ts @@ -20,13 +20,11 @@ interface ListFilter { const EXIT_EVENT_TYPES = new Set(["Detached", "exit", "error"]); /** - * In-process tracker for which agent (claude/codex/cursor/opencode/droid/…) - * is alive in which terminal. Populated by the hook receiver, drained on - * terminal exit. Absence is the only signal — no history is retained - * (plan decision #3). + * 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 so - * tRPC subscribers can re-snapshot. + * Emits `"change"` with the affected workspaceId after every mutation. */ export class TerminalAgentStore extends EventEmitter { private readonly byTerminal = new Map(); @@ -48,19 +46,14 @@ export class TerminalAgentStore extends EventEmitter { } const existing = this.byTerminal.get(terminalId); - if (!agentId && !existing) { - // First sighting of the terminal with no agent identity attached — - // nothing to bind to. Skip. - return; - } + if (!agentId && !existing) return; const nextAgentId = agentId ?? existing?.agentId; if (!nextAgentId) return; - // Only inherit identity metadata when the agentId hasn't changed — - // otherwise a swap event (claude → codex) that omits agentSessionId - // or definitionId would carry over the prior agent's values and - // corrupt `definitionId`-filtered reads. + // 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 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 index 2c64959f1ed..440a9f41263 100644 --- a/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts +++ b/packages/host-service/src/trpc/router/terminal-agents/terminal-agents.ts @@ -75,16 +75,12 @@ export const terminalAgentsRouter = router({ /** * Reuse-or-launch primitive. Returns an existing active binding for the - * `(workspaceId, agentId, definitionId)` triple if one exists; otherwise - * spawns a fresh terminal with `initialCommand`/`cwd` and waits for the - * agent's hook to register a binding (10s budget). + * `(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 agent prompt-readiness. - * Callers that immediately `terminal.writeInput` can race the agent's - * REPL initialization; add a readiness wait if that matters. - * - * Callers compose with `terminal.writeInput` after this resolves — this - * module does not format input. + * 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( @@ -107,8 +103,7 @@ export const terminalAgentsRouter = router({ return { binding: existing, created: false }; } - // Coalesce concurrent callers for the same triple so we don't spawn - // duplicate terminals. + // 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; diff --git a/packages/shared/src/host-agent-presets.ts b/packages/shared/src/host-agent-presets.ts index c013b30aaa6..805eb76e492 100644 --- a/packages/shared/src/host-agent-presets.ts +++ b/packages/shared/src/host-agent-presets.ts @@ -12,25 +12,6 @@ export interface HostAgentPreset { env: Record; } -/** - * Terminal agent presets, derived from `BUILTIN_TERMINAL_AGENTS` so the - * catalog has a single source of truth. 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. 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. It never appears in - * `BUILTIN_TERMINAL_AGENTS`. - */ function tokenize(commandString: string): string[] { return commandString.split(/\s+/).filter(Boolean); } @@ -40,11 +21,23 @@ function derivePromptArgs( promptCommand: string | undefined, ): string[] { if (!promptCommand) return []; - // promptCommand is the full prompt-launch string (e.g. "codex --flag --"). - // The tail after the shared command-token prefix is the prompt-only args. + // 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); } +/** + * 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] + * + * Stdin transport pipes the prompt to stdin instead of pushing it to argv. + */ export const HOST_AGENT_PRESETS: readonly HostAgentPreset[] = BUILTIN_TERMINAL_AGENTS.map((agent) => { const commandTokens = tokenize(agent.command);