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
This commit is contained in:
Mohamed Boudra
2026-04-02 10:55:54 +07:00
parent 59cb4d7499
commit 060f3d457c
60 changed files with 60 additions and 3592 deletions
-506
View File
@@ -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<string, string>;
};
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<string, string> }): Promise<ManagedAgent> {
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<string, string>;
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 <TerminalAgentPanel agentId={agentId} agent={agent} />;
}
return <AgentPanelBody agent={agent} ... />;
}
```
#### 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<string, string>;
}
```
---
## 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)
-12
View File
@@ -89,12 +89,6 @@ export async function waitForLauncherPanel(page: Page): Promise<void> {
});
}
/** Assert that the launcher panel shows provider tiles under "Terminal Agents". */
export async function assertProviderTilesVisible(page: Page): Promise<void> {
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<void> {
@@ -122,12 +116,6 @@ export async function clickTerminal(page: Page): Promise<void> {
await button.click();
}
/** Click a provider tile by label (e.g. "Claude Code", "Codex"). */
export async function clickProviderTile(page: Page, providerLabel: string): Promise<void> {
const tile = page.getByRole("button", { name: providerLabel }).first();
await expect(tile).toBeVisible({ timeout: 10_000 });
await tile.click();
}
// ─── Tab title assertions ──────────────────────────────────────────────────
@@ -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<void> {
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<void> {
await clickNewChat(page);
await expect(composerInput(page)).toBeVisible({ timeout: 15_000 });
@@ -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<void> {
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<void> {
await workspaceSetupDialog(page)
.getByRole("button", { name: /^Terminal Create the workspace/i })
+1 -59
View File
@@ -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);
@@ -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);
@@ -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);
+1 -8
View File
@@ -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"}
</Text>
{agent.terminal ? (
<SessionBadge
label="Terminal"
icon={<SquareTerminal size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
/>
) : null}
{agent.archivedAt ? (
<SessionBadge
label="Archived"
@@ -261,7 +255,6 @@ export function AgentList({
workspaceId,
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
requestReopen: agent.terminal && agent.status === "closed",
});
router.navigate(route as any);
},
@@ -1,17 +1,13 @@
import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { Bot, ChevronLeft, MessagesSquare, SquareTerminal } from "lucide-react-native";
import { ChevronLeft, MessagesSquare, SquareTerminal } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { createNameId } from "mnemonic-id";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { Composer } from "@/components/composer";
import { getProviderIcon } from "@/components/provider-icons";
import { Button } from "@/components/ui/button";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useProviderRecency } from "@/stores/provider-recency-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
@@ -24,8 +20,6 @@ import {
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { MessagePayload } from "./message-input";
type SetupStep = "choose" | "chat" | "terminal-agent";
export function WorkspaceSetupDialog() {
const { theme } = useUnistyles();
const toast = useToast();
@@ -34,15 +28,12 @@ export function WorkspaceSetupDialog() {
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
const setAgents = useSessionStore((state) => state.setAgents);
const [step, setStep] = useState<SetupStep>("choose");
const [terminalPrompt, setTerminalPrompt] = useState("");
const [step, setStep] = useState<"choose" | "chat">("choose");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor
> | 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");
}}
/>
<ChoiceCard
title="Terminal Agent"
description="Launch an agent-backed terminal in an agent tab."
Icon={Bot}
disabled={pendingAction !== null}
onPress={() => {
setErrorMessage(null);
setStep("terminal-agent");
}}
/>
<ChoiceCard
title="Terminal"
description="Create the workspace, then open a standalone terminal tab."
@@ -408,68 +333,6 @@ export function WorkspaceSetupDialog() {
</View>
) : null}
{step === "terminal-agent" ? (
<View style={styles.section}>
<StepHeader
title="Terminal Agent"
onBack={() => {
setErrorMessage(null);
setStep("choose");
}}
/>
<Text style={styles.helper}>
Choose a provider and optionally send an initial prompt. The workspace is created before the terminal agent launches.
</Text>
<View style={styles.providerGrid}>
{sortedProviders.map((provider) => (
<ProviderOption
key={provider.id}
provider={provider}
selected={provider.id === composerState?.selectedProvider}
disabled={pendingAction !== null}
onPress={() => composerState?.setProviderFromUser(provider.id)}
/>
))}
</View>
<View style={styles.field}>
<Text style={styles.fieldLabel}>Initial prompt</Text>
<AdaptiveTextInput
value={terminalPrompt}
onChangeText={setTerminalPrompt}
placeholder="Optional"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
multiline
autoCapitalize="sentences"
autoCorrect={false}
/>
</View>
<View style={styles.actions}>
<Button
variant="secondary"
style={styles.actionButton}
disabled={pendingAction !== null}
onPress={handleClose}
>
Cancel
</Button>
<Button
variant="default"
style={styles.actionButton}
disabled={pendingAction !== null}
onPress={() => {
void handleCreateTerminalAgent();
}}
>
{pendingAction === "terminal-agent" ? "Launching..." : "Launch"}
</Button>
</View>
</View>
) : null}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</AdaptiveModalSheet>
);
@@ -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 (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.providerCard,
selected ? styles.providerCardSelected : null,
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
disabled ? styles.cardDisabled : null,
]}
>
<View style={styles.providerIconWrap}>
<Icon size={16} color={theme.colors.foreground} />
</View>
<View style={styles.providerBody}>
<Text style={styles.providerTitle}>{provider.label}</Text>
</View>
</Pressable>
);
}
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,
@@ -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: [],
@@ -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: [],
@@ -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;
@@ -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,
@@ -8,7 +8,6 @@ function makeAgent(input?: Partial<Agent>): 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>): Agent {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: input?.currentModeId ?? null,
availableModes: input?.availableModes ?? [],
@@ -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,
@@ -10,7 +10,6 @@ function makeAgent(overrides: Partial<Agent> = {}): 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> = {}): Agent {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: null,
availableModes: [],
-28
View File
@@ -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 (
<View style={styles.root}>
<TerminalAgentPanel
serverId={serverId}
client={client}
agent={agent}
isPaneFocused={isPaneFocused}
/>
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ActivityIndicator size="large" color={theme.colors.foreground} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
</View>
) : null}
</View>
);
}
return (
<ChatAgentContent
serverId={serverId}
@@ -480,11 +455,9 @@ function ChatAgentContent({
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,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
@@ -716,7 +689,6 @@ function ChatAgentContent({
status: agentState.status,
cwd: agentState.cwd,
lastError: agentState.lastError ?? null,
terminalExit: agentState.terminalExit ?? null,
projectPlacement,
}
: null;
+3 -238
View File
@@ -1,30 +1,15 @@
import { useCallback, useMemo, useState, type ComponentType } from "react";
import { useCallback, useState, type ComponentType } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import { Bot, ChevronDown, Plus, SquarePen, SquareTerminal } from "lucide-react-native";
import { Plus, SquarePen, SquareTerminal } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { getProviderIcon } from "@/components/provider-icons";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { generateDraftId } from "@/stores/draft-keys";
import { useProviderRecency } from "@/stores/provider-recency-store";
import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { toErrorMessage } from "@/utils/error-messages";
import {
getWorkspaceExecutionAuthority,
requireWorkspaceRecordId,
} from "@/utils/workspace-execution";
const MAX_VISIBLE_PROVIDER_TILES = 4;
import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
function useLauncherPanelDescriptor() {
return {
@@ -45,70 +30,10 @@ function LauncherPanel() {
const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
const { providers, recordUsage } = useProviderRecency();
const setAgents = useSessionStore((state) => state.setAgents);
const [pendingAction, setPendingAction] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(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() {
}}
/>
</View>
<Text style={styles.sectionLabel}>Terminal Agents</Text>
<View style={styles.providerGrid}>
{visibleProviders.map((provider) => (
<ProviderTile
key={provider.id}
provider={provider}
disabled={actionsDisabled}
pending={pendingAction === provider.id}
onPress={() => {
void launchTerminalAgent(provider.id);
}}
/>
))}
{overflowProviders.length > 0 ? (
<ViewAllProvidersTile
providers={overflowProviders}
disabled={actionsDisabled}
pendingProviderId={pendingAction}
onSelectProvider={(providerId) => {
void launchTerminalAgent(providerId);
}}
/>
) : null}
</View>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
</ScrollView>
@@ -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 (
<Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ hovered, pressed }) => [
styles.providerTile,
(hovered || pressed) && !disabled ? styles.tileInteractive : null,
disabled ? styles.tileDisabled : null,
]}
>
<View style={styles.providerIconWrap}>
{pending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<Icon size={16} color={theme.colors.foreground} />
)}
</View>
<Text style={styles.providerLabel}>{provider.label}</Text>
</Pressable>
);
}
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 (
<DropdownMenu>
<DropdownMenuTrigger disabled={disabled} style={styles.providerTile}>
{({ open }) => (
<>
<View style={styles.providerIconWrap}>
<Bot size={16} color={theme.colors.foreground} />
</View>
<Text style={styles.providerLabel}>More</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
{open ? <View style={styles.dropdownOutline} /> : null}
</>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={260}>
{providers.map((provider) => {
const Icon = getProviderIcon(provider.id);
return (
<DropdownMenuItem
key={provider.id}
description={provider.description}
onSelect={() => onSelectProvider(provider.id as AgentProvider)}
leading={<Icon size={16} color={theme.colors.foregroundMuted} />}
status={pendingProviderId === provider.id ? "pending" : "idle"}
pendingLabel={`Launching ${provider.label}...`}
>
{provider.label}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
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,
@@ -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<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(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 <View style={styles.container} />;
}
if (terminalId) {
return (
<TerminalPane
serverId={serverId}
cwd={agent.cwd}
terminalId={terminalId}
isPaneFocused={isPaneFocused}
/>
);
}
if (isCreating) {
return (
<View style={styles.state} testID="terminal-agent-loading">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.message}>Opening terminal…</Text>
</View>
);
}
if (createError) {
return (
<View style={styles.state} testID="terminal-agent-error">
<Text style={styles.title}>Failed to open terminal</Text>
<Text style={styles.message}>{createError}</Text>
</View>
);
}
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 (
<View style={styles.state} testID="terminal-agent-closed">
<Text style={styles.title}>{getTerminalExitTitle(agent)}</Text>
<Text style={styles.message}>{getTerminalExitMessage(agent)}</Text>
{terminalExit ? (
<View style={styles.detailsCard}>
{exitMeta ? <Text style={styles.detailsLabel}>{exitMeta}</Text> : null}
{terminalExit.outputLines.length > 0 ? (
<Text style={styles.output}>{terminalExit.outputLines.join("\n")}</Text>
) : null}
</View>
) : null}
<Text style={styles.message}>Reopen the agent from the sessions list to start it again.</Text>
</View>
);
}
return (
<View style={styles.state} testID="terminal-agent-idle">
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
</View>
);
}
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,
},
}));
@@ -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,
@@ -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,
@@ -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<string, Agent>([
[
"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", () => {
@@ -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,
@@ -92,7 +92,7 @@ export const useProviderRecencyStore = create<ProviderRecencyStoreState>()(
},
}),
{
name: "terminal-agent-provider-recency",
name: "agent-provider-recency",
version: PROVIDER_RECENCY_STORE_VERSION,
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
-5
View File
@@ -80,13 +80,10 @@ export interface AgentRuntimeInfo {
extra?: Record<string, unknown>;
}
type TerminalExitDetails = NonNullable<AgentSnapshotPayload["terminalExit"]>;
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<SessionStore>()(
id: agent.id,
serverId,
title: agent.title ?? null,
terminal: agent.terminal,
status: agent.status,
lastActivityAt,
cwd: agent.cwd,
@@ -1,52 +0,0 @@
import { create } from "zustand";
interface BuildTerminalAgentReopenKeyInput {
serverId: string;
agentId: string;
}
interface RequestTerminalAgentReopenInput {
serverId: string;
agentId: string;
}
interface TerminalAgentReopenStore {
reopenIntentVersionByAgentKey: Record<string, number>;
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<TerminalAgentReopenStore>()((set) => ({
reopenIntentVersionByAgentKey: {},
requestReopen: ({ serverId, agentId }) => {
const key = buildTerminalAgentReopenKey({ serverId, agentId });
if (!key) {
return;
}
set((state) => ({
reopenIntentVersionByAgentKey: {
...state.reopenIntentVersionByAgentKey,
[key]: (state.reopenIntentVersionByAgentKey[key] ?? 0) + 1,
},
}));
},
}));
@@ -5,7 +5,6 @@ export type AgentDirectoryEntry = Pick<
| "id"
| "serverId"
| "title"
| "terminal"
| "status"
| "lastActivityAt"
| "cwd"
@@ -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,
@@ -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);
});
});
@@ -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);
}
-3
View File
@@ -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<AgentListItem> = {
{ 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),
-14
View File
@@ -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<AgentSendResult> = {
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;
@@ -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<T>(): Deferred<T> {
return { promise, resolve, reject };
}
class EventPushable<T> implements AsyncIterable<T> {
private queue: T[] = [];
private resolvers: Array<(value: IteratorResult<T, void>) => 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<T> {
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<AgentManagerEvent, { type: "agent_state" }>["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");
@@ -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<ForegroundTurnWaiter>;
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<Promise<void>>();
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<AgentKind | null> {
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<string | null> {
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<string, string>;
attention?: {
requiresAttention: boolean;
attentionReason?: "finished" | "error" | "permission" | null;
attentionTimestamp?: Date | null;
};
},
): Promise<ManagedTerminalAgent> {
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<string, string>;
attention?: AttentionState;
},
): Promise<ManagedTerminalAgent> {
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<string, AgentPermissionRequest>(),
pendingReplacement: false,
activeForegroundTurnId: null,
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
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<void> {
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;
}
@@ -58,7 +58,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
config: { ...baseConfig, ...configOverrides },
lifecycle,
@@ -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;
}
@@ -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<string, string>;
};
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<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
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.
@@ -57,7 +57,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
config,
lifecycle,
@@ -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"
@@ -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: [],
},
@@ -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<string, string | undefined>, 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<boolean> {
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 };
}
}
@@ -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<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args: [...launchPrefix.args],
env: terminalEnv,
};
}
async isAvailable(): Promise<boolean> {
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 };
}
}
@@ -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<string, string | undefined>, 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<string, string | undefined>, 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<boolean> {
const command = this.runtimeSettings?.command;
if (command?.mode === "replace") {
@@ -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<string, string | undefined>, 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<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args: [
...launchPrefix.args,
"resume",
handle.nativeHandle ?? handle.sessionId,
],
env: terminalEnv,
};
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
const child = this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
@@ -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<string, string | undefined>, 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<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args: [...launchPrefix.args, "--resume"],
env: terminalEnv,
};
}
async isAvailable(): Promise<boolean> {
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 };
}
}
@@ -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<string, string | undefined>, 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<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args: [...launchPrefix.args, "--session", handle.nativeHandle ?? handle.sessionId],
env: terminalEnv,
};
}
async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command;
if (command?.mode === "replace") {
@@ -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);
});
});
+1 -16
View File
@@ -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, {
@@ -33,7 +33,6 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsTerminalMode: false,
};
interface ScriptedAgentBehavior {
@@ -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,
};
}
@@ -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,
});
});
});
@@ -7,7 +7,6 @@ import { isValidAgentProvider } from "./agent/provider-manifest.js";
export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSessionConfig> {
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,
@@ -368,9 +368,6 @@ export class ScheduleService {
private async executeSchedule(schedule: StoredSchedule): Promise<ScheduleExecutionResult> {
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,
@@ -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,
+24 -140
View File
@@ -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<ManagedAgent> {
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<T extends { id: string }>(terminals: T[]): T[] {
return terminals.filter((terminal) => !this.agentManager.isTerminalBoundToAgent(terminal.id));
return terminals;
}
private toTerminalInfo(terminal: Pick<TerminalSession, "id" | "name" | "getTitle">): {
@@ -7328,46 +7259,6 @@ export class Session {
}
}
private async createOrResumeAgentTerminal(agentId: string): Promise<TerminalSession> {
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<TerminalSession[]> {
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,
},
});
@@ -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({
@@ -30,7 +30,6 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
};
type Deferred<T> = {
-12
View File
@@ -95,7 +95,6 @@ const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
supportsMcpServers: z.boolean(),
supportsReasoningStream: z.boolean(),
supportsToolInvocations: z.boolean(),
supportsTerminalMode: z.boolean(),
});
const AgentUsageSchema: z.ZodType<AgentUsage> = 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<AgentRuntimeInfo> = 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(),
@@ -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<Array<{ id: string; title?: string }>> = [];
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 }> = [];
@@ -33,12 +33,7 @@ export interface TerminalManager {
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
}
type AgentBoundTerminalTitleHandler = (input: { agentId: string; title: string }) => Promise<void> | void;
export function createTerminalManager(options?: {
resolveAgentIdForTerminal?: (terminalId: string) => string | null;
onAgentBoundTerminalTitleChange?: AgentBoundTerminalTitleHandler;
}): TerminalManager {
export function createTerminalManager(): TerminalManager {
const terminalsByCwd = new Map<string, TerminalSession[]>();
const terminalsById = new Map<string, TerminalSession>();
const terminalExitUnsubscribeById = new Map<string, () => 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);