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:
@@ -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)
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user