From 060f3d457c82cecb405aea98064f6f3fbad09844 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 2 Apr 2026 10:55:54 +0700 Subject: [PATCH] refactor: remove terminal agent concept entirely Remove the "agent can be a terminal" branching from the entire codebase. An agent is now always a session-backed chat agent. Standalone terminal infrastructure (terminal component, ANSI handling, terminal-stream-protocol) is preserved. Server: delete ManagedTerminalAgent, AgentKind, TerminalExitDetails, launchTerminalAgent, registerTerminalAgent, handleTerminalAgentExited, supportsTerminalMode capability, buildTerminalCreate/ResumeCommand from all providers, terminal agent persistence/projections. App: delete terminal-agent-panel.tsx, terminal-agent-reopen-store.ts, terminal/terminalExit fields on agent state, "Terminal Agents" launcher section, terminal-agent workspace setup flow, terminal badge in agent list. CLI: remove terminal column from ls, terminal-agent error from send. 60 files changed, -3592 lines --- docs/TERMINAL-MODE.md | 506 -------------- packages/app/e2e/helpers/launcher.ts | 12 - .../app/e2e/helpers/workspace-lifecycle.ts | 9 - packages/app/e2e/helpers/workspace-setup.ts | 20 - packages/app/e2e/launcher-tab.spec.ts | 60 +- packages/app/e2e/workspace-lifecycle.spec.ts | 72 -- .../app/e2e/workspace-setup-runtime.spec.ts | 31 - packages/app/src/components/agent-list.tsx | 9 +- .../src/components/workspace-setup-dialog.tsx | 244 +------ .../contexts/session-status-tracking.test.ts | 2 - .../use-agent-screen-state-machine.test.ts | 2 - .../hooks/use-agent-screen-state-machine.ts | 7 - .../app/src/hooks/use-aggregated-agents.ts | 1 - .../app/src/hooks/use-all-agents-list.test.ts | 2 - packages/app/src/hooks/use-all-agents-list.ts | 1 - .../app/src/hooks/use-archive-agent.test.ts | 2 - packages/app/src/panels/agent-panel.tsx | 28 - packages/app/src/panels/launcher-panel.tsx | 241 +------ .../app/src/panels/terminal-agent-panel.tsx | 316 --------- packages/app/src/runtime/host-runtime.test.ts | 2 - .../src/screens/agent/draft-agent-screen.tsx | 2 - .../workspace-agent-visibility.test.ts | 10 +- .../workspace/workspace-draft-agent-tab.tsx | 2 - .../app/src/stores/provider-recency-store.ts | 2 +- packages/app/src/stores/session-store.ts | 5 - .../src/stores/terminal-agent-reopen-store.ts | 52 -- packages/app/src/types/agent-directory.ts | 1 - packages/app/src/utils/agent-snapshots.ts | 2 - .../src/utils/workspace-navigation.test.ts | 32 +- .../app/src/utils/workspace-navigation.ts | 9 - packages/cli/src/commands/agent/ls.ts | 3 - packages/cli/src/commands/agent/send.ts | 14 - .../src/server/agent/agent-manager.test.ts | 641 +----------------- .../server/src/server/agent/agent-manager.ts | 432 +----------- .../server/agent/agent-projections.test.ts | 1 - .../src/server/agent/agent-projections.ts | 9 - .../src/server/agent/agent-sdk-types.ts | 14 - .../src/server/agent/agent-storage.test.ts | 1 - .../server/src/server/agent/agent-storage.ts | 11 - .../src/server/agent/provider-manifest.ts | 6 +- .../src/server/agent/providers/aider-agent.ts | 37 +- .../src/server/agent/providers/amp-agent.ts | 36 +- .../server/agent/providers/claude-agent.ts | 70 -- .../agent/providers/codex-app-server-agent.ts | 57 -- .../server/agent/providers/gemini-agent.ts | 55 +- .../server/agent/providers/opencode-agent.ts | 50 -- .../providers/terminal-only-providers.test.ts | 103 --- packages/server/src/server/bootstrap.ts | 17 +- .../server/src/server/loop-service.test.ts | 1 - packages/server/src/server/loop-service.ts | 2 - .../src/server/persistence-hooks.test.ts | 7 +- .../server/src/server/persistence-hooks.ts | 2 - .../server/src/server/schedule/service.ts | 4 - ...er-history-compatibility-ownership.test.ts | 1 - packages/server/src/server/session.ts | 164 +---- .../src/server/session.workspaces.test.ts | 129 ---- .../server/test-utils/fake-agent-client.ts | 1 - packages/server/src/shared/messages.ts | 12 - .../src/terminal/terminal-manager.test.ts | 63 -- .../server/src/terminal/terminal-manager.ts | 25 +- 60 files changed, 60 insertions(+), 3592 deletions(-) delete mode 100644 docs/TERMINAL-MODE.md delete mode 100644 packages/app/src/panels/terminal-agent-panel.tsx delete mode 100644 packages/app/src/stores/terminal-agent-reopen-store.ts delete mode 100644 packages/server/src/server/agent/providers/terminal-only-providers.test.ts diff --git a/docs/TERMINAL-MODE.md b/docs/TERMINAL-MODE.md deleted file mode 100644 index 03b6cdcb6..000000000 --- a/docs/TERMINAL-MODE.md +++ /dev/null @@ -1,506 +0,0 @@ -# Terminal Mode — Implementation Plan - -## Concept - -Terminal mode wraps an agent TUI (Claude Code, Codex, OpenCode, Gemini, etc.) in a Paseo agent entity. The agent is tracked in sessions, has a provider/icon/title, and can be archived — but instead of rendering a structured chat view, it renders a terminal running the agent's CLI. - -**Key principle:** `agent.terminal` is a boolean flag on the agent entity. If `true`, the panel renders a terminal. If `false` (default), it renders the current structured AgentStreamView. - -## What Changes - -### Phase 1: Server — Data Model & Provider Interface - -#### 1.1 Add `terminal` flag to `ManagedAgentBase` - -**File:** `packages/server/src/server/agent/agent-manager.ts` - -```typescript -type ManagedAgentBase = { - // ...existing fields... - terminal: boolean; // NEW — if true, this agent renders as a terminal TUI -}; -``` - -This flag is set at creation time and never changes. A terminal agent is always a terminal agent. - -#### 1.2 Add `terminal` to `AgentSessionConfig` - -**File:** `packages/server/src/server/agent/agent-sdk-types.ts` - -```typescript -export type AgentSessionConfig = { - // ...existing fields... - terminal?: boolean; // NEW — create as terminal agent -}; -``` - -#### 1.3 Add `terminal` to the Zod schema - -**File:** `packages/server/src/shared/messages.ts` - -Add to `AgentSessionConfigSchema`: -```typescript -terminal: z.boolean().optional(), -``` - -Add to the `AgentStateSchema` (the wire format sent to clients): -```typescript -terminal: z.boolean().optional(), -``` - -#### 1.4 Add terminal command builders to `AgentClient` - -**File:** `packages/server/src/server/agent/agent-sdk-types.ts` - -```typescript -export type TerminalCommand = { - command: string; - args: string[]; - env?: Record; -}; - -export interface AgentClient { - // ...existing methods... - - /** - * Build the shell command to launch this agent's TUI for a new session. - * Only available if capabilities.supportsTerminalMode is true. - */ - buildTerminalCreateCommand?(config: AgentSessionConfig): TerminalCommand; - - /** - * Build the shell command to resume an existing session in the agent's TUI. - * Only available if capabilities.supportsTerminalMode is true. - */ - buildTerminalResumeCommand?(handle: AgentPersistenceHandle): TerminalCommand; -} -``` - -#### 1.5 Add `supportsTerminalMode` capability - -**File:** `packages/server/src/server/agent/agent-sdk-types.ts` - -```typescript -export type AgentCapabilityFlags = { - // ...existing flags... - supportsTerminalMode: boolean; // NEW -}; -``` - -Also add to the Zod schema in `messages.ts`: -```typescript -supportsTerminalMode: z.boolean(), -``` - -#### 1.6 Implement terminal command builders in providers - -**Claude** (`packages/server/src/server/agent/providers/claude-agent.ts`): -```typescript -buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand { - const args: string[] = []; - if (config.modeId === "bypassPermissions") { - args.push("--dangerously-skip-permissions"); - } - if (config.model) args.push("--model", config.model); - // mode mapping: default → nothing, plan → --plan, etc. - return { command: "claude", args, env: {} }; -} - -buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand { - return { - command: "claude", - args: ["--resume", handle.sessionId], - env: {}, - }; -} -``` - -**Codex** (`packages/server/src/server/agent/providers/codex-app-server-agent.ts`): -```typescript -buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand { - const args: string[] = []; - if (config.model) args.push("--model", config.model); - if (config.modeId) args.push("--approval-mode", config.modeId); - return { command: "codex", args, env: {} }; -} - -buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand { - return { - command: "codex", - args: ["--resume", handle.nativeHandle ?? handle.sessionId], - env: {}, - }; -} -``` - -**OpenCode** (`packages/server/src/server/agent/providers/opencode-agent.ts`): -```typescript -buildTerminalCreateCommand(config: AgentSessionConfig): TerminalCommand { - return { command: "opencode", args: [], env: {} }; -} -// No resume support for OpenCode initially -``` - -Capabilities for each provider: -- Claude: `supportsTerminalMode: true` -- Codex: `supportsTerminalMode: true` -- OpenCode: `supportsTerminalMode: true` - -#### 1.7 Handle terminal agent creation in `AgentManager.createAgent()` - -**File:** `packages/server/src/server/agent/agent-manager.ts` - -When `config.terminal === true`: -1. Do NOT call `client.createSession()` — there is no managed session -2. Call `client.buildTerminalCreateCommand(config)` to get the command -3. Create a `TerminalSession` via `terminalManager.createTerminal()` with the command -4. Register the agent with `terminal: true`, `lifecycle: "idle"`, `session: null` -5. Store the terminal ID in the agent's metadata or a new field -6. The agent's persistence handle can be populated later (the CLI will create its own session file) - -```typescript -async createAgent(config: AgentSessionConfig, agentId?: string, options?: { labels?: Record }): Promise { - const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); - const normalizedConfig = await this.normalizeConfig(config); - const client = this.requireClient(normalizedConfig.provider); - - if (normalizedConfig.terminal) { - // Terminal mode — no managed session, just build the command - const buildCmd = client.buildTerminalCreateCommand; - if (!buildCmd) { - throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`); - } - const cmd = buildCmd.call(client, normalizedConfig); - return this.registerTerminalAgent(resolvedAgentId, normalizedConfig, cmd, { - labels: options?.labels, - }); - } - - // ...existing managed agent flow... -} -``` - -New method `registerTerminalAgent()`: -- Creates a ManagedAgent with `terminal: true` -- Stores the `TerminalCommand` in agent metadata for later use (resume, reconnect) -- Sets lifecycle to `"idle"` (the terminal itself manages the agent's internal state) -- Does NOT have an `AgentSession` — the `session` field is `null` (like closed agents) -- Broadcasts `agent_state` event so clients know about it - -#### 1.8 New message: create terminal for agent - -The client needs a way to request a terminal for a terminal agent. Options: - -**Option A:** Extend `createTerminal` to accept an agent ID. When provided, the server looks up the agent, gets the command, and creates a terminal pre-configured with that command. - -**Option B:** New message type `create_terminal_agent_request` that combines agent creation + terminal creation in one step. - -**Recommendation: Option A.** Add optional `agentId` to `CreateTerminalRequestMessage`. If provided: -- Look up the agent (must be a terminal agent) -- Use the agent's stored command to create the terminal -- Associate the terminal with the agent - -**File:** `packages/server/src/shared/messages.ts` - -```typescript -const CreateTerminalRequestMessageSchema = z.object({ - type: z.literal("create_terminal_request"), - cwd: z.string(), - name: z.string().optional(), - agentId: z.string().optional(), // NEW — if provided, create terminal for this terminal agent - requestId: z.string(), -}); -``` - -#### 1.9 Terminal → Agent lifecycle binding - -When a terminal associated with a terminal agent exits: -- Set agent lifecycle to `"closed"` -- Attempt to detect the agent's session file for persistence handle -- Broadcast state update - -When a terminal agent is opened from the sessions page: -- Server calls `buildTerminalResumeCommand(handle)` if persistence handle exists -- Otherwise calls `buildTerminalCreateCommand(config)` -- Creates a new terminal with that command - -#### 1.10 Extend `createTerminal()` to support command + args - -**File:** `packages/server/src/terminal/terminal.ts` - -```typescript -export interface CreateTerminalOptions { - cwd: string; - shell?: string; - env?: Record; - rows?: number; - cols?: number; - name?: string; - command?: string; // NEW — if provided, run this instead of shell - args?: string[]; // NEW — arguments for command -} -``` - -In `createTerminal()`: -```typescript -const spawnCommand = options.command ?? shell; -const spawnArgs = options.command ? (options.args ?? []) : []; - -const ptyProcess = pty.spawn(spawnCommand, spawnArgs, { - name: "xterm-256color", - cols, rows, cwd, - env: { ...process.env, ...env, TERM: "xterm-256color" }, -}); -``` - ---- - -### Phase 2: App — Draft UI & Terminal Toggle - -#### 2.1 Add terminal toggle to draft tab - -**File:** `packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx` - -Add a toggle switch in the draft UI: **"Chat" / "Terminal"** - -State: -```typescript -const [isTerminalMode, setIsTerminalMode] = useState(false); -``` - -The toggle should be persistent per draft (stored in the draft store or as a preference). - -When terminal mode is selected: -- The provider/model pickers still work (same UI) -- The mode picker still works -- The "send" button label changes to "Launch" or "Start" -- The initial prompt input may be hidden or optional (terminal agents don't need an initial prompt — the user types directly into the TUI) - -#### 2.2 Modify agent creation to pass `terminal: true` - -When the user submits a draft in terminal mode: - -```typescript -const config: AgentSessionConfig = { - provider: selectedProvider, - cwd: workspaceId, - model: selectedModel, - modeId: selectedMode, - terminal: true, // NEW -}; -``` - -The `CreateAgentRequestMessage` already carries `config`, so no new wire message needed. - -#### 2.3 Terminal mode in `AgentStatusBar` - -**File:** `packages/app/src/components/agent-status-bar.tsx` - -When rendering a draft's status bar, filter the capability: -- If `supportsTerminalMode` is false for a provider, disable the terminal toggle when that provider is selected -- The terminal toggle can live next to the provider selector or as a segmented control above the input area - ---- - -### Phase 3: App — Agent Panel Rendering - -#### 3.1 Branch rendering in `AgentPanel` - -**File:** `packages/app/src/panels/agent-panel.tsx` - -```typescript -function AgentPanelContent({ agentId, ... }) { - const agent = useAgentState(agentId); - - if (agent?.terminal) { - return ; - } - - return ; -} -``` - -#### 3.2 New component: `TerminalAgentPanel` - -**File:** `packages/app/src/panels/terminal-agent-panel.tsx` (new file) - -This component: -1. Gets the terminal ID associated with the agent (from agent metadata or a new field) -2. Renders a `TerminalPane` connected to that terminal session -3. If no terminal exists yet (agent from sessions page), requests terminal creation via `createTerminal({ agentId })` -4. Handles terminal exit → agent close lifecycle - -Essentially: it's the existing `TerminalPane` component, but associated with an agent entity instead of a standalone terminal. - -#### 3.3 Tab descriptor for terminal agents - -**File:** `packages/app/src/panels/agent-panel.tsx` → `useAgentPanelDescriptor` - -The tab descriptor (icon, label) already comes from the agent's provider. Terminal agents get the same icon/label as managed agents — that's the whole point. No changes needed here unless we want a "terminal" badge. - -Optional: add a small terminal icon badge to distinguish terminal agents from managed agents in the tab bar. - ---- - -### Phase 4: Sessions Page - -#### 4.1 Terminal agents appear in sessions list - -No changes needed for listing — terminal agents are real agents, they already show up via `AgentManager.getAgents()`. - -#### 4.2 Opening a terminal agent from sessions - -**File:** `packages/app/src/screens/sessions/` (sessions screen) - -When the user clicks a closed terminal agent: -1. Server calls `buildTerminalResumeCommand(handle)` if persistence exists -2. Creates a new terminal with that command -3. Opens agent tab in workspace - -If no persistence handle (session was ephemeral), show "Start new session" which calls `buildTerminalCreateCommand(config)`. - ---- - -### Phase 5: CLI Gating - -#### 5.1 `paseo send` — error for terminal agents - -**File:** `packages/cli/src/commands/send.ts` - -```typescript -if (agent.terminal) { - throw new Error("Cannot send messages to terminal agents. Open the terminal in the UI instead."); -} -``` - -#### 5.2 `paseo run` — could support `--terminal` flag (future) - -Not in v1. For now, `paseo run` always creates managed agents. Terminal mode is UI-only. - -#### 5.3 `paseo ls` — show terminal flag - -Add a `terminal` column or badge to `paseo ls` output so users can distinguish terminal agents. - ---- - -## Wire Format Changes Summary - -### AgentSessionConfig (create request) -```diff - { - provider: string; - cwd: string; - model?: string; - modeId?: string; -+ terminal?: boolean; - ... - } -``` - -### AgentState (server → client) -```diff - { - id: string; - provider: string; - lifecycle: string; -+ terminal?: boolean; - ... - } -``` - -### AgentCapabilityFlags -```diff - { - supportsStreaming: boolean; - supportsSessionPersistence: boolean; -+ supportsTerminalMode: boolean; - ... - } -``` - -### CreateTerminalRequest -```diff - { - type: "create_terminal_request"; - cwd: string; - name?: string; -+ agentId?: string; - requestId: string; - } -``` - -### TerminalCommand (new type) -```typescript -{ - command: string; - args: string[]; - env?: Record; -} -``` - ---- - -## Implementation Phases & Agent Assignments - -### Phase 1: Server data model (1 agent) -- Add `terminal` to types, schemas, and agent manager -- Add `TerminalCommand` type and `buildTerminalCreateCommand`/`buildTerminalResumeCommand` to `AgentClient` -- Add `supportsTerminalMode` capability flag -- Extend `createTerminal()` to support command+args -- Implement terminal agent creation flow in `AgentManager` -- Wire terminal exit → agent close lifecycle -- Implement command builders in Claude, Codex, OpenCode providers -- Typecheck must pass - -### Phase 2: App draft UI + terminal toggle (1 agent) -- Add terminal mode toggle to `workspace-draft-agent-tab.tsx` -- Pass `terminal: true` in config when toggle is on -- Filter toggle based on `supportsTerminalMode` capability -- Persist toggle preference -- Typecheck must pass - -### Phase 3: App panel rendering (1 agent) -- Branch `AgentPanelContent` on `agent.terminal` -- Create `TerminalAgentPanel` component -- Handle terminal creation for agent on open -- Handle terminal exit lifecycle -- Typecheck must pass - -### Phase 4: Sessions page + CLI gating (1 agent) -- Terminal agents show in sessions with badge -- Opening from sessions resumes or creates terminal -- `paseo send` errors for terminal agents -- `paseo ls` shows terminal badge -- Typecheck must pass - ---- - -## Feature Interaction Guards - -Terminal agents are explicitly excluded from automated dispatch paths: - -- **LoopService**: `buildWorkerConfig` and `buildVerifierConfig` set `terminal: false` -- **ScheduleService**: `executeSchedule` rejects terminal agents with a clear error for agent-targeted schedules; new-agent schedules set `terminal: false` -- **Voice mode / `handleSendAgentMessage`**: Guarded by `getStructuredSendRejection()` before send -- **CLI `paseo send`**: Returns error for terminal agents -- **MCP agent creation**: Programmatic paths don't pass `terminal: true` - -All session-specific operations (`runAgent`, `streamAgent`, `setMode`, `cancelAgentRun`, etc.) are guarded by the centralized `requireSessionAgent()` which rejects terminal agents. - -## What This Does NOT Change - -- The existing managed agent flow is untouched -- Terminal sessions (non-agent) still work as before -- The `AgentSession` interface is unchanged -- Mobile experience is unchanged (terminal mode is web/desktop only for now) -- No new providers are added (existing providers gain terminal command builders) -- No hooks, no env injection, no process tree detection (v1 keeps it simple) - -## Future Work (Not In This Plan) - -- Auto-detect agent type from PTY process tree (for standalone terminals) -- "Convert to chat" / "Convert to terminal" actions -- Terminal title/icon from OSC sequences -- `paseo run --terminal` CLI support -- Mobile terminal mode (if xterm.js works well enough on mobile web) -- Gemini / Aider / Goose provider definitions (terminal-only providers) diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts index 798201137..d446396b2 100644 --- a/packages/app/e2e/helpers/launcher.ts +++ b/packages/app/e2e/helpers/launcher.ts @@ -89,12 +89,6 @@ export async function waitForLauncherPanel(page: Page): Promise { }); } -/** Assert that the launcher panel shows provider tiles under "Terminal Agents". */ -export async function assertProviderTilesVisible(page: Page): Promise { - await expect(page.getByText("Terminal Agents", { exact: true }).first()).toBeVisible({ - timeout: 10_000, - }); -} /** Assert the launcher panel has a "New Chat" tile. */ export async function assertNewChatTileVisible(page: Page): Promise { @@ -122,12 +116,6 @@ export async function clickTerminal(page: Page): Promise { await button.click(); } -/** Click a provider tile by label (e.g. "Claude Code", "Codex"). */ -export async function clickProviderTile(page: Page, providerLabel: string): Promise { - const tile = page.getByRole("button", { name: providerLabel }).first(); - await expect(tile).toBeVisible({ timeout: 10_000 }); - await tile.click(); -} // ─── Tab title assertions ────────────────────────────────────────────────── diff --git a/packages/app/e2e/helpers/workspace-lifecycle.ts b/packages/app/e2e/helpers/workspace-lifecycle.ts index e0aa30dec..792264b74 100644 --- a/packages/app/e2e/helpers/workspace-lifecycle.ts +++ b/packages/app/e2e/helpers/workspace-lifecycle.ts @@ -1,11 +1,9 @@ import { expect, type Page } from "@playwright/test"; import { clickNewChat, - clickProviderTile, clickTerminal, countTabsOfKind, getTabTestIds, - waitForTabWithTitle, } from "./launcher"; import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf"; @@ -35,13 +33,6 @@ export async function createStandaloneTerminalFromLauncher(page: Page): Promise< await expect.poll(async () => (await getTabTestIds(page)).length).toBe(tabIdsBefore.length); } -export async function createTerminalAgentFromLauncher(page: Page, providerLabel: string): Promise { - await clickProviderTile(page, providerLabel); - await expect(page.getByTestId("terminal-agent-loading")).toHaveCount(0, { timeout: 30_000 }); - await expect(terminalSurface(page)).toBeVisible({ timeout: 30_000 }); - await waitForTabWithTitle(page, /new agent/i); -} - export async function createAgentChatFromLauncher(page: Page): Promise { await clickNewChat(page); await expect(composerInput(page)).toBeVisible({ timeout: 15_000 }); diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts index 1dc09024a..91bc00d6b 100644 --- a/packages/app/e2e/helpers/workspace-setup.ts +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -186,26 +186,6 @@ export async function createChatAgentFromWorkspaceSetup( await dialog.getByRole("button", { name: "Send message" }).click(); } -export async function createTerminalAgentFromWorkspaceSetup( - page: Page, - input: { providerLabel: string; prompt?: string }, -): Promise { - const dialog = workspaceSetupDialog(page); - await dialog.getByRole("button", { name: /Terminal Agent/i }).click(); - - const providerButton = dialog.getByRole("button", { name: new RegExp(`^${input.providerLabel}$`, "i") }).first(); - await expect(providerButton).toBeVisible({ timeout: 15_000 }); - await providerButton.click(); - - if (input.prompt) { - const promptInput = dialog.getByPlaceholder("Optional").first(); - await expect(promptInput).toBeVisible({ timeout: 15_000 }); - await promptInput.fill(input.prompt); - } - - await dialog.getByRole("button", { name: "Launch" }).click(); -} - export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Promise { await workspaceSetupDialog(page) .getByRole("button", { name: /^Terminal Create the workspace/i }) diff --git a/packages/app/e2e/launcher-tab.spec.ts b/packages/app/e2e/launcher-tab.spec.ts index 343d5e7cd..a12946442 100644 --- a/packages/app/e2e/launcher-tab.spec.ts +++ b/packages/app/e2e/launcher-tab.spec.ts @@ -3,7 +3,6 @@ import { createTempGitRepo } from "./helpers/workspace"; import { gotoWorkspace, waitForLauncherPanel, - assertProviderTilesVisible, assertNewChatTileVisible, assertTerminalTileVisible, assertSingleNewTabButton, @@ -11,7 +10,6 @@ import { pressNewTabShortcut, clickNewChat, clickTerminal, - clickProviderTile, countTabsOfKind, getTabTestIds, waitForTabWithTitle, @@ -49,7 +47,7 @@ test.afterAll(async () => { // ═══════════════════════════════════════════════════════════════════════════ test.describe("Launcher tab", () => { - test("Cmd+T opens launcher panel with New Chat, Terminal, and provider tiles", async ({ + test("Cmd+T opens launcher panel with New Chat and Terminal tiles", async ({ page, }) => { await gotoWorkspace(page, workspaceId); @@ -59,7 +57,6 @@ test.describe("Launcher tab", () => { await waitForLauncherPanel(page); await assertNewChatTileVisible(page); await assertTerminalTileVisible(page); - await assertProviderTilesVisible(page); }); test("opening two new tabs creates two launcher tabs", async ({ page }) => { @@ -126,61 +123,6 @@ test.describe("Launcher tab", () => { expect(terminalTabs.length).toBeGreaterThanOrEqual(1); }); - test("clicking a provider tile replaces launcher with terminal agent tab", async ({ page }) => { - test.setTimeout(45_000); - await gotoWorkspace(page, workspaceId); - - await clickNewTabButton(page); - await waitForLauncherPanel(page); - - const tabsBefore = await getTabTestIds(page); - - // Click the first visible provider tile under "Terminal Agents" - const providerTiles = page.locator('[role="button"]').filter({ - has: page.locator("text=Terminal Agents").locator("..").locator(".."), - }); - - // Try clicking any provider tile — find the first one after the "Terminal Agents" label - const terminalAgentsLabel = page.getByText("Terminal Agents", { exact: true }).first(); - await expect(terminalAgentsLabel).toBeVisible({ timeout: 10_000 }); - - // The provider grid follows the label. Click the first provider tile. - const providerGrid = terminalAgentsLabel.locator("~ *").first(); - const firstProvider = providerGrid.getByRole("button").first(); - if (await firstProvider.isVisible().catch(() => false)) { - await firstProvider.click(); - } else { - // Fallback: look for any provider button after the section label - const allButtons = page.getByRole("button"); - const count = await allButtons.count(); - let clicked = false; - for (let i = 0; i < count; i++) { - const btn = allButtons.nth(i); - const text = await btn.innerText().catch(() => ""); - // Skip known non-provider buttons - if (["New Chat", "Terminal", "More", "+"].includes(text.trim())) continue; - if (!text.trim()) continue; - await btn.click(); - clicked = true; - break; - } - if (!clicked) { - test.skip(true, "No provider tiles available"); - return; - } - } - - // Should see an agent panel (terminal surface or agent stream) - const agentOrTerminal = page.locator( - '[data-testid="terminal-surface"], [data-testid^="agent-"]', - ); - await expect(agentOrTerminal.first()).toBeVisible({ timeout: 30_000 }); - - // Tab count stays the same (replaced, not added) - const tabsAfter = await getTabTestIds(page); - expect(tabsAfter.length).toBe(tabsBefore.length); - }); - test("tab bar shows a single + button per pane", async ({ page }) => { await gotoWorkspace(page, workspaceId); await assertSingleNewTabButton(page); diff --git a/packages/app/e2e/workspace-lifecycle.spec.ts b/packages/app/e2e/workspace-lifecycle.spec.ts index e7c183f6d..daac9166c 100644 --- a/packages/app/e2e/workspace-lifecycle.spec.ts +++ b/packages/app/e2e/workspace-lifecycle.spec.ts @@ -10,7 +10,6 @@ import { createTempGitRepo } from "./helpers/workspace"; import { createAgentChatFromLauncher, createStandaloneTerminalFromLauncher, - createTerminalAgentFromLauncher, expectTerminalCwd, } from "./helpers/workspace-lifecycle"; import { connectWorkspaceSetupClient, seedProjectForWorkspaceSetup } from "./helpers/workspace-setup"; @@ -22,30 +21,6 @@ test.describe("Workspace lifecycle", () => { test.describe.configure({ retries: 1 }); test.describe("Main checkout", () => { - test("creates a terminal agent via provider tile", async ({ page }) => { - test.setTimeout(60_000); - - const client = await connectWorkspaceSetupClient(); - const repo = await createTempGitRepo("lifecycle-main-agent-"); - - try { - await seedProjectForWorkspaceSetup(client, repo.path); - const workspaceResult = await client.openProject(repo.path); - if (!workspaceResult.workspace) { - throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`); - } - const workspaceId = String(workspaceResult.workspace.id); - - await gotoWorkspace(page, workspaceId); - await clickNewTabButton(page); - await waitForLauncherPanel(page); - await createTerminalAgentFromLauncher(page, "Claude"); - } finally { - await client.close(); - await repo.cleanup(); - } - }); - test("creates an agent chat via New Chat", async ({ page }) => { test.setTimeout(60_000); @@ -97,53 +72,6 @@ test.describe("Workspace lifecycle", () => { }); test.describe("Worktree workspace", () => { - test("creates a terminal agent via provider tile", async ({ page }) => { - test.setTimeout(90_000); - - const client = await connectWorkspaceSetupClient(); - const repo = await createTempGitRepo("lifecycle-wt-agent-"); - const worktreePath = path.join( - "/tmp", - `paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - const branchName = `lifecycle-wt-agent-${Date.now()}`; - let worktreeCreated = false; - - try { - await seedProjectForWorkspaceSetup(client, repo.path); - - execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, { - cwd: repo.path, - stdio: "ignore", - }); - worktreeCreated = true; - - const workspaceResult = await client.openProject(worktreePath); - if (!workspaceResult.workspace) { - throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`); - } - const workspaceId = String(workspaceResult.workspace.id); - - await gotoWorkspace(page, workspaceId); - await clickNewTabButton(page); - await waitForLauncherPanel(page); - await createTerminalAgentFromLauncher(page, "Claude"); - } finally { - if (worktreeCreated) { - try { - execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, { - cwd: repo.path, - stdio: "ignore", - }); - } catch { - // Best-effort cleanup so test failures preserve the original error. - } - } - await client.close(); - await repo.cleanup(); - } - }); - test("creates an agent chat via New Chat", async ({ page }) => { test.setTimeout(90_000); diff --git a/packages/app/e2e/workspace-setup-runtime.spec.ts b/packages/app/e2e/workspace-setup-runtime.spec.ts index ca5a2ab79..813fb7780 100644 --- a/packages/app/e2e/workspace-setup-runtime.spec.ts +++ b/packages/app/e2e/workspace-setup-runtime.spec.ts @@ -5,7 +5,6 @@ import { connectWorkspaceSetupClient, createChatAgentFromWorkspaceSetup, createStandaloneTerminalFromWorkspaceSetup, - createTerminalAgentFromWorkspaceSetup, createWorkspaceFromSidebar, findWorktreeWorkspaceForProject, openHomeWithProject, @@ -109,36 +108,6 @@ test.describe("Workspace setup runtime authority", () => { } }); - test("first terminal agent attaches to the created workspace", async ({ page }) => { - test.setTimeout(90_000); - - const client = await connectWorkspaceSetupClient(); - const repo = await createTempGitRepo("workspace-setup-terminal-agent-"); - - try { - await client.openProject(repo.path); - await openWorkspaceSetupDialogFromSidebar(page, repo.path); - const agentIdsBefore = new Set((await client.fetchAgents()).entries.map((entry) => entry.agent.id)); - - await createTerminalAgentFromWorkspaceSetup(page, { - providerLabel: "Claude", - prompt: `workspace-setup-terminal-agent-${Date.now()}`, - }); - - const workspace = await expectCreatedWorkspaceRoute(client, repo.path); - const agent = await waitForNewWorkspaceAgent( - client, - workspace.workspaceDirectory, - agentIdsBefore, - ); - expect(agent.cwd).toBe(workspace.workspaceDirectory); - expect(agent.cwd).not.toBe(repo.path); - } finally { - await client.close(); - await repo.cleanup(); - } - }); - test("first terminal attaches to the created workspace", async ({ page }) => { test.setTimeout(90_000); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 07457105c..1881b4ce4 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -15,7 +15,7 @@ import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; -import { Archive, SquareTerminal } from "lucide-react-native"; +import { Archive } from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { buildHostAgentDetailRoute } from "@/utils/host-routes"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; @@ -158,12 +158,6 @@ function SessionRow({ > {agent.title || "New session"} - {agent.terminal ? ( - } - /> - ) : null} {agent.archivedAt ? ( state.mergeWorkspaces); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); const setAgents = useSessionStore((state) => state.setAgents); - const [step, setStep] = useState("choose"); - const [terminalPrompt, setTerminalPrompt] = useState(""); + const [step, setStep] = useState<"choose" | "chat">("choose"); const [errorMessage, setErrorMessage] = useState(null); const [createdWorkspace, setCreatedWorkspace] = useState | null>(null); - const [pendingAction, setPendingAction] = useState<"chat" | "terminal-agent" | "terminal" | null>( - null, - ); + const [pendingAction, setPendingAction] = useState<"chat" | "terminal" | null>(null); const serverId = pendingWorkspaceSetup?.serverId ?? ""; const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? ""; @@ -66,13 +57,9 @@ export function WorkspaceSetupDialog() { if (!composerState && pendingWorkspaceSetup) { throw new Error("Workspace setup composer state is required"); } - const { providers: sortedProviders, recordUsage } = useProviderRecency( - composerState?.providerDefinitions ?? [], - ); useEffect(() => { setStep("choose"); - setTerminalPrompt(""); setErrorMessage(null); setCreatedWorkspace(null); setPendingAction(null); @@ -224,58 +211,6 @@ export function WorkspaceSetupDialog() { ], ); - const handleCreateTerminalAgent = useCallback(async () => { - try { - setPendingAction("terminal-agent"); - setErrorMessage(null); - const workspace = await ensureWorkspace(); - const connectedClient = withConnectedClient(); - if (!composerState) { - throw new Error("Workspace setup composer state is required"); - } - - const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory; - const agent = await connectedClient.createAgent({ - provider: composerState.selectedProvider, - cwd: workspaceDirectory, - workspaceId: requireWorkspaceRecordId(workspace.id), - terminal: true, - ...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}), - }); - - if (!getIsStillActive()) { - return; - } - - recordUsage(composerState.selectedProvider); - setAgents(serverId, (previous) => { - const next = new Map(previous); - next.set(agent.id, normalizeAgentSnapshot(agent, serverId)); - return next; - }); - navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id }); - } catch (error) { - const message = toErrorMessage(error); - setErrorMessage(message); - toast.error(message); - } finally { - if (getIsStillActive()) { - setPendingAction(null); - } - } - }, [ - composerState, - getIsStillActive, - navigateAfterCreation, - recordUsage, - serverId, - setAgents, - ensureWorkspace, - terminalPrompt, - toast, - withConnectedClient, - ]); - const handleCreateTerminal = useCallback(async () => { try { setPendingAction("terminal"); @@ -344,16 +279,6 @@ export function WorkspaceSetupDialog() { setStep("chat"); }} /> - { - setErrorMessage(null); - setStep("terminal-agent"); - }} - /> ) : null} - {step === "terminal-agent" ? ( - - { - setErrorMessage(null); - setStep("choose"); - }} - /> - - Choose a provider and optionally send an initial prompt. The workspace is created before the terminal agent launches. - - - - {sortedProviders.map((provider) => ( - composerState?.setProviderFromUser(provider.id)} - /> - ))} - - - - Initial prompt - - - - - - - - - ) : null} - {errorMessage ? {errorMessage} : null} ); @@ -531,42 +394,6 @@ function ChoiceCard({ ); } -function ProviderOption({ - provider, - selected, - disabled, - onPress, -}: { - provider: { id: AgentProvider; label: string; description: string }; - selected: boolean; - disabled: boolean; - onPress: () => void; -}) { - const { theme } = useUnistyles(); - const Icon = getProviderIcon(provider.id); - - return ( - [ - styles.providerCard, - selected ? styles.providerCardSelected : null, - (hovered || pressed) && !disabled ? styles.choiceCardHovered : null, - disabled ? styles.cardDisabled : null, - ]} - > - - - - - {provider.label} - - - ); -} - const styles = StyleSheet.create((theme) => ({ header: { gap: theme.spacing[1], @@ -655,69 +482,6 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", backgroundColor: theme.colors.surface2, }, - providerGrid: { - flexDirection: "row", - flexWrap: "wrap", - gap: theme.spacing[2], - }, - providerCard: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - borderWidth: 1, - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface1, - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - }, - providerCardSelected: { - borderColor: theme.colors.accent, - backgroundColor: theme.colors.surface2, - }, - providerIconWrap: { - width: 28, - height: 28, - borderRadius: theme.borderRadius.md, - alignItems: "center", - justifyContent: "center", - backgroundColor: theme.colors.surface2, - }, - providerBody: { - flex: 1, - }, - providerTitle: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - color: theme.colors.foreground, - }, - field: { - gap: theme.spacing[2], - }, - fieldLabel: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - color: theme.colors.foreground, - }, - input: { - minHeight: 80, - borderWidth: 1, - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface1, - color: theme.colors.foreground, - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[3], - textAlignVertical: "top", - fontSize: theme.fontSize.sm, - }, - actions: { - flexDirection: "row", - gap: theme.spacing[2], - }, - actionButton: { - flex: 1, - }, errorText: { fontSize: theme.fontSize.sm, color: theme.colors.destructive, diff --git a/packages/app/src/contexts/session-status-tracking.test.ts b/packages/app/src/contexts/session-status-tracking.test.ts index bce83d25f..d5e98ed3a 100644 --- a/packages/app/src/contexts/session-status-tracking.test.ts +++ b/packages/app/src/contexts/session-status-tracking.test.ts @@ -7,7 +7,6 @@ function createAgent(status: Agent["status"]): Agent { serverId: "server-1", id: "agent-1", provider: "codex", - terminal: false, status, createdAt: new Date(0), updatedAt: new Date(0), @@ -20,7 +19,6 @@ function createAgent(status: Agent["status"]): Agent { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, - supportsTerminalMode: false, }, currentModeId: null, availableModes: [], diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index fd189b3ff..137985743 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -17,7 +17,6 @@ function createAgent(id: string): Agent { serverId: "server-1", id, provider: "claude", - terminal: false, status: "running", createdAt: now, updatedAt: now, @@ -30,7 +29,6 @@ function createAgent(id: string): Agent { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, - supportsTerminalMode: false, }, currentModeId: null, availableModes: [], diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index 612dbbd85..39ecc4f87 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -6,13 +6,6 @@ export interface AgentScreenAgent { status: "initializing" | "idle" | "running" | "error" | "closed"; cwd: string; lastError?: string | null; - terminalExit?: { - command: string; - message: string; - exitCode: number | null; - signal: number | null; - outputLines: string[]; - } | null; projectPlacement?: { checkout?: { cwd?: string; diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 403ac1d20..f77736e85 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -65,7 +65,6 @@ export function useAggregatedAgents(options?: { serverId, serverLabel, title: agent.title ?? null, - terminal: agent.terminal, status: agent.status, lastActivityAt: agent.lastActivityAt, cwd: agent.cwd, diff --git a/packages/app/src/hooks/use-all-agents-list.test.ts b/packages/app/src/hooks/use-all-agents-list.test.ts index cc9ebda42..1353dcd62 100644 --- a/packages/app/src/hooks/use-all-agents-list.test.ts +++ b/packages/app/src/hooks/use-all-agents-list.test.ts @@ -8,7 +8,6 @@ function makeAgent(input?: Partial): Agent { serverId: "server-1", id: input?.id ?? "agent-1", provider: input?.provider ?? "codex", - terminal: input?.terminal ?? false, status: input?.status ?? "idle", createdAt: input?.createdAt ?? timestamp, updatedAt: input?.updatedAt ?? timestamp, @@ -21,7 +20,6 @@ function makeAgent(input?: Partial): Agent { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, - supportsTerminalMode: false, }, currentModeId: input?.currentModeId ?? null, availableModes: input?.availableModes ?? [], diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index 6bf0c0f27..d7c24e681 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -19,7 +19,6 @@ function toAggregatedAgent(params: { serverId: params.serverId, serverLabel: params.serverLabel, title: source.title ?? null, - terminal: source.terminal, status: source.status, lastActivityAt: source.lastActivityAt, cwd: source.cwd, diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index 74f848e96..173aa9ba1 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -10,7 +10,6 @@ function makeAgent(overrides: Partial = {}): Agent { serverId: "server-a", id: "agent-1", provider: "codex", - terminal: false, status: "running", createdAt: new Date("2026-04-01T03:00:00.000Z"), updatedAt: new Date("2026-04-01T03:00:00.000Z"), @@ -23,7 +22,6 @@ function makeAgent(overrides: Partial = {}): Agent { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, - supportsTerminalMode: false, }, currentModeId: null, availableModes: [], diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 0f0110442..a9a4f58c0 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -26,7 +26,6 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useStableEvent } from "@/hooks/use-stable-event"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; -import { TerminalAgentPanel } from "@/panels/terminal-agent-panel"; import { useHostRuntimeClient, useHostRuntimeConnectionStatus, @@ -250,11 +249,9 @@ function AgentPanelBody({ return { serverId: agent?.serverId ?? null, id: agent?.id ?? null, - terminal: agent?.terminal ?? false, status: agent?.status ?? null, cwd: agent?.cwd ?? null, lastError: agent?.lastError ?? null, - terminalExit: agent?.terminalExit ?? null, archivedAt: agent?.archivedAt ?? null, }; }), @@ -378,7 +375,6 @@ function AgentPanelBody({ status: agentState.status, cwd: agentState.cwd, lastError: agentState.lastError ?? null, - terminalExit: agentState.terminalExit ?? null, projectPlacement, } : null; @@ -395,27 +391,6 @@ function AgentPanelBody({ const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId })); - if (agentState.terminal) { - return ( - - - - {isArchivingCurrentAgent ? ( - - - Archiving agent... - Please wait while we archive this agent. - - ) : null} - - ); - } - return ( state.setAgents); const [pendingAction, setPendingAction] = useState(null); const [errorMessage, setErrorMessage] = useState(null); invariant(target.kind === "launcher", "LauncherPanel requires launcher target"); - const visibleProviders = useMemo( - () => providers.slice(0, MAX_VISIBLE_PROVIDER_TILES), - [providers], - ); - const overflowProviders = useMemo( - () => providers.slice(MAX_VISIBLE_PROVIDER_TILES), - [providers], - ); - - const launchTerminalAgent = useCallback( - async (providerId: AgentProvider) => { - if (!client || !isConnected || !workspaceDirectory) { - setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected"); - return; - } - if (!workspaceAuthority.ok) { - setErrorMessage(workspaceAuthority.message); - return; - } - const persistedWorkspaceId = requireWorkspaceRecordId(workspaceAuthority.authority.workspaceId); - - setPendingAction(providerId); - setErrorMessage(null); - - try { - const agent = await client.createAgent({ - provider: providerId, - cwd: workspaceDirectory, - workspaceId: persistedWorkspaceId, - terminal: true, - }); - recordUsage(providerId); - // Retarget first so the launcher converts in place before session reconciliation - // can materialize the new agent as a separate tab. - retargetCurrentTab({ kind: "agent", agentId: agent.id }); - setAgents(serverId, (previous) => { - const next = new Map(previous); - next.set(agent.id, normalizeAgentSnapshot(agent, serverId)); - return next; - }); - } catch (error) { - setErrorMessage(toErrorMessage(error)); - } finally { - setPendingAction((current) => (current === providerId ? null : current)); - } - }, - [ - client, - isConnected, - recordUsage, - retargetCurrentTab, - serverId, - setAgents, - workspaceAuthority, - workspaceDirectory, - ], - ); - const openDraftTab = useCallback(() => { setErrorMessage(null); setPendingAction("draft"); @@ -187,34 +112,6 @@ function LauncherPanel() { }} /> - - Terminal Agents - - - {visibleProviders.map((provider) => ( - { - void launchTerminalAgent(provider.id); - }} - /> - ))} - - {overflowProviders.length > 0 ? ( - { - void launchTerminalAgent(providerId); - }} - /> - ) : null} - - {errorMessage ? {errorMessage} : null} @@ -272,91 +169,6 @@ function LauncherTile({ ); } -function ProviderTile({ - provider, - disabled, - pending, - onPress, -}: { - provider: { id: string; label: string; description: string }; - disabled: boolean; - pending: boolean; - onPress: () => void; -}) { - const { theme } = useUnistyles(); - const Icon = getProviderIcon(provider.id); - - return ( - [ - styles.providerTile, - (hovered || pressed) && !disabled ? styles.tileInteractive : null, - disabled ? styles.tileDisabled : null, - ]} - > - - {pending ? ( - - ) : ( - - )} - - {provider.label} - - ); -} - -function ViewAllProvidersTile({ - providers, - disabled, - pendingProviderId, - onSelectProvider, -}: { - providers: Array<{ id: string; label: string; description: string }>; - disabled: boolean; - pendingProviderId: string | null; - onSelectProvider: (providerId: AgentProvider) => void; -}) { - const { theme } = useUnistyles(); - - return ( - - - {({ open }) => ( - <> - - - - More - - {open ? : null} - - )} - - - {providers.map((provider) => { - const Icon = getProviderIcon(provider.id); - return ( - onSelectProvider(provider.id as AgentProvider)} - leading={} - status={pendingProviderId === provider.id ? "pending" : "idle"} - pendingLabel={`Launching ${provider.label}...`} - > - {provider.label} - - ); - })} - - - ); -} - export const launcherPanelRegistration: PanelRegistration<"launcher"> = { kind: "launcher", component: LauncherPanel, @@ -431,53 +243,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, }, - sectionLabel: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.medium, - color: theme.colors.foregroundMuted, - textTransform: "uppercase", - letterSpacing: 0.6, - }, - providerGrid: { - flexDirection: "row", - flexWrap: "wrap", - gap: theme.spacing[2], - }, - providerTile: { - position: "relative", - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.borderAccent, - backgroundColor: theme.colors.surface1, - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - }, - providerIconWrap: { - width: 28, - height: 28, - borderRadius: theme.borderRadius.md, - alignItems: "center", - justifyContent: "center", - backgroundColor: theme.colors.surface2, - }, - providerLabel: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - color: theme.colors.foreground, - }, - dropdownOutline: { - position: "absolute", - top: 0, - right: 0, - bottom: 0, - left: 0, - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.accent, - }, errorText: { fontSize: theme.fontSize.sm, color: theme.colors.destructive, diff --git a/packages/app/src/panels/terminal-agent-panel.tsx b/packages/app/src/panels/terminal-agent-panel.tsx deleted file mode 100644 index f71d73ae0..000000000 --- a/packages/app/src/panels/terminal-agent-panel.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import { useIsFocused } from "@react-navigation/native"; -import { useEffect, useRef, useState } from "react"; -import { ActivityIndicator, Text, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import type { DaemonClient } from "@server/client/daemon-client"; -import { TerminalPane } from "@/components/terminal-pane"; -import { Fonts } from "@/constants/theme"; -import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { usePaneContext } from "@/panels/pane-context"; -import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; -import { - buildTerminalAgentReopenKey, - useTerminalAgentReopenStore, -} from "@/stores/terminal-agent-reopen-store"; -import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; - -type TerminalAgentPanelProps = { - serverId: string; - client: DaemonClient; - agent: AgentScreenAgent; - isPaneFocused: boolean; -}; - -function toErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -function getTerminalExitTitle(agent: AgentScreenAgent): string { - const exitCode = agent.terminalExit?.exitCode; - const signal = agent.terminalExit?.signal; - if ( - agent.status === "error" || - (exitCode != null && exitCode !== 0) || - signal != null - ) { - return "Terminal session failed"; - } - return "Terminal session ended"; -} - -function getTerminalExitMessage(agent: AgentScreenAgent): string { - const summary = agent.terminalExit?.message?.trim(); - if (summary) { - return summary; - } - const lastError = agent.lastError?.trim(); - if (lastError) { - return lastError; - } - return "Reopen the agent from the sessions list to start it again."; -} - -function isCleanTerminalExit(agent: AgentScreenAgent): boolean { - return agent.status === "closed" && agent.terminalExit?.exitCode === 0 && agent.terminalExit.signal == null; -} - -export function TerminalAgentPanel({ - serverId, - client, - agent, - isPaneFocused, -}: TerminalAgentPanelProps) { - const isScreenFocused = useIsFocused(); - const { theme } = useUnistyles(); - const { tabId, workspaceId } = usePaneContext(); - const { archiveAgent } = useArchiveAgent(); - const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); - const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); - const [terminalId, setTerminalId] = useState(null); - const [isCreating, setIsCreating] = useState(false); - const [createError, setCreateError] = useState(null); - const [didExitInPanel, setDidExitInPanel] = useState(false); - const reopenKey = buildTerminalAgentReopenKey({ serverId, agentId: agent.id }); - const reopenIntentVersion = useTerminalAgentReopenStore((state) => - reopenKey ? (state.reopenIntentVersionByAgentKey[reopenKey] ?? 0) : 0, - ); - - // Refs for effect guards — these values gate whether the creation effect - // should run, but changes to them should NOT re-trigger the effect. - const isCreatingRef = useRef(false); - const didExitRef = useRef(false); - const lastHandledReopenIntentRef = useRef(reopenIntentVersion); - const isAutoClosingRef = useRef(false); - - useEffect(() => { - setTerminalId(null); - setIsCreating(false); - setCreateError(null); - setDidExitInPanel(false); - isCreatingRef.current = false; - didExitRef.current = false; - }, [agent.id, serverId]); - - useEffect(() => { - if (reopenIntentVersion <= lastHandledReopenIntentRef.current) { - return; - } - - lastHandledReopenIntentRef.current = reopenIntentVersion; - if (!didExitRef.current && !didExitInPanel && !createError) { - return; - } - - didExitRef.current = false; - setDidExitInPanel(false); - setCreateError(null); - }, [createError, didExitInPanel, reopenIntentVersion]); - - useEffect(() => { - if (!terminalId) { - return; - } - return client.on("terminal_stream_exit", (message) => { - if (message.type !== "terminal_stream_exit" || message.payload.terminalId !== terminalId) { - return; - } - setTerminalId((current) => (current === message.payload.terminalId ? null : current)); - setDidExitInPanel(true); - didExitRef.current = true; - }); - }, [client, terminalId]); - - useEffect(() => { - if (!isCleanTerminalExit(agent) || isAutoClosingRef.current) { - return; - } - - const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - if (!workspaceKey) { - return; - } - - isAutoClosingRef.current = true; - void archiveAgent({ serverId, agentId: agent.id }) - .then(() => { - unpinWorkspaceAgent(workspaceKey, agent.id); - closeWorkspaceTab(workspaceKey, tabId); - }) - .finally(() => { - isAutoClosingRef.current = false; - }); - }, [ - agent, - archiveAgent, - closeWorkspaceTab, - serverId, - tabId, - unpinWorkspaceAgent, - workspaceId, - ]); - - // Create the terminal when the panel becomes visible and no terminal exists yet. - // Guards (isCreatingRef, didExitRef) are refs to avoid re-triggering the effect - // when their values change — we only want this to fire on genuine state transitions - // (focus change, terminal cleared, agent change). - useEffect(() => { - if ( - !isScreenFocused || - !isPaneFocused || - terminalId || - isCreatingRef.current || - didExitRef.current - ) { - return; - } - - let cancelled = false; - isCreatingRef.current = true; - setIsCreating(true); - setCreateError(null); - - void client - .createTerminal(agent.cwd, undefined, undefined, { agentId: agent.id }) - .then((payload) => { - if (cancelled) { - return; - } - if (payload.error || !payload.terminal) { - setCreateError(payload.error ?? "Failed to open terminal"); - return; - } - setTerminalId(payload.terminal.id); - }) - .catch((error) => { - if (cancelled) { - return; - } - setCreateError(toErrorMessage(error)); - }) - .finally(() => { - if (!cancelled) { - isCreatingRef.current = false; - setIsCreating(false); - } - }); - - return () => { - cancelled = true; - }; - }, [agent.cwd, agent.id, client, isPaneFocused, isScreenFocused, terminalId]); - - if (!isScreenFocused) { - return ; - } - - if (terminalId) { - return ( - - ); - } - - if (isCreating) { - return ( - - - Opening terminal… - - ); - } - - if (createError) { - return ( - - Failed to open terminal - {createError} - - ); - } - - if (didExitInPanel || agent.status === "closed" || agent.status === "error") { - const terminalExit = agent.terminalExit ?? null; - const exitMeta = - terminalExit?.exitCode != null - ? `Exit code ${terminalExit.exitCode}` - : terminalExit?.signal != null - ? `Signal ${terminalExit.signal}` - : null; - return ( - - {getTerminalExitTitle(agent)} - {getTerminalExitMessage(agent)} - {terminalExit ? ( - - {exitMeta ? {exitMeta} : null} - {terminalExit.outputLines.length > 0 ? ( - {terminalExit.outputLines.join("\n")} - ) : null} - - ) : null} - Reopen the agent from the sessions list to start it again. - - ); - } - - return ( - - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.surface0, - }, - state: { - flex: 1, - alignItems: "center", - justifyContent: "center", - gap: theme.spacing[3], - paddingHorizontal: theme.spacing[6], - backgroundColor: theme.colors.surface0, - }, - title: { - fontSize: theme.fontSize.lg, - color: theme.colors.foreground, - textAlign: "center", - }, - message: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - textAlign: "center", - }, - detailsCard: { - width: "100%", - maxWidth: 560, - padding: theme.spacing[4], - gap: theme.spacing[2], - borderRadius: theme.spacing[3], - backgroundColor: theme.colors.surface1, - borderWidth: StyleSheet.hairlineWidth, - borderColor: theme.colors.border, - }, - detailsLabel: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - textTransform: "uppercase", - letterSpacing: 0.4, - }, - output: { - fontSize: theme.fontSize.sm, - color: theme.colors.foreground, - fontFamily: Fonts.mono, - lineHeight: 20, - }, -})); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 5d4ff7fe1..5afd566c4 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -134,7 +134,6 @@ function makeFetchAgentsEntry(input: { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, - supportsTerminalMode: false, }, currentModeId: null, availableModes: [], @@ -1182,7 +1181,6 @@ describe("HostRuntimeStore", () => { const staleAgent: Agent = { ...stale, serverId: host.serverId, - terminal: false, createdAt: new Date(stale.createdAt), updatedAt: new Date(stale.updatedAt), lastUserMessageAt: null, diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 60c265d55..f82a48ad7 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -66,7 +66,6 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, - supportsTerminalMode: false, }; const PROVIDER_DEFINITION_MAP = new Map( AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]), @@ -832,7 +831,6 @@ function DraftAgentScreenContent({ serverId, id: draftAgentIdRef.current, provider, - terminal: false, status: "running", createdAt: now, updatedAt: now, diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts index 16fb2cc3b..7d03db8ac 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts @@ -19,7 +19,6 @@ function makeAgent(input: { serverId: "srv", id: input.id, provider: "codex", - terminal: false, status: "idle", createdAt, updatedAt: createdAt, @@ -32,7 +31,6 @@ function makeAgent(input: { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, - supportsTerminalMode: false, }, currentModeId: null, availableModes: [], @@ -163,9 +161,9 @@ describe("workspace agent visibility", () => { it("matches workspace agents using the workspace directory even when the route uses a numeric workspace id", () => { const sessionAgents = new Map([ [ - "terminal-agent", + "recent-agent", makeAgent({ - id: "terminal-agent", + id: "recent-agent", cwd: "/tmp/workspace-lifecycle-main", }), ], @@ -176,8 +174,8 @@ describe("workspace agent visibility", () => { workspaceDirectory: "/tmp/workspace-lifecycle-main", }); - expect(result.activeAgentIds).toEqual(new Set(["terminal-agent"])); - expect(result.knownAgentIds).toEqual(new Set(["terminal-agent"])); + expect(result.activeAgentIds).toEqual(new Set(["recent-agent"])); + expect(result.knownAgentIds).toEqual(new Set(["recent-agent"])); }); describe("workspaceAgentVisibilityEqual", () => { diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 4647480b9..a03c17e41 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -29,7 +29,6 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, - supportsTerminalMode: false, }; type WorkspaceDraftAgentTabProps = { @@ -134,7 +133,6 @@ export function WorkspaceDraftAgentTab({ serverId, id: tabId, provider: composerState.selectedProvider, - terminal: false, status: "running", createdAt: now, updatedAt: now, diff --git a/packages/app/src/stores/provider-recency-store.ts b/packages/app/src/stores/provider-recency-store.ts index d7014deaa..7ffd8a82f 100644 --- a/packages/app/src/stores/provider-recency-store.ts +++ b/packages/app/src/stores/provider-recency-store.ts @@ -92,7 +92,7 @@ export const useProviderRecencyStore = create()( }, }), { - name: "terminal-agent-provider-recency", + name: "agent-provider-recency", version: PROVIDER_RECENCY_STORE_VERSION, storage: createJSONStorage(() => AsyncStorage), partialize: (state) => ({ diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index d029864f6..f75ff4b54 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -80,13 +80,10 @@ export interface AgentRuntimeInfo { extra?: Record; } -type TerminalExitDetails = NonNullable; - export interface Agent { serverId: string; id: string; provider: AgentProvider; - terminal: boolean; status: AgentLifecycleStatus; createdAt: Date; updatedAt: Date; @@ -100,7 +97,6 @@ export interface Agent { runtimeInfo?: AgentRuntimeInfo; lastUsage?: AgentUsage; lastError?: string | null; - terminalExit?: TerminalExitDetails | null; title: string | null; cwd: string; model: string | null; @@ -1127,7 +1123,6 @@ export const useSessionStore = create()( id: agent.id, serverId, title: agent.title ?? null, - terminal: agent.terminal, status: agent.status, lastActivityAt, cwd: agent.cwd, diff --git a/packages/app/src/stores/terminal-agent-reopen-store.ts b/packages/app/src/stores/terminal-agent-reopen-store.ts deleted file mode 100644 index 63001a73b..000000000 --- a/packages/app/src/stores/terminal-agent-reopen-store.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { create } from "zustand"; - -interface BuildTerminalAgentReopenKeyInput { - serverId: string; - agentId: string; -} - -interface RequestTerminalAgentReopenInput { - serverId: string; - agentId: string; -} - -interface TerminalAgentReopenStore { - reopenIntentVersionByAgentKey: Record; - requestReopen: (input: RequestTerminalAgentReopenInput) => void; -} - -function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -export function buildTerminalAgentReopenKey( - input: BuildTerminalAgentReopenKeyInput, -): string | null { - const serverId = trimNonEmpty(input.serverId); - const agentId = trimNonEmpty(input.agentId); - if (!serverId || !agentId) { - return null; - } - return `${serverId}:${agentId}`; -} - -export const useTerminalAgentReopenStore = create()((set) => ({ - reopenIntentVersionByAgentKey: {}, - requestReopen: ({ serverId, agentId }) => { - const key = buildTerminalAgentReopenKey({ serverId, agentId }); - if (!key) { - return; - } - - set((state) => ({ - reopenIntentVersionByAgentKey: { - ...state.reopenIntentVersionByAgentKey, - [key]: (state.reopenIntentVersionByAgentKey[key] ?? 0) + 1, - }, - })); - }, -})); diff --git a/packages/app/src/types/agent-directory.ts b/packages/app/src/types/agent-directory.ts index a395596ec..b7d9be9b3 100644 --- a/packages/app/src/types/agent-directory.ts +++ b/packages/app/src/types/agent-directory.ts @@ -5,7 +5,6 @@ export type AgentDirectoryEntry = Pick< | "id" | "serverId" | "title" - | "terminal" | "status" | "lastActivityAt" | "cwd" diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index 2361eba85..223c2437a 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -31,7 +31,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: serverId, id: snapshot.id, provider: snapshot.provider, - terminal: snapshot.terminal === true, status: snapshot.status as AgentLifecycleStatus, createdAt, updatedAt, @@ -45,7 +44,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: runtimeInfo: snapshot.runtimeInfo, lastUsage: snapshot.lastUsage, lastError: snapshot.lastError ?? null, - terminalExit: snapshot.terminalExit ?? null, title: snapshot.title ?? null, cwd: snapshot.cwd, model: snapshot.model ?? null, diff --git a/packages/app/src/utils/workspace-navigation.test.ts b/packages/app/src/utils/workspace-navigation.test.ts index afba7fedf..3ce623701 100644 --- a/packages/app/src/utils/workspace-navigation.test.ts +++ b/packages/app/src/utils/workspace-navigation.test.ts @@ -23,10 +23,6 @@ vi.mock("@react-native-async-storage/async-storage", () => { }); import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { - buildTerminalAgentReopenKey, - useTerminalAgentReopenStore, -} from "@/stores/terminal-agent-reopen-store"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; const SERVER_ID = "server-1"; @@ -40,39 +36,17 @@ describe("prepareWorkspaceTab", () => { splitSizesByWorkspace: {}, pinnedAgentIdsByWorkspace: {}, }); - useTerminalAgentReopenStore.setState({ - reopenIntentVersionByAgentKey: {}, - requestReopen: useTerminalAgentReopenStore.getState().requestReopen, - }); }); - it("publishes a reopen intent when requested for an agent tab", () => { + it("opens and focuses an agent tab", () => { const route = prepareWorkspaceTab({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID, target: { kind: "agent", agentId: AGENT_ID }, - requestReopen: true, }); - const reopenKey = buildTerminalAgentReopenKey({ serverId: SERVER_ID, agentId: AGENT_ID }); - expect(reopenKey).toBeTruthy(); expect(route).toBe("/h/server-1/workspace/L3JlcG8vd29ya3RyZWU"); - expect( - useTerminalAgentReopenStore.getState().reopenIntentVersionByAgentKey[reopenKey as string], - ).toBe(1); - }); - - it("does not publish a reopen intent unless explicitly requested", () => { - prepareWorkspaceTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId: AGENT_ID }, - }); - - const reopenKey = buildTerminalAgentReopenKey({ serverId: SERVER_ID, agentId: AGENT_ID }); - expect(reopenKey).toBeTruthy(); - expect( - useTerminalAgentReopenStore.getState().reopenIntentVersionByAgentKey[reopenKey as string], - ).toBeUndefined(); + const key = "server-1:/repo/worktree"; + expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toHaveLength(1); }); }); diff --git a/packages/app/src/utils/workspace-navigation.ts b/packages/app/src/utils/workspace-navigation.ts index a232288f5..1e6458c51 100644 --- a/packages/app/src/utils/workspace-navigation.ts +++ b/packages/app/src/utils/workspace-navigation.ts @@ -1,7 +1,6 @@ import { router } from "expo-router"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { generateDraftId } from "@/stores/draft-keys"; -import { useTerminalAgentReopenStore } from "@/stores/terminal-agent-reopen-store"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget, @@ -13,7 +12,6 @@ interface PrepareWorkspaceTabInput { workspaceId: string; target: WorkspaceTabTarget; pin?: boolean; - requestReopen?: boolean; } interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput { @@ -45,13 +43,6 @@ export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string { useWorkspaceLayoutStore.getState().pinAgent(key, target.agentId); } - if (input.requestReopen && target.kind === "agent") { - useTerminalAgentReopenStore.getState().requestReopen({ - serverId: input.serverId, - agentId: target.agentId, - }); - } - return buildHostWorkspaceRoute(input.serverId, input.workspaceId); } diff --git a/packages/cli/src/commands/agent/ls.ts b/packages/cli/src/commands/agent/ls.ts index a8d73c039..866869672 100644 --- a/packages/cli/src/commands/agent/ls.ts +++ b/packages/cli/src/commands/agent/ls.ts @@ -25,7 +25,6 @@ export interface AgentListItem { shortId: string; name: string; provider: string; - terminal: boolean; thinking: string; status: string; cwd: string; @@ -67,7 +66,6 @@ export const agentLsSchema: OutputSchema = { { header: "AGENT ID", field: "shortId", width: 12 }, { header: "NAME", field: "name", width: 20 }, { header: "PROVIDER", field: "provider", width: 15 }, - { header: "TERM", field: "terminal", width: 6 }, { header: "THINKING", field: "thinking", width: 12 }, { header: "STATUS", @@ -93,7 +91,6 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem { shortId: agent.id.slice(0, 7), name: agent.title ?? "-", provider: model ? `${agent.provider}/${model}` : agent.provider, - terminal: agent.terminal === true, thinking: agent.effectiveThinkingOptionId ?? "auto", status: agent.status, cwd: shortenPath(agent.cwd), diff --git a/packages/cli/src/commands/agent/send.ts b/packages/cli/src/commands/agent/send.ts index 1b8e9c9e7..feaa6df1f 100644 --- a/packages/cli/src/commands/agent/send.ts +++ b/packages/cli/src/commands/agent/send.ts @@ -17,11 +17,6 @@ export interface AgentSendResult { message: string; } -function isTerminalAgentSendError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return /terminal agents do not support structured send operations/i.test(message); -} - /** Schema for agent send output */ export const agentSendSchema: OutputSchema = { idField: "agentId", @@ -265,15 +260,6 @@ export async function runSendCommand( } catch (err) { await client.close().catch(() => {}); - if (isTerminalAgentSendError(err)) { - const error: CommandError = { - code: "TERMINAL_AGENT_UNSUPPORTED", - message: "Cannot send messages to terminal agents", - details: "Open the terminal agent from the Sessions UI and interact through its terminal.", - }; - throw error; - } - // Re-throw CommandError as-is if (err && typeof err === "object" && "code" in err) { throw err; diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 26f1c8606..ef82def56 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; @@ -9,11 +9,8 @@ import { DbAgentSnapshotStore } from "../db/db-agent-snapshot-store.js"; import { DbAgentTimelineStore } from "../db/db-agent-timeline-store.js"; import { openPaseoDatabase, type PaseoDatabaseHandle } from "../db/sqlite-database.js"; import { projects, workspaces } from "../db/schema.js"; -import { AgentManager, type AgentManagerEvent } from "./agent-manager.js"; +import { AgentManager } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; -import type { TerminalManager } from "../../terminal/terminal-manager.js"; -import { createTerminalManager } from "../../terminal/terminal-manager.js"; -import type { TerminalExitInfo, TerminalSession } from "../../terminal/terminal.js"; import type { AgentClient, AgentLaunchContext, @@ -41,47 +38,6 @@ function deferred(): Deferred { return { promise, resolve, reject }; } -class EventPushable implements AsyncIterable { - private queue: T[] = []; - private resolvers: Array<(value: IteratorResult) => void> = []; - private closed = false; - - push(value: T): void { - if (this.closed) { - return; - } - const resolver = this.resolvers.shift(); - if (resolver) { - resolver({ value, done: false }); - return; - } - this.queue.push(value); - } - - end(): void { - this.closed = true; - while (this.resolvers.length > 0) { - const resolver = this.resolvers.shift(); - resolver?.({ value: undefined, done: true }); - } - } - - [Symbol.asyncIterator](): AsyncIterator { - return { - next: () => { - if (this.queue.length > 0) { - const value = this.queue.shift()!; - return Promise.resolve({ value, done: false }); - } - if (this.closed) { - return Promise.resolve({ value: undefined, done: true }); - } - return new Promise((resolve) => this.resolvers.push(resolve)); - }, - }; - } -} - const TEST_CAPABILITIES = { supportsStreaming: false, supportsSessionPersistence: false, @@ -89,12 +45,6 @@ const TEST_CAPABILITIES = { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, - supportsTerminalMode: false, -} as const; - -const TERMINAL_TEST_CAPABILITIES = { - ...TEST_CAPABILITIES, - supportsTerminalMode: true, } as const; async function seedWorkspace( @@ -354,596 +304,9 @@ class StreamingAssistantClient implements AgentClient { } } -class TerminalTestAgentClient extends TestAgentClient { - override readonly capabilities = TERMINAL_TEST_CAPABILITIES; - public lastTerminalCreateHandle: AgentPersistenceHandle | null = null; - public lastTerminalInitialPrompt: string | undefined; - - override buildTerminalCreateCommand( - _config: AgentSessionConfig, - handle: AgentPersistenceHandle, - initialPrompt?: string, - ) { - this.lastTerminalCreateHandle = handle; - this.lastTerminalInitialPrompt = initialPrompt; - return { - command: "terminal-test-cli", - args: ["--session-id", handle.sessionId], - env: { TEST_SESSION_ID: handle.sessionId }, - }; - } - - override buildTerminalResumeCommand(handle: AgentPersistenceHandle) { - return { - command: "terminal-test-cli", - args: ["resume", handle.nativeHandle ?? handle.sessionId], - }; - } -} - -function createStubTerminalManager(): TerminalManager { - const terminals = new Map< - string, - TerminalSession & { - emitExit: (info?: TerminalExitInfo) => void; - emitTitleChange: (title?: string) => void; - } - >(); - return { - async getTerminals() { - return Array.from(terminals.values()); - }, - async createTerminal(options) { - const id = options.id ?? `term-${terminals.size + 1}`; - const exitListeners = new Set<(info: TerminalExitInfo) => void>(); - const titleListeners = new Set<(title?: string) => void>(); - let title: string | undefined; - let exitInfo: TerminalExitInfo | null = null; - const session: TerminalSession & { - emitExit: (info?: TerminalExitInfo) => void; - emitTitleChange: (title?: string) => void; - } = { - id, - name: options.name ?? "Terminal", - cwd: options.cwd, - send: () => {}, - subscribe: () => () => {}, - onExit(listener) { - exitListeners.add(listener); - return () => { - exitListeners.delete(listener); - }; - }, - onTitleChange(listener) { - titleListeners.add(listener); - return () => { - titleListeners.delete(listener); - }; - }, - getSize: () => ({ rows: 24, cols: 80 }), - getState: () => ({ rows: 24, cols: 80, cursor: { row: 0, col: 0 }, scrollback: [], grid: [] }), - getTitle() { - return title; - }, - getExitInfo() { - return exitInfo; - }, - kill() { - for (const listener of Array.from(exitListeners)) { - listener(exitInfo ?? { exitCode: null, signal: null, lastOutputLines: [] }); - } - }, - emitExit(info = { exitCode: null, signal: null, lastOutputLines: [] }) { - exitInfo = info; - for (const listener of Array.from(exitListeners)) { - listener(info); - } - }, - emitTitleChange(nextTitle) { - title = nextTitle; - for (const listener of Array.from(titleListeners)) { - listener(nextTitle); - } - }, - }; - terminals.set(id, session); - return session; - }, - registerCwdEnv() {}, - getTerminal(id) { - return terminals.get(id); - }, - killTerminal(id) { - terminals.get(id)?.kill(); - terminals.delete(id); - }, - listDirectories() { - return []; - }, - killAll() { - terminals.clear(); - }, - subscribeTerminalsChanged() { - return () => {}; - }, - }; -} - describe("AgentManager", () => { const logger = createTestLogger(); - test("terminal agents persist a deterministic handle and expose terminal kind after unload", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-")); - const storage = new AgentStorage(join(workdir, "agents"), logger); - const client = new TerminalTestAgentClient(); - const manager = new AgentManager({ - clients: { codex: client }, - registry: storage, - terminalManager: createStubTerminalManager(), - logger, - idFactory: () => "00000000-0000-4000-8000-0000000073e1", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - terminal: true, - }); - - expect(snapshot.terminal).toBe(true); - expect(snapshot.persistence).toMatchObject({ - provider: "codex", - sessionId: "00000000-0000-4000-8000-0000000073e1", - nativeHandle: "00000000-0000-4000-8000-0000000073e1", - }); - expect(client.lastTerminalCreateHandle?.sessionId).toBe("00000000-0000-4000-8000-0000000073e1"); - expect(client.lastTerminalInitialPrompt).toBeUndefined(); - expect(await manager.getAgentKind(snapshot.id)).toBe("terminal"); - - await manager.closeAgent(snapshot.id); - - expect(await manager.getAgentKind(snapshot.id)).toBe("terminal"); - expect(await manager.getStructuredSendRejection(snapshot.id)).toBe( - "Terminal agents do not support structured send operations", - ); - }); - - test("terminal agents reserve the terminal binding before terminal creation completes", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-binding-")); - const storage = new AgentStorage(join(workdir, "agents"), logger); - const client = new TerminalTestAgentClient(); - let manager: AgentManager; - const terminalManager: TerminalManager = { - async getTerminals() { - return []; - }, - async createTerminal(options) { - expect(options.id).toBeTruthy(); - expect(manager.isTerminalBoundToAgent(options.id!)).toBe(true); - const exitListeners = new Set<(info: TerminalExitInfo) => void>(); - return { - id: options.id!, - name: options.name ?? "Terminal", - cwd: options.cwd, - send: () => {}, - subscribe: () => () => {}, - onExit(listener) { - exitListeners.add(listener); - return () => { - exitListeners.delete(listener); - }; - }, - getSize: () => ({ rows: 24, cols: 80 }), - getState: () => ({ rows: 24, cols: 80, cursor: { row: 0, col: 0 }, scrollback: [], grid: [] }), - getTitle: () => undefined, - getExitInfo: () => null, - onTitleChange: () => () => {}, - kill() { - for (const listener of Array.from(exitListeners)) { - listener({ exitCode: null, signal: null, lastOutputLines: [] }); - } - }, - }; - }, - registerCwdEnv() {}, - getTerminal() { - return undefined; - }, - killTerminal() {}, - listDirectories() { - return []; - }, - killAll() {}, - subscribeTerminalsChanged() { - return () => {}; - }, - }; - - manager = new AgentManager({ - clients: { codex: client }, - registry: storage, - terminalManager, - logger, - idFactory: () => "00000000-0000-4000-8000-0000000b01d0", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - terminal: true, - }); - - expect(snapshot.terminalId).toBeTruthy(); - expect(manager.isTerminalBoundToAgent(snapshot.terminalId!)).toBe(true); - }); - - test("setTitle persists and emits state for live terminal agents", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-title-")); - const storage = new AgentStorage(join(workdir, "agents"), logger); - const manager = new AgentManager({ - clients: { codex: new TerminalTestAgentClient() }, - registry: storage, - terminalManager: createStubTerminalManager(), - logger, - idFactory: () => "00000000-0000-4000-8000-00000000aa11", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - terminal: true, - }); - let stateEventCount = 0; - const unsubscribe = manager.subscribe((event) => { - if (event.type === "agent_state" && event.agent.id === snapshot.id) { - stateEventCount += 1; - } - }, { agentId: snapshot.id, replayState: false }); - - await manager.setTitle(snapshot.id, "Agent Shell"); - - const stored = await storage.get(snapshot.id); - expect(stored?.title).toBe("Agent Shell"); - expect(manager.getAgentIdForTerminal(snapshot.terminalId!)).toBe(snapshot.id); - expect(stateEventCount).toBe(1); - - unsubscribe(); - }); - - test("terminal agent creation ignores title propagation before the initial snapshot is persisted", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-title-race-")); - const dataDir = join(workdir, "db"); - const database = await openPaseoDatabase(dataDir); - let manager: AgentManager | null = null; - - try { - const workspaceId = await seedWorkspace(database, { directory: workdir }); - const storage = new DbAgentSnapshotStore(database.db); - const terminalManager: TerminalManager = { - async getTerminals() { - return []; - }, - async createTerminal(options) { - const exitListeners = new Set<(info: TerminalExitInfo) => void>(); - const titleListeners = new Set<(title?: string) => void>(); - const session: TerminalSession = { - id: options.id, - name: options.name ?? "Terminal", - cwd: options.cwd, - send: () => {}, - subscribe: () => () => {}, - onExit(listener) { - exitListeners.add(listener); - return () => { - exitListeners.delete(listener); - }; - }, - onTitleChange(listener) { - titleListeners.add(listener); - return () => { - titleListeners.delete(listener); - }; - }, - getSize: () => ({ rows: 24, cols: 80 }), - getState: () => ({ - rows: 24, - cols: 80, - cursor: { row: 0, col: 0 }, - scrollback: [], - grid: [], - }), - getTitle: () => "Agent Shell", - getExitInfo: () => null, - kill() { - for (const listener of Array.from(exitListeners)) { - listener({ exitCode: null, signal: null, lastOutputLines: [] }); - } - }, - }; - - const agentId = manager?.getAgentIdForTerminal(options.id) ?? null; - if (agentId) { - await manager?.setTitle(agentId, "Agent Shell"); - } - - return session; - }, - registerCwdEnv() {}, - getTerminal() { - return undefined; - }, - killTerminal() {}, - listDirectories() { - return []; - }, - killAll() {}, - subscribeTerminalsChanged() { - return () => {}; - }, - }; - - manager = new AgentManager({ - clients: { codex: new TerminalTestAgentClient() }, - registry: storage, - terminalManager, - logger, - idFactory: () => "00000000-0000-4000-8000-00000000aa13", - }); - - const snapshot = await manager.createAgent( - { - provider: "codex", - cwd: workdir, - terminal: true, - }, - undefined, - { workspaceId }, - ); - - const stored = await storage.get(snapshot.id); - expect(stored?.title).toBe("Agent Shell"); - } finally { - await database.close(); - rmSync(workdir, { recursive: true, force: true }); - } - }); - - test("terminal agent creation preserves titles propagated during terminal registration", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-registration-title-")); - const storage = new AgentStorage(join(workdir, "agents"), logger); - const scriptPath = join(workdir, "npm-cli.js"); - let manager: AgentManager | null = null; - - const terminalManager = createTerminalManager({ - resolveAgentIdForTerminal: (terminalId) => manager?.getAgentIdForTerminal(terminalId) ?? null, - onAgentBoundTerminalTitleChange: async ({ agentId, title }) => { - if (!manager) { - return; - } - await manager.setTitle(agentId, title); - }, - }); - - class TitleReplayTerminalAgentClient extends TerminalTestAgentClient { - override buildTerminalCreateCommand( - _config: AgentSessionConfig, - handle: AgentPersistenceHandle, - ) { - return { - command: process.execPath, - args: [scriptPath, "--session-id", handle.sessionId], - }; - } - } - - manager = new AgentManager({ - clients: { codex: new TitleReplayTerminalAgentClient() }, - registry: storage, - terminalManager, - logger, - idFactory: () => "00000000-0000-4000-8000-00000000aa12", - }); - - writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n"); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - terminal: true, - }); - - const deadline = Date.now() + 2000; - let storedTitle: string | null = null; - while (Date.now() < deadline) { - storedTitle = (await storage.get(snapshot.id))?.title ?? null; - if (storedTitle?.startsWith("npm --session-id ")) { - break; - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - - expect(storedTitle?.startsWith("npm --session-id ")).toBe(true); - - terminalManager.killAll(); - }); - - test("getMetricsSnapshot skips agents without in-memory timeline state", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-metrics-")); - const storage = new AgentStorage(join(workdir, "agents"), logger); - const manager = new AgentManager({ - clients: { codex: new TerminalTestAgentClient() }, - registry: storage, - terminalManager: createStubTerminalManager(), - logger, - idFactory: () => "00000000-0000-4000-8000-00000000aa14", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - terminal: true, - }); - - expect(manager.getMetricsSnapshot()).toEqual({ - total: 1, - byLifecycle: { idle: 1 }, - withActiveForegroundTurn: 0, - timelineStats: { - totalItems: 0, - maxItemsPerAgent: 0, - }, - }); - expect(snapshot.terminal).toBe(true); - }); - - test("terminal agent closure preserves exit diagnostics for failed launches", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-exit-")); - const storage = new AgentStorage(join(workdir, "agents"), logger); - const manager = new AgentManager({ - clients: { codex: new TerminalTestAgentClient() }, - registry: storage, - terminalManager: createStubTerminalManager(), - logger, - idFactory: () => "00000000-0000-4000-8000-00000000aa12", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - terminal: true, - }); - - const terminal = manager.getTerminalSessionForAgent(snapshot.id) as TerminalSession & { - emitExit: (info?: TerminalExitInfo) => void; - }; - expect(terminal).toBeTruthy(); - - let closedEvent: Extract["agent"] | null = null; - manager.subscribe( - (event) => { - if (event.type === "agent_state" && event.agent.id === snapshot.id) { - closedEvent = event.agent; - } - }, - { agentId: snapshot.id, replayState: false }, - ); - - terminal.emitExit({ - exitCode: 127, - signal: null, - lastOutputLines: ["gemini: command not found"], - }); - - await vi.waitFor(async () => { - const stored = await storage.get(snapshot.id); - expect(stored?.terminalExit).toEqual({ - command: "terminal-test-cli", - message: "gemini: command not found", - exitCode: 127, - signal: null, - outputLines: ["gemini: command not found"], - }); - expect(stored?.lastError).toContain("Exit code: 127"); - }); - - expect(closedEvent?.lifecycle).toBe("closed"); - expect(closedEvent?.lastError).toContain("gemini: command not found"); - expect(closedEvent?.terminalExit).toEqual({ - command: "terminal-test-cli", - message: "gemini: command not found", - exitCode: 127, - signal: null, - outputLines: ["gemini: command not found"], - }); - }); - - test("structured send rejection is null for managed agents", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-")); - const storage = new AgentStorage(join(workdir, "agents"), logger); - const manager = new AgentManager({ - clients: { codex: new TestAgentClient() }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000100", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - expect(await manager.getAgentKind(snapshot.id)).toBe("session"); - expect(await manager.getStructuredSendRejection(snapshot.id)).toBeNull(); - }); - - test("createAgent passes initialPrompt into terminal command builders", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-prompt-")); - const storage = new AgentStorage(join(workdir, "agents"), logger); - const client = new TerminalTestAgentClient(); - const terminalManager: TerminalManager = { - async getTerminals() { - return []; - }, - async createTerminal(options) { - const exitListeners = new Set<(info: TerminalExitInfo) => void>(); - return { - id: options.id ?? "00000000-0000-4000-8000-00000000abcd", - name: options.name ?? "Terminal", - cwd: options.cwd, - send: () => {}, - subscribe: () => () => {}, - onExit(listener) { - exitListeners.add(listener); - return () => { - exitListeners.delete(listener); - }; - }, - getSize: () => ({ rows: 24, cols: 80 }), - getState: () => ({ rows: 24, cols: 80, cursor: { row: 0, col: 0 }, scrollback: [], grid: [] }), - getTitle: () => undefined, - getExitInfo: () => null, - onTitleChange: () => () => {}, - kill() { - for (const listener of Array.from(exitListeners)) { - listener({ exitCode: null, signal: null, lastOutputLines: [] }); - } - }, - }; - }, - registerCwdEnv() {}, - getTerminal() { - return undefined; - }, - killTerminal() {}, - listDirectories() { - return []; - }, - killAll() {}, - subscribeTerminalsChanged() { - return () => {}; - }, - }; - const manager = new AgentManager({ - clients: { codex: client }, - registry: storage, - terminalManager, - logger, - idFactory: () => "00000000-0000-4000-8000-00000000abcd", - }); - - await manager.createAgent( - { - provider: "codex", - cwd: workdir, - terminal: true, - }, - undefined, - { initialPrompt: "Implement terminal prompt routing" }, - ); - - expect(client.lastTerminalInitialPrompt).toBe("Implement terminal prompt routing"); - }); - test("normalizeConfig does not inject default model when omitted", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index ffb814f13..d1bfe69dd 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { basename, resolve } from "node:path"; +import { resolve } from "node:path"; import { stat } from "node:fs/promises"; import { AGENT_LIFECYCLE_STATUSES, @@ -8,7 +8,6 @@ import { import type { Logger } from "pino"; import { z } from "zod"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; -import type { TerminalExitInfo, TerminalSession } from "../../terminal/terminal.js"; import type { AgentCapabilityFlags, @@ -31,7 +30,6 @@ import type { AgentRuntimeInfo, ListPersistedAgentsOptions, PersistedAgentDescriptor, - TerminalCommand, } from "./agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent-storage.js"; import type { AgentSnapshotStore } from "./agent-snapshot-store.js"; @@ -114,13 +112,6 @@ export type WaitForAgentStartOptions = { signal?: AbortSignal; }; -export interface TerminalExitDetails { - command: string; - message: string; - exitCode: number | null; - signal: number | null; - outputLines: string[]; -} type AttentionState = | { requiresAttention: false } | { @@ -149,7 +140,6 @@ type ManagedAgentBase = { id: string; provider: AgentProvider; cwd: string; - terminal: boolean; capabilities: AgentCapabilityFlags; config: AgentSessionConfig; runtimeInfo?: AgentRuntimeInfo; @@ -165,7 +155,6 @@ type ManagedAgentBase = { lastUserMessageAt: Date | null; lastUsage?: AgentUsage; lastError?: string; - terminalExit?: TerminalExitDetails; attention: AttentionState; foregroundTurnWaiters: Set; unsubscribeSession: (() => void) | null; @@ -181,7 +170,6 @@ type ManagedAgentBase = { type ManagedAgentWithSession = ManagedAgentBase & { session: AgentSession; - terminal: false; }; type ManagedAgentInitializing = ManagedAgentWithSession & { @@ -211,24 +199,11 @@ type ManagedAgentClosed = ManagedAgentBase & { activeForegroundTurnId: null; }; -type ManagedTerminalAgent = ManagedAgentBase & { - terminal: true; - lifecycle: "idle"; - session: null; - activeForegroundTurnId: null; - terminalCommand: TerminalCommand; - terminalId: string | null; - unsubscribeTerminalExit: (() => void) | null; -}; - -export type AgentKind = "session" | "terminal"; - export type ManagedAgent = | ManagedAgentInitializing | ManagedAgentIdle | ManagedAgentRunning | ManagedAgentError - | ManagedTerminalAgent | ManagedAgentClosed; export interface AgentMetricsSnapshot { @@ -247,7 +222,7 @@ type ActiveManagedAgent = | ManagedAgentRunning | ManagedAgentError; -type LiveManagedAgent = ActiveManagedAgent | ManagedTerminalAgent; +type LiveManagedAgent = ActiveManagedAgent; const SYSTEM_ERROR_PREFIX = "[System Error]"; @@ -298,73 +273,6 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri return Object.assign(new Error(message), { name: "AbortError" }); } -function formatTerminalExitSummary(input: { - command: string; - exitCode: number | null; - signal: number | null; - outputLines: string[]; -}): string { - const commandLabel = basename(input.command) || input.command; - const commandNotFoundLine = input.outputLines.find((line) => - /command not found|not recognized|no such file or directory/i.test(line), - ); - - if (input.exitCode === 127) { - return commandNotFoundLine ?? `${commandLabel}: command not found`; - } - if (input.exitCode !== null) { - return `${commandLabel} exited with code ${input.exitCode}.`; - } - if (input.signal !== null) { - return `${commandLabel} exited with signal ${input.signal}.`; - } - return `${commandLabel} exited unexpectedly.`; -} - -function buildTerminalExitDetails(input: { - command: string; - exit: TerminalExitInfo; -}): TerminalExitDetails | null { - const outputLines = input.exit.lastOutputLines.map((line) => line.trimEnd()); - while (outputLines[0]?.length === 0) { - outputLines.shift(); - } - while (outputLines[outputLines.length - 1]?.length === 0) { - outputLines.pop(); - } - - if (input.exit.exitCode === null && input.exit.signal === null && outputLines.length === 0) { - return null; - } - - return { - command: input.command, - message: formatTerminalExitSummary({ - command: input.command, - exitCode: input.exit.exitCode, - signal: input.exit.signal, - outputLines, - }), - exitCode: input.exit.exitCode, - signal: input.exit.signal, - outputLines, - }; -} - -function buildTerminalExitErrorMessage(details: TerminalExitDetails): string { - const lines = [details.message]; - if (details.exitCode !== null) { - lines.push(`Exit code: ${details.exitCode}`); - } else if (details.signal !== null) { - lines.push(`Signal: ${details.signal}`); - } - if (details.outputLines.length > 0) { - lines.push("Last output:"); - lines.push(...details.outputLines); - } - return lines.join("\n"); -} - function validateAgentId(agentId: string, source: string): string { const result = AgentIdSchema.safeParse(agentId); if (!result.success) { @@ -396,14 +304,12 @@ export class AgentManager { private readonly backgroundTasks = new Set>(); private onAgentAttention?: AgentAttentionCallback; private logger: Logger; - private readonly terminalManager: TerminalManager | null; constructor(options: AgentManagerOptions) { this.idFactory = options?.idFactory ?? (() => randomUUID()); this.registry = options?.registry; this.durableTimelineStore = options?.durableTimelineStore; this.onAgentAttention = options?.onAgentAttention; - this.terminalManager = options?.terminalManager ?? null; this.logger = options.logger.child({ module: "agent", component: "agent-manager" }); if (options?.clients) { for (const [provider, client] of Object.entries(options.clients)) { @@ -624,51 +530,6 @@ export class AgentManager { return agent ? { ...agent } : null; } - async getAgentKind(id: string): Promise { - const normalizedId = validateAgentId(id, "getAgentKind"); - const liveAgent = this.agents.get(normalizedId); - if (liveAgent) { - return liveAgent.terminal ? "terminal" : "session"; - } - if (!this.registry) { - return null; - } - const stored = await this.registry.get(normalizedId); - if (!stored) { - return null; - } - return stored.config?.terminal === true ? "terminal" : "session"; - } - - async getStructuredSendRejection(id: string): Promise { - const kind = await this.getAgentKind(id); - return kind === "terminal" - ? "Terminal agents do not support structured send operations" - : null; - } - - getTerminalSessionForAgent(id: string): TerminalSession | null { - const agent = this.agents.get(id); - if (!agent || !agent.terminal || !("terminalId" in agent) || !agent.terminalId) { - return null; - } - return this.terminalManager?.getTerminal(agent.terminalId) ?? null; - } - - getAgentIdForTerminal(terminalId: string): string | null { - for (const agent of this.agents.values()) { - if (!agent.terminal || !("terminalId" in agent) || agent.terminalId !== terminalId) { - continue; - } - return agent.id; - } - return null; - } - - isTerminalBoundToAgent(terminalId: string): boolean { - return this.getAgentIdForTerminal(terminalId) !== null; - } - getTimeline(id: string): AgentTimelineItem[] { this.requireAgent(id); return this.timelineStore.getItems(id); @@ -713,34 +574,6 @@ export class AgentManager { `Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`, ); } - if (normalizedConfig.terminal) { - const buildCommand = client.buildTerminalCreateCommand; - if (!buildCommand) { - throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`); - } - const persistence = this.buildTerminalPersistenceHandle( - resolvedAgentId, - normalizedConfig.provider, - normalizedConfig.cwd, - ); - const command = buildCommand.call( - client, - normalizedConfig, - persistence, - options?.initialPrompt, - ); - return this.registerTerminalAgent( - resolvedAgentId, - normalizedConfig, - client.capabilities, - command, - persistence, - { - labels: options?.labels, - workspaceId: options?.workspaceId, - }, - ); - } const session = await client.createSession(normalizedConfig, launchContext); return this.registerSession(session, normalizedConfig, resolvedAgentId, { labels: options?.labels, @@ -748,91 +581,6 @@ export class AgentManager { }); } - // Reconstruct an agent from provider persistence. When a durable timeline - // store is configured, the live timeline buffer only seeds seq metadata from - // the durable store instead of loading committed history back into memory. - // Tests without a durable timeline store can still call - // hydrateTimelineFromProvider() for backward compatibility. - async launchTerminalAgent( - config: AgentSessionConfig, - agentId: string, - options?: { - persistence?: AgentPersistenceHandle | null; - createdAt?: Date; - updatedAt?: Date; - lastUserMessageAt?: Date | null; - labels?: Record; - attention?: { - requiresAttention: boolean; - attentionReason?: "finished" | "error" | "permission" | null; - attentionTimestamp?: Date | null; - }; - }, - ): Promise { - const resolvedAgentId = validateAgentId(agentId, "launchTerminalAgent"); - const normalizedConfig = await this.normalizeConfig(config); - const client = this.requireClient(normalizedConfig.provider); - const available = await client.isAvailable(); - if (!available) { - throw new Error( - `Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`, - ); - } - - const resumeCommand = - options?.persistence && client.buildTerminalResumeCommand - ? client.buildTerminalResumeCommand.call(client, options.persistence) - : null; - const createCommand = client.buildTerminalCreateCommand; - const terminalCommand = - resumeCommand ?? - (createCommand - ? createCommand.call( - client, - normalizedConfig, - options?.persistence ?? - this.buildTerminalPersistenceHandle( - resolvedAgentId, - normalizedConfig.provider, - normalizedConfig.cwd, - ), - ) - : null); - - if (!terminalCommand) { - throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`); - } - - return this.registerTerminalAgent( - resolvedAgentId, - normalizedConfig, - client.capabilities, - terminalCommand, - options?.persistence ?? - this.buildTerminalPersistenceHandle( - resolvedAgentId, - normalizedConfig.provider, - normalizedConfig.cwd, - ), - { - createdAt: options?.createdAt, - updatedAt: options?.updatedAt, - lastUserMessageAt: options?.lastUserMessageAt, - labels: options?.labels, - attention: - options?.attention?.requiresAttention && - options.attention.attentionReason && - options.attention.attentionTimestamp - ? { - requiresAttention: true, - attentionReason: options.attention.attentionReason, - attentionTimestamp: options.attention.attentionTimestamp, - } - : undefined, - }, - ); - } - // Reconstruct an agent from provider persistence. Callers should explicitly // hydrate timeline history after resume. async resumeAgentFromPersistence( @@ -941,13 +689,7 @@ export class AgentManager { "closeAgent: start", ); const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); - if (agent.terminal) { - if (agent.terminalId) { - this.terminalManager?.killTerminal(agent.terminalId); - } - } else { - await agent.session.close(); - } + await agent.session.close(); this.timelineStore.delete(agentId); this.emitClosedAgent(closedAgent); this.logger.trace({ agentId }, "closeAgent: completed"); @@ -2050,7 +1792,6 @@ export class AgentManager { id: resolvedAgentId, provider: config.provider, cwd: config.cwd, - terminal: false, session, capabilities: session.capabilities, config, @@ -2071,7 +1812,6 @@ export class AgentManager { lastUserMessageAt: options?.lastUserMessageAt ?? null, lastUsage: options?.lastUsage, lastError: options?.lastError, - terminalExit: undefined, attention: options?.attention != null ? options.attention.requiresAttention @@ -2118,162 +1858,6 @@ export class AgentManager { }; } - private async registerTerminalAgent( - agentId: string, - config: AgentSessionConfig, - capabilities: AgentCapabilityFlags, - terminalCommand: TerminalCommand, - persistence: AgentPersistenceHandle, - options?: { - workspaceId?: number; - createdAt?: Date; - updatedAt?: Date; - lastUserMessageAt?: Date | null; - labels?: Record; - attention?: AttentionState; - }, - ): Promise { - if (!this.terminalManager) { - throw new Error("Terminal manager is not configured"); - } - const resolvedAgentId = validateAgentId(agentId, "registerTerminalAgent"); - if (this.agents.has(resolvedAgentId)) { - throw new Error(`Agent with id ${resolvedAgentId} already exists`); - } - const initialPersistedTitle = await this.resolveInitialPersistedTitle(resolvedAgentId, config); - const now = new Date(); - const reservedTerminalId = randomUUID(); - - const managed: ManagedTerminalAgent = { - id: resolvedAgentId, - provider: config.provider, - cwd: config.cwd, - terminal: true, - session: null, - capabilities, - config, - runtimeInfo: undefined, - lifecycle: "idle", - createdAt: options?.createdAt ?? now, - updatedAt: options?.updatedAt ?? now, - availableModes: [], - currentModeId: config.modeId ?? null, - pendingPermissions: new Map(), - pendingReplacement: false, - activeForegroundTurnId: null, - foregroundTurnWaiters: new Set(), - unsubscribeSession: null, - provisionalAssistantText: null, - persistence: attachPersistenceCwd(persistence, config.cwd), - historyPrimed: false, - lastUserMessageAt: options?.lastUserMessageAt ?? null, - lastUsage: undefined, - lastError: undefined, - terminalExit: undefined, - attention: - options?.attention != null - ? options.attention.requiresAttention - ? { - requiresAttention: true, - attentionReason: options.attention.attentionReason, - attentionTimestamp: new Date(options.attention.attentionTimestamp), - } - : { requiresAttention: false } - : { requiresAttention: false }, - internal: config.internal ?? false, - labels: options?.labels ?? {}, - terminalCommand, - terminalId: reservedTerminalId, - unsubscribeTerminalExit: null, - }; - - this.agents.set(resolvedAgentId, managed); - this.previousStatuses.set(resolvedAgentId, managed.lifecycle); - this.agentsAwaitingInitialSnapshotPersist.add(resolvedAgentId); - - let terminalSession: TerminalSession; - try { - terminalSession = await this.terminalManager.createTerminal({ - id: reservedTerminalId, - cwd: config.cwd, - name: initialPersistedTitle ?? undefined, - command: terminalCommand.command, - args: terminalCommand.args, - env: terminalCommand.env, - }); - } catch (error) { - this.agents.delete(resolvedAgentId); - this.previousStatuses.delete(resolvedAgentId); - this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId); - throw error; - } - - if (terminalSession.id !== reservedTerminalId) { - this.agents.delete(resolvedAgentId); - this.previousStatuses.delete(resolvedAgentId); - this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId); - throw new Error( - `Reserved terminal id ${reservedTerminalId} but terminal manager returned ${terminalSession.id}`, - ); - } - - const unsubscribeTerminalExit = terminalSession.onExit((exit) => { - void this.handleTerminalAgentExited(resolvedAgentId, exit); - }); - managed.unsubscribeTerminalExit = unsubscribeTerminalExit; - const terminalSessionTitle = terminalSession.getTitle()?.trim(); - try { - await this.persistSnapshot(managed, { - workspaceId: options?.workspaceId, - title: - terminalSessionTitle && terminalSessionTitle.length > 0 - ? terminalSessionTitle - : initialPersistedTitle, - }); - } finally { - this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId); - } - this.emitState(managed); - return { ...managed }; - } - - private async handleTerminalAgentExited(agentId: string, exit: TerminalExitInfo): Promise { - const agent = this.agents.get(agentId); - if (!agent || !agent.terminal) { - return; - } - const terminalExit = buildTerminalExitDetails({ - command: agent.terminalCommand.command, - exit, - }); - if (terminalExit) { - agent.terminalExit = terminalExit; - if (terminalExit.exitCode !== null && terminalExit.exitCode !== 0) { - agent.lastError = buildTerminalExitErrorMessage(terminalExit); - } else if (terminalExit.signal !== null) { - agent.lastError = buildTerminalExitErrorMessage(terminalExit); - } - } - const closedAgent = this.prepareAgentForClosure(agent, "agent terminal exited"); - await this.persistSnapshot(closedAgent); - this.emitClosedAgent(closedAgent); - } - - private buildTerminalPersistenceHandle( - agentId: string, - provider: AgentProvider, - cwd: string, - ): AgentPersistenceHandle { - return attachPersistenceCwd( - { - provider, - sessionId: agentId, - nativeHandle: agentId, - }, - cwd, - )!; - } - private prepareAgentForClosure( agent: LiveManagedAgent, cancelReason: string, @@ -2284,10 +1868,6 @@ export class AgentManager { agent.unsubscribeSession(); agent.unsubscribeSession = null; } - if (agent.terminal && agent.unsubscribeTerminalExit) { - agent.unsubscribeTerminalExit(); - agent.unsubscribeTerminalExit = null; - } for (const waiter of agent.foregroundTurnWaiters) { waiter.callback({ type: "turn_canceled", @@ -2330,7 +1910,7 @@ export class AgentManager { if (!current) { return; } - if (current.terminal || current.session == null) { + if (current.session == null) { return; } await this.dispatchSessionEvent(current, event); @@ -3112,8 +2692,8 @@ export class AgentManager { private requireSessionAgent(id: string): ActiveManagedAgent { const agent = this.requireAgent(id); - if (agent.terminal || agent.session === null) { - throw new Error(`Agent '${agent.id}' is a terminal agent and has no managed session`); + if (agent.session === null) { + throw new Error(`Agent '${agent.id}' has no managed session`); } return agent; } diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index 7c59c9bbc..d8e81201d 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -58,7 +58,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, - supportsTerminalMode: false, }, config: { ...baseConfig, ...configOverrides }, lifecycle, diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 28f452230..d7a7b78c9 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -63,7 +63,6 @@ export function toStoredAgentRecord( runtimeInfo, persistence, lastError: agent.lastError ?? undefined, - terminalExit: agent.terminalExit ?? undefined, requiresAttention: agent.attention.requiresAttention, attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null, attentionTimestamp: agent.attention.requiresAttention @@ -88,7 +87,6 @@ export function toAgentPayload( id: agent.id, provider: agent.provider, cwd: agent.cwd, - terminal: agent.terminal, model: agent.config.model ?? null, thinkingOptionId, effectiveThinkingOptionId, @@ -115,10 +113,6 @@ export function toAgentPayload( payload.lastError = agent.lastError; } - if (agent.terminalExit) { - payload.terminalExit = agent.terminalExit; - } - // Handle attention state payload.requiresAttention = agent.attention.requiresAttention; if (agent.attention.requiresAttention) { @@ -134,9 +128,6 @@ export function toAgentPayload( function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null { const serializable: SerializableAgentConfig = {}; - if (config.terminal !== undefined) { - serializable.terminal = config.terminal; - } if (Object.prototype.hasOwnProperty.call(config, "title")) { serializable.title = config.title ?? null; } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 5fc10b088..891ecc814 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -71,7 +71,6 @@ export type AgentCapabilityFlags = { supportsMcpServers: boolean; supportsReasoningStream: boolean; supportsToolInvocations: boolean; - supportsTerminalMode: boolean; }; export type AgentPersistenceHandle = { @@ -358,16 +357,9 @@ export type PersistedAgentDescriptor = { timeline: AgentTimelineItem[]; }; -export type TerminalCommand = { - command: string; - args: string[]; - env?: Record; -}; - export type AgentSessionConfig = { provider: AgentProvider; cwd: string; - terminal?: boolean; /** * Provider-agnostic system/developer instruction string. * Mapped by each provider to its native instruction field. @@ -437,12 +429,6 @@ export interface AgentClient { ): Promise; listModels(options?: ListModelsOptions): Promise; listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise; - buildTerminalCreateCommand?( - config: AgentSessionConfig, - handle: AgentPersistenceHandle, - initialPrompt?: string, - ): TerminalCommand; - buildTerminalResumeCommand?(handle: AgentPersistenceHandle): TerminalCommand; /** * Check if this provider is available (CLI binary is installed). * Returns true if available, false otherwise. diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index 1304b7f89..00db5a635 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -57,7 +57,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, - supportsTerminalMode: false, }, config, lifecycle, diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 2b2748df6..2ad9a2c1b 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -12,7 +12,6 @@ import type { AgentSessionConfig } from "./agent-sdk-types.js"; const SERIALIZABLE_CONFIG_SCHEMA = z .object({ - terminal: z.boolean().optional(), title: z.string().nullable().optional(), modeId: z.string().nullable().optional(), model: z.string().nullable().optional(), @@ -59,15 +58,6 @@ const STORED_AGENT_SCHEMA = z.object({ .optional(), persistence: PERSISTENCE_HANDLE_SCHEMA, lastError: z.string().nullable().optional(), - terminalExit: z - .object({ - command: z.string(), - message: z.string(), - exitCode: z.number().nullable(), - signal: z.number().nullable(), - outputLines: z.array(z.string()), - }) - .optional(), requiresAttention: z.boolean().optional(), attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), attentionTimestamp: z.string().nullable().optional(), @@ -77,7 +67,6 @@ const STORED_AGENT_SCHEMA = z.object({ export type SerializableAgentConfig = Pick< AgentSessionConfig, - | "terminal" | "title" | "modeId" | "model" diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 53f514837..f244348d9 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -125,21 +125,21 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [ { id: "gemini", label: "Gemini CLI", - description: "Google's terminal-based coding agent", + description: "Google's coding agent CLI", defaultModeId: null, modes: [], }, { id: "amp", label: "AMP", - description: "Sourcegraph's terminal-based coding agent", + description: "Sourcegraph's coding agent CLI", defaultModeId: null, modes: [], }, { id: "aider", label: "Aider", - description: "Paul Gauthier's terminal-based coding assistant", + description: "Paul Gauthier's coding assistant CLI", defaultModeId: null, modes: [], }, diff --git a/packages/server/src/server/agent/providers/aider-agent.ts b/packages/server/src/server/agent/providers/aider-agent.ts index 9a9c54fe1..2b063babb 100644 --- a/packages/server/src/server/agent/providers/aider-agent.ts +++ b/packages/server/src/server/agent/providers/aider-agent.ts @@ -9,14 +9,10 @@ import type { AgentSession, AgentSessionConfig, ListModelsOptions, - TerminalCommand, } from "../agent-sdk-types.js"; import { - applyProviderEnv, findExecutable, isProviderCommandAvailable, - resolveProviderCommandPrefix, - sanitizeTerminalEnv, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; @@ -29,11 +25,8 @@ const AIDER_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, - supportsTerminalMode: true, }; -type AiderAgentConfig = AgentSessionConfig & { provider: "aider" }; - function resolveAiderBinary(): string { const found = findExecutable("aider"); if (found) { @@ -45,7 +38,7 @@ function resolveAiderBinary(): string { } function createUnsupportedSessionError(): Error { - return new Error("Aider currently supports terminal mode only in Paseo."); + return new Error("Aider does not support session-backed agents in Paseo."); } export class AiderAgentClient implements AgentClient { @@ -73,38 +66,10 @@ export class AiderAgentClient implements AgentClient { return []; } - buildTerminalCreateCommand( - config: AgentSessionConfig, - _handle: AgentPersistenceHandle, - _initialPrompt?: string, - ): TerminalCommand { - this.assertConfig(config); - const launchPrefix = resolveProviderCommandPrefix( - this.runtimeSettings?.command, - resolveAiderBinary, - ); - const terminalEnv = sanitizeTerminalEnv( - applyProviderEnv(process.env as Record, this.runtimeSettings), - ); - return { - command: launchPrefix.command, - // Aider uses positional arguments for file paths, not interactive prompts. - args: [...launchPrefix.args, "--no-auto-commits"], - env: terminalEnv, - }; - } - async isAvailable(): Promise { if (this.runtimeSettings?.command?.mode === "replace") { return existsSync(this.runtimeSettings.command.argv[0]); } return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAiderBinary); } - - private assertConfig(config: AgentSessionConfig): AiderAgentConfig { - if (config.provider !== AIDER_PROVIDER) { - throw new Error(`AiderAgentClient received config for provider '${config.provider}'`); - } - return { ...config, provider: AIDER_PROVIDER }; - } } diff --git a/packages/server/src/server/agent/providers/amp-agent.ts b/packages/server/src/server/agent/providers/amp-agent.ts index 9318e8401..a676db808 100644 --- a/packages/server/src/server/agent/providers/amp-agent.ts +++ b/packages/server/src/server/agent/providers/amp-agent.ts @@ -9,14 +9,10 @@ import type { AgentSession, AgentSessionConfig, ListModelsOptions, - TerminalCommand, } from "../agent-sdk-types.js"; import { - applyProviderEnv, findExecutable, isProviderCommandAvailable, - resolveProviderCommandPrefix, - sanitizeTerminalEnv, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; @@ -29,11 +25,8 @@ const AMP_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, - supportsTerminalMode: true, }; -type AmpAgentConfig = AgentSessionConfig & { provider: "amp" }; - function resolveAmpBinary(): string { const found = findExecutable("amp"); if (found) { @@ -45,7 +38,7 @@ function resolveAmpBinary(): string { } function createUnsupportedSessionError(): Error { - return new Error("AMP currently supports terminal mode only in Paseo."); + return new Error("AMP does not support session-backed agents in Paseo."); } export class AmpAgentClient implements AgentClient { @@ -73,37 +66,10 @@ export class AmpAgentClient implements AgentClient { return []; } - buildTerminalCreateCommand( - config: AgentSessionConfig, - _handle: AgentPersistenceHandle, - _initialPrompt?: string, - ): TerminalCommand { - this.assertConfig(config); - const launchPrefix = resolveProviderCommandPrefix( - this.runtimeSettings?.command, - resolveAmpBinary, - ); - const terminalEnv = sanitizeTerminalEnv( - applyProviderEnv(process.env as Record, this.runtimeSettings), - ); - return { - command: launchPrefix.command, - args: [...launchPrefix.args], - env: terminalEnv, - }; - } - async isAvailable(): Promise { if (this.runtimeSettings?.command?.mode === "replace") { return existsSync(this.runtimeSettings.command.argv[0]); } return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAmpBinary); } - - private assertConfig(config: AgentSessionConfig): AmpAgentConfig { - if (config.provider !== AMP_PROVIDER) { - throw new Error(`AmpAgentClient received config for provider '${config.provider}'`); - } - return { ...config, provider: AMP_PROVIDER }; - } } diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 3a437ee88..d15c18ff5 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -66,12 +66,10 @@ import type { ListPersistedAgentsOptions, McpServerConfig, PersistedAgentDescriptor, - TerminalCommand, } from "../agent-sdk-types.js"; import { applyProviderEnv, findExecutable, - sanitizeTerminalEnv, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; @@ -104,7 +102,6 @@ const CLAUDE_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, - supportsTerminalMode: true, }; const DEFAULT_MODES: AgentMode[] = [ @@ -1099,73 +1096,6 @@ export class ClaudeAgentClient implements AgentClient { return descriptors; } - buildTerminalCreateCommand( - config: AgentSessionConfig, - handle: AgentPersistenceHandle, - initialPrompt?: string, - ): TerminalCommand { - const claudeConfig = this.assertConfig(config); - const baseCommand = findExecutable("claude") ?? "claude"; - const terminalEnv = sanitizeTerminalEnv( - applyProviderEnv(process.env as Record, this.runtimeSettings), - ); - const spawnCommand = resolveClaudeSpawnCommand( - { - command: baseCommand, - args: [], - cwd: claudeConfig.cwd, - env: terminalEnv, - signal: new AbortController().signal, - }, - this.runtimeSettings, - ); - const args = [...spawnCommand.args, "--session-id", handle.sessionId]; - if (claudeConfig.modeId === "bypassPermissions") { - args.push("--dangerously-skip-permissions"); - } else if (claudeConfig.modeId) { - args.push("--permission-mode", claudeConfig.modeId); - } - if (claudeConfig.model) { - args.push("--model", claudeConfig.model); - } - if (claudeConfig.thinkingOptionId && claudeConfig.thinkingOptionId !== "default") { - args.push("--effort", claudeConfig.thinkingOptionId); - } - if (claudeConfig.systemPrompt?.trim()) { - args.push("--append-system-prompt", claudeConfig.systemPrompt.trim()); - } - if (initialPrompt?.trim()) { - args.push(initialPrompt.trim()); - } - return { - command: spawnCommand.command, - args, - env: terminalEnv, - }; - } - - buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand { - const baseCommand = findExecutable("claude") ?? "claude"; - const terminalEnv = sanitizeTerminalEnv( - applyProviderEnv(process.env as Record, this.runtimeSettings), - ); - const spawnCommand = resolveClaudeSpawnCommand( - { - command: baseCommand, - args: [], - cwd: process.cwd(), - env: terminalEnv, - signal: new AbortController().signal, - }, - this.runtimeSettings, - ); - return { - command: spawnCommand.command, - args: [...spawnCommand.args, "--resume", handle.sessionId], - env: terminalEnv, - }; - } - async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 1a96033d0..ccd83cf8c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -19,11 +19,9 @@ import type { AgentTimelineItem, ToolCallTimelineItem, AgentUsage, - AgentPersistenceHandle, ListModelsOptions, ListPersistedAgentsOptions, PersistedAgentDescriptor, - TerminalCommand, } from "../agent-sdk-types.js"; import type { Logger } from "pino"; @@ -45,7 +43,6 @@ import { applyProviderEnv, findExecutable, resolveProviderCommandPrefix, - sanitizeTerminalEnv, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; @@ -62,7 +59,6 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, - supportsTerminalMode: true, }; const CODEX_MODES: AgentMode[] = [ @@ -3536,59 +3532,6 @@ export class CodexAppServerAgentClient implements AgentClient { } } - buildTerminalCreateCommand( - config: AgentSessionConfig, - handle: AgentPersistenceHandle, - initialPrompt?: string, - ): TerminalCommand { - const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings); - const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER }; - const modeId = sessionConfig.modeId ?? DEFAULT_CODEX_MODE_ID; - validateCodexMode(modeId); - const preset = MODE_PRESETS[modeId] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID]; - const approvalPolicy = sessionConfig.approvalPolicy ?? preset.approvalPolicy; - const sandbox = sessionConfig.sandboxMode ?? preset.sandbox; - const args = [...launchPrefix.args, "-c", `sessionId=\"${handle.sessionId}\"`]; - if (sessionConfig.model) { - args.push("--model", sessionConfig.model); - } - args.push("--ask-for-approval", approvalPolicy, "--sandbox", sandbox); - if ( - typeof sessionConfig.networkAccess === "boolean" - ? sessionConfig.networkAccess - : preset.networkAccess === true - ) { - args.push("--search"); - } - if (initialPrompt?.trim()) { - args.push(initialPrompt.trim()); - } - const terminalEnv = sanitizeTerminalEnv( - applyProviderEnv(process.env as Record, this.runtimeSettings), - ); - return { - command: launchPrefix.command, - args, - env: terminalEnv, - }; - } - - buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand { - const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings); - const terminalEnv = sanitizeTerminalEnv( - applyProviderEnv(process.env as Record, this.runtimeSettings), - ); - return { - command: launchPrefix.command, - args: [ - ...launchPrefix.args, - "resume", - handle.nativeHandle ?? handle.sessionId, - ], - env: terminalEnv, - }; - } - async listModels(_options?: ListModelsOptions): Promise { const child = this.spawnAppServer(); const client = new CodexAppServerClient(child, this.logger); diff --git a/packages/server/src/server/agent/providers/gemini-agent.ts b/packages/server/src/server/agent/providers/gemini-agent.ts index 72a44a7b3..61dd18a17 100644 --- a/packages/server/src/server/agent/providers/gemini-agent.ts +++ b/packages/server/src/server/agent/providers/gemini-agent.ts @@ -9,14 +9,10 @@ import type { AgentSession, AgentSessionConfig, ListModelsOptions, - TerminalCommand, } from "../agent-sdk-types.js"; import { - applyProviderEnv, findExecutable, isProviderCommandAvailable, - resolveProviderCommandPrefix, - sanitizeTerminalEnv, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; @@ -29,11 +25,8 @@ const GEMINI_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, - supportsTerminalMode: true, }; -type GeminiAgentConfig = AgentSessionConfig & { provider: "gemini" }; - function resolveGeminiBinary(): string { const found = findExecutable("gemini"); if (found) { @@ -45,7 +38,7 @@ function resolveGeminiBinary(): string { } function createUnsupportedSessionError(): Error { - return new Error("Gemini CLI currently supports terminal mode only in Paseo."); + return new Error("Gemini CLI does not support session-backed agents in Paseo."); } export class GeminiAgentClient implements AgentClient { @@ -73,56 +66,10 @@ export class GeminiAgentClient implements AgentClient { return []; } - buildTerminalCreateCommand( - config: AgentSessionConfig, - _handle: AgentPersistenceHandle, - initialPrompt?: string, - ): TerminalCommand { - this.assertConfig(config); - const launchPrefix = resolveProviderCommandPrefix( - this.runtimeSettings?.command, - resolveGeminiBinary, - ); - const terminalEnv = sanitizeTerminalEnv( - applyProviderEnv(process.env as Record, this.runtimeSettings), - ); - const args = [...launchPrefix.args]; - if (initialPrompt?.trim()) { - args.push("-i", initialPrompt.trim()); - } - return { - command: launchPrefix.command, - args, - env: terminalEnv, - }; - } - - buildTerminalResumeCommand(_handle: AgentPersistenceHandle): TerminalCommand { - const launchPrefix = resolveProviderCommandPrefix( - this.runtimeSettings?.command, - resolveGeminiBinary, - ); - const terminalEnv = sanitizeTerminalEnv( - applyProviderEnv(process.env as Record, this.runtimeSettings), - ); - return { - command: launchPrefix.command, - args: [...launchPrefix.args, "--resume"], - env: terminalEnv, - }; - } - async isAvailable(): Promise { if (this.runtimeSettings?.command?.mode === "replace") { return existsSync(this.runtimeSettings.command.argv[0]); } return isProviderCommandAvailable(this.runtimeSettings?.command, resolveGeminiBinary); } - - private assertConfig(config: AgentSessionConfig): GeminiAgentConfig { - if (config.provider !== GEMINI_PROVIDER) { - throw new Error(`GeminiAgentClient received config for provider '${config.provider}'`); - } - return { ...config, provider: GEMINI_PROVIDER }; - } } diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 3407fe7ae..a129675d4 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -28,13 +28,11 @@ import type { ListPersistedAgentsOptions, McpServerConfig, PersistedAgentDescriptor, - TerminalCommand, } from "../agent-sdk-types.js"; import { applyProviderEnv, findExecutable, resolveProviderCommandPrefix, - sanitizeTerminalEnv, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; @@ -46,7 +44,6 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, - supportsTerminalMode: true, }; const DEFAULT_MODES: AgentMode[] = [ @@ -562,53 +559,6 @@ export class OpenCodeAgentClient implements AgentClient { return []; } - buildTerminalCreateCommand( - config: AgentSessionConfig, - handle: AgentPersistenceHandle, - initialPrompt?: string, - ): TerminalCommand { - const launchPrefix = resolveProviderCommandPrefix( - this.runtimeSettings?.command, - resolveOpenCodeBinary, - ); - const terminalEnv = sanitizeTerminalEnv( - applyProviderEnv(process.env as Record, this.runtimeSettings), - ); - const args = [...launchPrefix.args, "--session", handle.nativeHandle ?? handle.sessionId]; - if (config.cwd) { - args.push(config.cwd); - } - if (config.model) { - args.push("--model", config.model); - } - if (config.modeId) { - args.push("--agent", config.modeId); - } - if (initialPrompt?.trim()) { - args.push(initialPrompt.trim()); - } - return { - command: launchPrefix.command, - args, - env: terminalEnv, - }; - } - - buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand { - const launchPrefix = resolveProviderCommandPrefix( - this.runtimeSettings?.command, - resolveOpenCodeBinary, - ); - const terminalEnv = sanitizeTerminalEnv( - applyProviderEnv(process.env as Record, this.runtimeSettings), - ); - return { - command: launchPrefix.command, - args: [...launchPrefix.args, "--session", handle.nativeHandle ?? handle.sessionId], - env: terminalEnv, - }; - } - async isAvailable(): Promise { const command = this.runtimeSettings?.command; if (command?.mode === "replace") { diff --git a/packages/server/src/server/agent/providers/terminal-only-providers.test.ts b/packages/server/src/server/agent/providers/terminal-only-providers.test.ts deleted file mode 100644 index a14703664..000000000 --- a/packages/server/src/server/agent/providers/terminal-only-providers.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, test } from "vitest"; -import type { AgentSessionConfig } from "../agent-sdk-types.js"; -import { AiderAgentClient } from "./aider-agent.js"; -import { AmpAgentClient } from "./amp-agent.js"; -import { GeminiAgentClient } from "./gemini-agent.js"; - -function createExecutable(): string { - const dir = mkdtempSync(path.join(os.tmpdir(), "terminal-provider-test-")); - const file = path.join(dir, "provider-bin"); - writeFileSync(file, "#!/bin/sh\nexit 0\n"); - chmodSync(file, 0o755); - return file; -} - -const buildConfig = (provider: "gemini" | "amp" | "aider"): AgentSessionConfig => ({ - provider, - cwd: "/tmp/worktree", - terminal: true, -}); - -describe("terminal-only providers", () => { - test("Gemini builds an interactive prompt command without injecting cwd flags", () => { - const executable = createExecutable(); - try { - const client = new GeminiAgentClient({ - command: { mode: "replace", argv: [executable] }, - }); - - const command = client.buildTerminalCreateCommand( - buildConfig("gemini"), - { provider: "gemini", sessionId: "session-1" }, - "Fix the bug", - ); - - expect(command.command).toBe(executable); - expect(command.args).toEqual(["-i", "Fix the bug"]); - } finally { - rmSync(path.dirname(executable), { recursive: true, force: true }); - } - }); - - test("AMP launches without unsupported cwd flags", () => { - const executable = createExecutable(); - try { - const client = new AmpAgentClient({ - command: { mode: "replace", argv: [executable] }, - }); - - const command = client.buildTerminalCreateCommand(buildConfig("amp"), { - provider: "amp", - sessionId: "session-1", - }); - - expect(command.command).toBe(executable); - expect(command.args).toEqual([]); - } finally { - rmSync(path.dirname(executable), { recursive: true, force: true }); - } - }); - - test("Aider does not treat initial prompts as positional CLI arguments", () => { - const executable = createExecutable(); - try { - const client = new AiderAgentClient({ - command: { mode: "replace", argv: [executable] }, - }); - - const command = client.buildTerminalCreateCommand( - buildConfig("aider"), - { provider: "aider", sessionId: "session-1" }, - "Refactor the parser", - ); - - expect(command.command).toBe(executable); - expect(command.args).toEqual(["--no-auto-commits"]); - } finally { - rmSync(path.dirname(executable), { recursive: true, force: true }); - } - }); - - test("provider availability respects missing replacement binaries", async () => { - const missingPath = path.join(os.tmpdir(), "missing-terminal-provider"); - - await expect( - new GeminiAgentClient({ - command: { mode: "replace", argv: [missingPath] }, - }).isAvailable(), - ).resolves.toBe(false); - await expect( - new AmpAgentClient({ - command: { mode: "replace", argv: [missingPath] }, - }).isAvailable(), - ).resolves.toBe(false); - await expect( - new AiderAgentClient({ - command: { mode: "replace", argv: [missingPath] }, - }).isAvailable(), - ).resolves.toBe(false); - }); -}); diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 41956b22f..f39d62fd4 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -408,22 +408,7 @@ export async function createPaseoDaemon( }); const durableTimelineStore = new DbAgentTimelineStore(database.db); let agentManager: AgentManager | null = null; - const terminalManager = createTerminalManager({ - resolveAgentIdForTerminal: (terminalId) => agentManager?.getAgentIdForTerminal(terminalId) ?? null, - onAgentBoundTerminalTitleChange: async ({ agentId, title }) => { - if (!agentManager) { - return; - } - try { - await agentManager.setTitle(agentId, title); - } catch (error) { - logger.warn( - { err: error, agentId }, - "Failed to propagate bound terminal title to agent state", - ); - } - }, - }); + const terminalManager = createTerminalManager(); agentManager = new AgentManager({ clients: { ...createAllClients(logger, { diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index 6993db444..2f9a09e7c 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -33,7 +33,6 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, - supportsTerminalMode: false, }; interface ScriptedAgentBehavior { diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index f2bd51f80..81d48b351 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -785,7 +785,6 @@ export class LoopService { model: loop.workerModel ?? loop.model ?? undefined, title: buildWorkerTitle(loop, iteration.index), internal: true, - terminal: false, }; } @@ -796,7 +795,6 @@ export class LoopService { model: loop.verifierModel ?? loop.model ?? undefined, title: buildVerifierTitle(loop, iteration.index), internal: true, - terminal: false, }; } diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts index 815c16fef..22510d478 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -95,22 +95,19 @@ describe("persistence hooks", () => { }); }); - test("buildSessionConfig accepts terminal-only providers from the canonical manifest", () => { + test("buildSessionConfig accepts providers from the canonical manifest", () => { const record = createRecord({ provider: "gemini", persistence: { provider: "gemini", sessionId: "session-123", }, - config: { - terminal: true, - }, + config: {}, }); expect(buildSessionConfig(record)).toMatchObject({ provider: "gemini", cwd: "/tmp/project", - terminal: true, }); }); }); diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index ca6905582..c5aa6fb33 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -7,7 +7,6 @@ import { isValidAgentProvider } from "./agent/provider-manifest.js"; export function buildConfigOverrides(record: StoredAgentRecord): Partial { return { cwd: record.cwd, - terminal: record.config?.terminal ?? undefined, modeId: record.lastModeId ?? record.config?.modeId ?? undefined, model: record.config?.model ?? undefined, thinkingOptionId: record.config?.thinkingOptionId ?? undefined, @@ -26,7 +25,6 @@ export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfi return { provider: record.provider, cwd: record.cwd, - terminal: overrides.terminal, modeId: overrides.modeId, model: overrides.model, thinkingOptionId: overrides.thinkingOptionId, diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index c0d6ef6a9..318223fbc 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -368,9 +368,6 @@ export class ScheduleService { private async executeSchedule(schedule: StoredSchedule): Promise { if (schedule.target.type === "agent") { const agent = await this.ensureAgentLoaded(schedule.target.agentId); - if (agent.terminal) { - throw new Error(`Agent ${agent.id} is a terminal agent and cannot be targeted by schedules`); - } if (this.agentManager.hasInFlightRun(agent.id)) { throw new Error(`Agent ${agent.id} already has an active run`); } @@ -401,7 +398,6 @@ export class ScheduleService { extra: schedule.target.config.extra, systemPrompt: schedule.target.config.systemPrompt, mcpServers: schedule.target.config.mcpServers as AgentSessionConfig["mcpServers"], - terminal: false, }; const labels = { "paseo.schedule-id": schedule.id, diff --git a/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts b/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts index 6727dbbf3..97acd7fa0 100644 --- a/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts +++ b/packages/server/src/server/session.provider-history-compatibility-ownership.test.ts @@ -77,7 +77,6 @@ function createSessionForOwnershipTests(options?: { hydrateTimelineFromProvider: vi.fn(async () => { throw new Error("Session should not call hydrateTimelineFromProvider directly"); }), - getStructuredSendRejection: vi.fn(async () => null), fetchTimeline: vi.fn(async () => ({ rows: options?.timelineRows ?? [], hasOlder: false, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 96b1ccc88..3aac4667c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -57,8 +57,6 @@ import { type VoiceTurnController, } from "./voice/voice-turn-controller.js"; import { - buildSessionConfig, - extractTimestamps, toAgentPersistenceHandle, } from "./persistence-hooks.js"; import { experimental_createMCPClient } from "ai"; @@ -1005,7 +1003,6 @@ export class Session { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: true, - supportsTerminalMode: false, } as const; const createdAt = new Date(record.createdAt); @@ -1033,7 +1030,6 @@ export class Session { id: record.id, provider, cwd: record.cwd, - terminal: record.config?.terminal ?? false, model: record.config?.model ?? null, thinkingOptionId: record.config?.thinkingOptionId ?? null, effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({ @@ -1052,7 +1048,6 @@ export class Session { persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence), lastUsage: undefined, lastError: record.lastError ?? undefined, - terminalExit: record.terminalExit, title: record.title ?? record.config?.title ?? null, requiresAttention: record.requiresAttention ?? false, attentionReason: record.attentionReason ?? null, @@ -1091,10 +1086,7 @@ export class Session { } const initPromise = (async () => { - const record = await this.requireStoredAgentRecord(agentId); - if (record.config?.terminal) { - return this.ensureTerminalAgentLoaded(agentId, record); - } + await this.requireStoredAgentRecord(agentId); return this.agentLoadingService.ensureAgentLoaded({ agentId }); })(); @@ -1118,39 +1110,6 @@ export class Session { return record; } - private async getAgentMode(agentId: string): Promise<"chat" | "terminal"> { - const existing = this.agentManager.getAgent(agentId); - if (existing) { - return existing.terminal ? "terminal" : "chat"; - } - const record = await this.requireStoredAgentRecord(agentId); - return record.config?.terminal ? "terminal" : "chat"; - } - - private async ensureTerminalAgentLoaded( - agentId: string, - record: StoredAgentRecord, - ): Promise { - const timestamps = extractTimestamps(record); - const snapshot = await this.agentManager.launchTerminalAgent(buildSessionConfig(record), agentId, { - persistence: record.persistence ?? null, - createdAt: timestamps.createdAt, - updatedAt: timestamps.updatedAt, - lastUserMessageAt: timestamps.lastUserMessageAt, - labels: timestamps.labels, - attention: { - requiresAttention: record.requiresAttention ?? false, - attentionReason: record.attentionReason ?? null, - attentionTimestamp: record.attentionTimestamp ? new Date(record.attentionTimestamp) : null, - }, - }); - this.sessionLogger.info( - { agentId, provider: record.provider }, - "Terminal agent loaded from stored config", - ); - return this.agentManager.getAgent(agentId) ?? snapshot; - } - private matchesAgentFilter(options: { agent: AgentSnapshotPayload; project: ProjectPlacementPayload; @@ -2618,9 +2577,6 @@ export class Session { }, ); await this.forwardAgentUpdate(snapshot); - if (sessionConfig.terminal) { - void this.emitInitialTerminalsChangedSnapshot(resolvedWorkspace.directory); - } if (requestId) { const agentPayload = await this.getAgentPayloadById(snapshot.id); @@ -2649,29 +2605,27 @@ export class Session { logger: this.sessionLogger, }); - if (!sessionConfig.terminal) { - void this.handleSendAgentMessage( - snapshot.id, - trimmedPrompt, - resolveClientMessageId(clientMessageId), - images, - outputSchema ? { outputSchema } : undefined, - ).catch((promptError) => { - this.sessionLogger.error( - { err: promptError, agentId: snapshot.id }, - `Failed to run initial prompt for agent ${snapshot.id}`, - ); - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "error", - content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`, - }, - }); + void this.handleSendAgentMessage( + snapshot.id, + trimmedPrompt, + resolveClientMessageId(clientMessageId), + images, + outputSchema ? { outputSchema } : undefined, + ).catch((promptError) => { + this.sessionLogger.error( + { err: promptError, agentId: snapshot.id }, + `Failed to run initial prompt for agent ${snapshot.id}`, + ); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`, + }, }); - } + }); } if (worktreeBootstrap) { @@ -5694,13 +5648,6 @@ export class Session { : undefined; try { - const agentMode = await this.getAgentMode(msg.agentId); - if (agentMode === "terminal") { - throw new SessionRequestError( - "unsupported_agent_kind", - `Agent ${msg.agentId} is a terminal agent and has no timeline history`, - ); - } const snapshot = await this.ensureAgentLoaded(msg.agentId); const agentPayload = await this.buildAgentPayload(snapshot); const timeline = await this.agentManager.fetchTimeline(msg.agentId, { @@ -5773,22 +5720,6 @@ export class Session { } try { - const structuredSendRejection = await this.agentManager.getStructuredSendRejection( - resolved.agentId, - ); - if (structuredSendRejection) { - this.emit({ - type: "send_agent_message_response", - payload: { - requestId: msg.requestId, - agentId: resolved.agentId, - accepted: false, - error: structuredSendRejection, - }, - }); - return; - } - const agentId = resolved.agentId; await this.unarchiveAgentState(agentId); @@ -7221,7 +7152,7 @@ export class Session { } private filterStandaloneTerminals(terminals: T[]): T[] { - return terminals.filter((terminal) => !this.agentManager.isTerminalBoundToAgent(terminal.id)); + return terminals; } private toTerminalInfo(terminal: Pick): { @@ -7328,46 +7259,6 @@ export class Session { } } - private async createOrResumeAgentTerminal(agentId: string): Promise { - if (!this.terminalManager) { - throw new Error("Terminal manager not available"); - } - - const existingTerminal = this.agentManager.getTerminalSessionForAgent(agentId); - if (existingTerminal) { - return existingTerminal; - } - - const record = await this.agentStorage.get(agentId); - if (!record || record.internal) { - throw new Error(`Agent not found: ${agentId}`); - } - if (!record.config?.terminal) { - throw new Error(`Agent ${agentId} is not a terminal agent`); - } - - const timestamps = extractTimestamps(record); - const launched = await this.agentManager.launchTerminalAgent(buildSessionConfig(record), agentId, { - persistence: record.persistence ?? null, - createdAt: timestamps.createdAt, - updatedAt: timestamps.updatedAt, - lastUserMessageAt: timestamps.lastUserMessageAt, - labels: timestamps.labels, - attention: { - requiresAttention: record.requiresAttention ?? false, - attentionReason: record.attentionReason ?? null, - attentionTimestamp: record.attentionTimestamp ? new Date(record.attentionTimestamp) : null, - }, - }); - const terminal = launched.terminalId - ? this.terminalManager.getTerminal(launched.terminalId) - : null; - if (!terminal) { - throw new Error(`Terminal not available for agent ${agentId}`); - } - return terminal; - } - private async getAllTerminalSessions(): Promise { if (!this.terminalManager) { return []; @@ -7395,18 +7286,11 @@ export class Session { try { if (msg.agentId) { - const terminal = await this.createOrResumeAgentTerminal(msg.agentId); - this.ensureTerminalExitSubscription(terminal); this.emit({ type: "create_terminal_response", payload: { - terminal: { - id: terminal.id, - name: terminal.name, - cwd: terminal.cwd, - ...(terminal.getTitle() ? { title: terminal.getTitle() } : {}), - }, - error: null, + terminal: null, + error: `Agent-backed terminals are no longer supported for agent ${msg.agentId}`, requestId: msg.requestId, }, }); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index e4a34f164..8192b92bc 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -9,7 +9,6 @@ import { createPersistedProjectRecord, createPersistedWorkspaceRecord, } from "./workspace-registry.js"; -import type { StoredAgentRecord } from "./agent/agent-storage.js"; function makeAgent(input: { id: string; @@ -39,7 +38,6 @@ function makeAgent(input: { supportsMcpServers: true, supportsReasoningStream: true, supportsToolInvocations: true, - supportsTerminalMode: false, }, currentModeId: null, availableModes: [], @@ -222,50 +220,6 @@ function seedWorkspace(options: { return record; } -function createStoredTerminalAgentRecord(input: { - id: string; - cwd: string; -}): StoredAgentRecord { - return { - id: input.id, - provider: "codex", - cwd: input.cwd, - createdAt: "2026-03-01T12:00:00.000Z", - updatedAt: "2026-03-01T12:00:00.000Z", - lastActivityAt: "2026-03-01T12:00:00.000Z", - lastUserMessageAt: null, - title: null, - labels: {}, - lastStatus: "closed", - lastModeId: null, - config: { - terminal: true, - }, - runtimeInfo: { - provider: "codex", - sessionId: null, - }, - persistence: { - provider: "codex", - sessionId: input.id, - nativeHandle: input.id, - }, - lastError: null, - terminalExit: { - command: "codex", - message: "Terminal session ended", - exitCode: 0, - signal: null, - outputLines: [], - }, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - internal: false, - archivedAt: null, - }; -} - function createTempGitRepo(options?: { remoteUrl?: string; branchName?: string; @@ -635,89 +589,6 @@ describe("workspace aggregation", () => { expect(sessionLogger.warn).toHaveBeenCalled(); }); - test("terminal agents reject timeline fetch without reloading as chat sessions", async () => { - const emitted: Array<{ type: string; payload: any }> = []; - const logger = { - child: () => logger, - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - const resumeAgentFromPersistence = vi.fn(); - const launchTerminalAgent = vi.fn(); - const hydrateTimelineFromProvider = vi.fn(); - - const session = new Session({ - clientId: "test-client", - onMessage: (message) => emitted.push(message as any), - logger: logger as any, - downloadTokenStore: {} as any, - pushTokenStore: {} as any, - paseoHome: "/tmp/paseo-test", - agentManager: { - subscribe: () => () => {}, - listAgents: () => [], - getAgent: () => null, - resumeAgentFromPersistence, - launchTerminalAgent, - hydrateTimelineFromProvider, - } as any, - agentStorage: { - list: async () => [], - get: async (agentId: string) => - agentId === "terminal-1" - ? createStoredTerminalAgentRecord({ id: agentId, cwd: "/tmp/repo" }) - : null, - } as any, - projectRegistry: { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [], - get: async () => null, - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - } as any, - workspaceRegistry: { - initialize: async () => {}, - existsOnDisk: async () => true, - list: async () => [], - get: async () => null, - upsert: async () => {}, - archive: async () => {}, - remove: async () => {}, - } as any, - createAgentMcpTransport: async () => { - throw new Error("not used"); - }, - stt: null, - tts: null, - terminalManager: null, - }) as any; - - await session.handleMessage({ - type: "fetch_agent_timeline_request", - requestId: "req-terminal-timeline", - agentId: "terminal-1", - }); - - expect(resumeAgentFromPersistence).not.toHaveBeenCalled(); - expect(launchTerminalAgent).not.toHaveBeenCalled(); - expect(hydrateTimelineFromProvider).not.toHaveBeenCalled(); - expect(emitted).toContainEqual( - expect.objectContaining({ - type: "fetch_agent_timeline_response", - payload: expect.objectContaining({ - requestId: "req-terminal-timeline", - agentId: "terminal-1", - error: "Agent terminal-1 is a terminal agent and has no timeline history", - }), - }), - ); - }); - test("uses persisted workspace names and stable status aggregation", async () => { const { session, projects, workspaces } = createSessionForWorkspaceTests(); seedProject({ diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index a473cd645..b3822e911 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -30,7 +30,6 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: false, supportsReasoningStream: true, supportsToolInvocations: true, - supportsTerminalMode: false, }; type Deferred = { diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index e14d243b8..254d9e5cb 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -95,7 +95,6 @@ const AgentCapabilityFlagsSchema: z.ZodType = z.object({ supportsMcpServers: z.boolean(), supportsReasoningStream: z.boolean(), supportsToolInvocations: z.boolean(), - supportsTerminalMode: z.boolean(), }); const AgentUsageSchema: z.ZodType = z.object({ @@ -133,7 +132,6 @@ const McpServerConfigSchema = z.discriminatedUnion("type", [ const AgentSessionConfigSchema = z.object({ provider: AgentProviderSchema, cwd: z.string(), - terminal: z.boolean().optional(), modeId: z.string().optional(), model: z.string().optional(), thinkingOptionId: z.string().optional(), @@ -458,19 +456,10 @@ const AgentRuntimeInfoSchema: z.ZodType = z.object({ extra: z.record(z.unknown()).optional(), }); -const TerminalExitDetailsSchema = z.object({ - command: z.string(), - message: z.string(), - exitCode: z.number().nullable(), - signal: z.number().nullable(), - outputLines: z.array(z.string()), -}); - export const AgentSnapshotPayloadSchema = z.object({ id: z.string(), provider: AgentProviderSchema, cwd: z.string(), - terminal: z.boolean().optional(), model: z.string().nullable(), thinkingOptionId: z.string().nullable().optional(), effectiveThinkingOptionId: z.string().nullable().optional(), @@ -486,7 +475,6 @@ export const AgentSnapshotPayloadSchema = z.object({ runtimeInfo: AgentRuntimeInfoSchema.optional(), lastUsage: AgentUsageSchema.optional(), lastError: z.string().optional(), - terminalExit: TerminalExitDetailsSchema.optional(), title: z.string().nullable(), labels: z.record(z.string()).default({}), requiresAttention: z.boolean().optional(), diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index b45566992..52f76695e 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -359,69 +359,6 @@ describe("TerminalManager", () => { 10000, ); - it("forwards bound terminal titles through the agent bridge without changing standalone lists", async () => { - await withShell("/bin/sh", async () => { - const onAgentBoundTerminalTitleChange = vi.fn(); - manager = createTerminalManager({ - resolveAgentIdForTerminal: () => "agent-1", - onAgentBoundTerminalTitleChange, - }); - - const snapshots: Array> = []; - const unsubscribe = manager.subscribeTerminalsChanged((input) => { - snapshots.push( - input.terminals.map((terminal) => ({ - id: terminal.id, - ...(terminal.title ? { title: terminal.title } : {}), - })), - ); - }); - - const session = await manager.createTerminal({ cwd: "/tmp" }); - session.send({ type: "input", data: "printf '\\033]0;Agent Shell\\007'\r" }); - - await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000); - - expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({ - agentId: "agent-1", - title: "Agent Shell", - }); - expect( - snapshots.some((snapshot) => - snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Agent Shell"), - ), - ).toBe(true); - - unsubscribe(); - }); - }); - - it("forwards initial titles for agent-bound terminals created with command args", async () => { - const packageRoot = mkdtempSync(join(tmpdir(), "terminal-manager-title-script-")); - temporaryDirs.push(packageRoot); - const scriptPath = join(packageRoot, "npm-cli.js"); - writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n"); - - const onAgentBoundTerminalTitleChange = vi.fn(); - manager = createTerminalManager({ - resolveAgentIdForTerminal: () => "agent-1", - onAgentBoundTerminalTitleChange, - }); - - await manager.createTerminal({ - cwd: packageRoot, - command: process.execPath, - args: [scriptPath, "run", "dev"], - }); - - await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000); - - expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({ - agentId: "agent-1", - title: "npm run dev", - }); - }); - it("emits empty snapshot when last terminal is removed", async () => { manager = createTerminalManager(); const snapshots: Array<{ cwd: string; terminalCount: number }> = []; diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 915ff315e..6dc49fe11 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -33,12 +33,7 @@ export interface TerminalManager { subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void; } -type AgentBoundTerminalTitleHandler = (input: { agentId: string; title: string }) => Promise | void; - -export function createTerminalManager(options?: { - resolveAgentIdForTerminal?: (terminalId: string) => string | null; - onAgentBoundTerminalTitleChange?: AgentBoundTerminalTitleHandler; -}): TerminalManager { +export function createTerminalManager(): TerminalManager { const terminalsByCwd = new Map(); const terminalsById = new Map(); const terminalExitUnsubscribeById = new Map void>(); @@ -111,24 +106,8 @@ export function createTerminalManager(options?: { const unsubscribeExit = session.onExit(() => { removeSessionById(session.id, { kill: false }); }); - const unsubscribeTitle = session.onTitleChange((title) => { + const unsubscribeTitle = session.onTitleChange(() => { emitTerminalsChanged({ cwd: session.cwd }); - const normalizedTitle = title?.trim(); - if (!normalizedTitle) { - return; - } - const agentId = options?.resolveAgentIdForTerminal?.(session.id) ?? null; - if (!agentId) { - return; - } - void Promise.resolve( - options?.onAgentBoundTerminalTitleChange?.({ - agentId, - title: normalizedTitle, - }), - ).catch(() => { - // no-op - }); }); terminalExitUnsubscribeById.set(session.id, unsubscribeExit); terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle);