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. */
|
/** Assert the launcher panel has a "New Chat" tile. */
|
||||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||||
@@ -122,12 +116,6 @@ export async function clickTerminal(page: Page): Promise<void> {
|
|||||||
await button.click();
|
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 ──────────────────────────────────────────────────
|
// ─── Tab title assertions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
clickNewChat,
|
clickNewChat,
|
||||||
clickProviderTile,
|
|
||||||
clickTerminal,
|
clickTerminal,
|
||||||
countTabsOfKind,
|
countTabsOfKind,
|
||||||
getTabTestIds,
|
getTabTestIds,
|
||||||
waitForTabWithTitle,
|
|
||||||
} from "./launcher";
|
} from "./launcher";
|
||||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
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);
|
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> {
|
export async function createAgentChatFromLauncher(page: Page): Promise<void> {
|
||||||
await clickNewChat(page);
|
await clickNewChat(page);
|
||||||
await expect(composerInput(page)).toBeVisible({ timeout: 15_000 });
|
await expect(composerInput(page)).toBeVisible({ timeout: 15_000 });
|
||||||
|
|||||||
@@ -186,26 +186,6 @@ export async function createChatAgentFromWorkspaceSetup(
|
|||||||
await dialog.getByRole("button", { name: "Send message" }).click();
|
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> {
|
export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Promise<void> {
|
||||||
await workspaceSetupDialog(page)
|
await workspaceSetupDialog(page)
|
||||||
.getByRole("button", { name: /^Terminal Create the workspace/i })
|
.getByRole("button", { name: /^Terminal Create the workspace/i })
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { createTempGitRepo } from "./helpers/workspace";
|
|||||||
import {
|
import {
|
||||||
gotoWorkspace,
|
gotoWorkspace,
|
||||||
waitForLauncherPanel,
|
waitForLauncherPanel,
|
||||||
assertProviderTilesVisible,
|
|
||||||
assertNewChatTileVisible,
|
assertNewChatTileVisible,
|
||||||
assertTerminalTileVisible,
|
assertTerminalTileVisible,
|
||||||
assertSingleNewTabButton,
|
assertSingleNewTabButton,
|
||||||
@@ -11,7 +10,6 @@ import {
|
|||||||
pressNewTabShortcut,
|
pressNewTabShortcut,
|
||||||
clickNewChat,
|
clickNewChat,
|
||||||
clickTerminal,
|
clickTerminal,
|
||||||
clickProviderTile,
|
|
||||||
countTabsOfKind,
|
countTabsOfKind,
|
||||||
getTabTestIds,
|
getTabTestIds,
|
||||||
waitForTabWithTitle,
|
waitForTabWithTitle,
|
||||||
@@ -49,7 +47,7 @@ test.afterAll(async () => {
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
test.describe("Launcher tab", () => {
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
@@ -59,7 +57,6 @@ test.describe("Launcher tab", () => {
|
|||||||
await waitForLauncherPanel(page);
|
await waitForLauncherPanel(page);
|
||||||
await assertNewChatTileVisible(page);
|
await assertNewChatTileVisible(page);
|
||||||
await assertTerminalTileVisible(page);
|
await assertTerminalTileVisible(page);
|
||||||
await assertProviderTilesVisible(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("opening two new tabs creates two launcher tabs", async ({ 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);
|
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 }) => {
|
test("tab bar shows a single + button per pane", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceId);
|
await gotoWorkspace(page, workspaceId);
|
||||||
await assertSingleNewTabButton(page);
|
await assertSingleNewTabButton(page);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { createTempGitRepo } from "./helpers/workspace";
|
|||||||
import {
|
import {
|
||||||
createAgentChatFromLauncher,
|
createAgentChatFromLauncher,
|
||||||
createStandaloneTerminalFromLauncher,
|
createStandaloneTerminalFromLauncher,
|
||||||
createTerminalAgentFromLauncher,
|
|
||||||
expectTerminalCwd,
|
expectTerminalCwd,
|
||||||
} from "./helpers/workspace-lifecycle";
|
} from "./helpers/workspace-lifecycle";
|
||||||
import { connectWorkspaceSetupClient, seedProjectForWorkspaceSetup } from "./helpers/workspace-setup";
|
import { connectWorkspaceSetupClient, seedProjectForWorkspaceSetup } from "./helpers/workspace-setup";
|
||||||
@@ -22,30 +21,6 @@ test.describe("Workspace lifecycle", () => {
|
|||||||
test.describe.configure({ retries: 1 });
|
test.describe.configure({ retries: 1 });
|
||||||
|
|
||||||
test.describe("Main checkout", () => {
|
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("creates an agent chat via New Chat", async ({ page }) => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
@@ -97,53 +72,6 @@ test.describe("Workspace lifecycle", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Worktree workspace", () => {
|
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("creates an agent chat via New Chat", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
connectWorkspaceSetupClient,
|
connectWorkspaceSetupClient,
|
||||||
createChatAgentFromWorkspaceSetup,
|
createChatAgentFromWorkspaceSetup,
|
||||||
createStandaloneTerminalFromWorkspaceSetup,
|
createStandaloneTerminalFromWorkspaceSetup,
|
||||||
createTerminalAgentFromWorkspaceSetup,
|
|
||||||
createWorkspaceFromSidebar,
|
createWorkspaceFromSidebar,
|
||||||
findWorktreeWorkspaceForProject,
|
findWorktreeWorkspaceForProject,
|
||||||
openHomeWithProject,
|
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("first terminal attaches to the created workspace", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { formatTimeAgo } from "@/utils/time";
|
|||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
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 { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
@@ -158,12 +158,6 @@ function SessionRow({
|
|||||||
>
|
>
|
||||||
{agent.title || "New session"}
|
{agent.title || "New session"}
|
||||||
</Text>
|
</Text>
|
||||||
{agent.terminal ? (
|
|
||||||
<SessionBadge
|
|
||||||
label="Terminal"
|
|
||||||
icon={<SquareTerminal size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{agent.archivedAt ? (
|
{agent.archivedAt ? (
|
||||||
<SessionBadge
|
<SessionBadge
|
||||||
label="Archived"
|
label="Archived"
|
||||||
@@ -261,7 +255,6 @@ export function AgentList({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
pin: Boolean(agent.archivedAt),
|
pin: Boolean(agent.archivedAt),
|
||||||
requestReopen: agent.terminal && agent.status === "closed",
|
|
||||||
});
|
});
|
||||||
router.navigate(route as any);
|
router.navigate(route as any);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
|
import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
|
||||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
|
||||||
import { createNameId } from "mnemonic-id";
|
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 { Composer } from "@/components/composer";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { useProviderRecency } from "@/stores/provider-recency-store";
|
|
||||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
@@ -24,8 +20,6 @@ import {
|
|||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import type { MessagePayload } from "./message-input";
|
import type { MessagePayload } from "./message-input";
|
||||||
|
|
||||||
type SetupStep = "choose" | "chat" | "terminal-agent";
|
|
||||||
|
|
||||||
export function WorkspaceSetupDialog() {
|
export function WorkspaceSetupDialog() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -34,15 +28,12 @@ export function WorkspaceSetupDialog() {
|
|||||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||||
const setAgents = useSessionStore((state) => state.setAgents);
|
const setAgents = useSessionStore((state) => state.setAgents);
|
||||||
const [step, setStep] = useState<SetupStep>("choose");
|
const [step, setStep] = useState<"choose" | "chat">("choose");
|
||||||
const [terminalPrompt, setTerminalPrompt] = useState("");
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||||
typeof normalizeWorkspaceDescriptor
|
typeof normalizeWorkspaceDescriptor
|
||||||
> | null>(null);
|
> | null>(null);
|
||||||
const [pendingAction, setPendingAction] = useState<"chat" | "terminal-agent" | "terminal" | null>(
|
const [pendingAction, setPendingAction] = useState<"chat" | "terminal" | null>(null);
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const serverId = pendingWorkspaceSetup?.serverId ?? "";
|
const serverId = pendingWorkspaceSetup?.serverId ?? "";
|
||||||
const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? "";
|
const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? "";
|
||||||
@@ -66,13 +57,9 @@ export function WorkspaceSetupDialog() {
|
|||||||
if (!composerState && pendingWorkspaceSetup) {
|
if (!composerState && pendingWorkspaceSetup) {
|
||||||
throw new Error("Workspace setup composer state is required");
|
throw new Error("Workspace setup composer state is required");
|
||||||
}
|
}
|
||||||
const { providers: sortedProviders, recordUsage } = useProviderRecency(
|
|
||||||
composerState?.providerDefinitions ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStep("choose");
|
setStep("choose");
|
||||||
setTerminalPrompt("");
|
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setCreatedWorkspace(null);
|
setCreatedWorkspace(null);
|
||||||
setPendingAction(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 () => {
|
const handleCreateTerminal = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setPendingAction("terminal");
|
setPendingAction("terminal");
|
||||||
@@ -344,16 +279,6 @@ export function WorkspaceSetupDialog() {
|
|||||||
setStep("chat");
|
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
|
<ChoiceCard
|
||||||
title="Terminal"
|
title="Terminal"
|
||||||
description="Create the workspace, then open a standalone terminal tab."
|
description="Create the workspace, then open a standalone terminal tab."
|
||||||
@@ -408,68 +333,6 @@ export function WorkspaceSetupDialog() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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}
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||||
</AdaptiveModalSheet>
|
</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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
header: {
|
header: {
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
@@ -655,69 +482,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: theme.colors.surface2,
|
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: {
|
errorText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.destructive,
|
color: theme.colors.destructive,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ function createAgent(status: Agent["status"]): Agent {
|
|||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
terminal: false,
|
|
||||||
status,
|
status,
|
||||||
createdAt: new Date(0),
|
createdAt: new Date(0),
|
||||||
updatedAt: new Date(0),
|
updatedAt: new Date(0),
|
||||||
@@ -20,7 +19,6 @@ function createAgent(status: Agent["status"]): Agent {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: false,
|
|
||||||
},
|
},
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ function createAgent(id: string): Agent {
|
|||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
id,
|
id,
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
terminal: false,
|
|
||||||
status: "running",
|
status: "running",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -30,7 +29,6 @@ function createAgent(id: string): Agent {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: false,
|
|
||||||
},
|
},
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
|
|||||||
@@ -6,13 +6,6 @@ export interface AgentScreenAgent {
|
|||||||
status: "initializing" | "idle" | "running" | "error" | "closed";
|
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||||
cwd: string;
|
cwd: string;
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
terminalExit?: {
|
|
||||||
command: string;
|
|
||||||
message: string;
|
|
||||||
exitCode: number | null;
|
|
||||||
signal: number | null;
|
|
||||||
outputLines: string[];
|
|
||||||
} | null;
|
|
||||||
projectPlacement?: {
|
projectPlacement?: {
|
||||||
checkout?: {
|
checkout?: {
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export function useAggregatedAgents(options?: {
|
|||||||
serverId,
|
serverId,
|
||||||
serverLabel,
|
serverLabel,
|
||||||
title: agent.title ?? null,
|
title: agent.title ?? null,
|
||||||
terminal: agent.terminal,
|
|
||||||
status: agent.status,
|
status: agent.status,
|
||||||
lastActivityAt: agent.lastActivityAt,
|
lastActivityAt: agent.lastActivityAt,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ function makeAgent(input?: Partial<Agent>): Agent {
|
|||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
id: input?.id ?? "agent-1",
|
id: input?.id ?? "agent-1",
|
||||||
provider: input?.provider ?? "codex",
|
provider: input?.provider ?? "codex",
|
||||||
terminal: input?.terminal ?? false,
|
|
||||||
status: input?.status ?? "idle",
|
status: input?.status ?? "idle",
|
||||||
createdAt: input?.createdAt ?? timestamp,
|
createdAt: input?.createdAt ?? timestamp,
|
||||||
updatedAt: input?.updatedAt ?? timestamp,
|
updatedAt: input?.updatedAt ?? timestamp,
|
||||||
@@ -21,7 +20,6 @@ function makeAgent(input?: Partial<Agent>): Agent {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: false,
|
|
||||||
},
|
},
|
||||||
currentModeId: input?.currentModeId ?? null,
|
currentModeId: input?.currentModeId ?? null,
|
||||||
availableModes: input?.availableModes ?? [],
|
availableModes: input?.availableModes ?? [],
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ function toAggregatedAgent(params: {
|
|||||||
serverId: params.serverId,
|
serverId: params.serverId,
|
||||||
serverLabel: params.serverLabel,
|
serverLabel: params.serverLabel,
|
||||||
title: source.title ?? null,
|
title: source.title ?? null,
|
||||||
terminal: source.terminal,
|
|
||||||
status: source.status,
|
status: source.status,
|
||||||
lastActivityAt: source.lastActivityAt,
|
lastActivityAt: source.lastActivityAt,
|
||||||
cwd: source.cwd,
|
cwd: source.cwd,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
|||||||
serverId: "server-a",
|
serverId: "server-a",
|
||||||
id: "agent-1",
|
id: "agent-1",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
terminal: false,
|
|
||||||
status: "running",
|
status: "running",
|
||||||
createdAt: new Date("2026-04-01T03:00:00.000Z"),
|
createdAt: new Date("2026-04-01T03:00:00.000Z"),
|
||||||
updatedAt: 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,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: false,
|
|
||||||
},
|
},
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
|||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { usePaneContext } from "@/panels/pane-context";
|
import { usePaneContext } from "@/panels/pane-context";
|
||||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { TerminalAgentPanel } from "@/panels/terminal-agent-panel";
|
|
||||||
import {
|
import {
|
||||||
useHostRuntimeClient,
|
useHostRuntimeClient,
|
||||||
useHostRuntimeConnectionStatus,
|
useHostRuntimeConnectionStatus,
|
||||||
@@ -250,11 +249,9 @@ function AgentPanelBody({
|
|||||||
return {
|
return {
|
||||||
serverId: agent?.serverId ?? null,
|
serverId: agent?.serverId ?? null,
|
||||||
id: agent?.id ?? null,
|
id: agent?.id ?? null,
|
||||||
terminal: agent?.terminal ?? false,
|
|
||||||
status: agent?.status ?? null,
|
status: agent?.status ?? null,
|
||||||
cwd: agent?.cwd ?? null,
|
cwd: agent?.cwd ?? null,
|
||||||
lastError: agent?.lastError ?? null,
|
lastError: agent?.lastError ?? null,
|
||||||
terminalExit: agent?.terminalExit ?? null,
|
|
||||||
archivedAt: agent?.archivedAt ?? null,
|
archivedAt: agent?.archivedAt ?? null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -378,7 +375,6 @@ function AgentPanelBody({
|
|||||||
status: agentState.status,
|
status: agentState.status,
|
||||||
cwd: agentState.cwd,
|
cwd: agentState.cwd,
|
||||||
lastError: agentState.lastError ?? null,
|
lastError: agentState.lastError ?? null,
|
||||||
terminalExit: agentState.terminalExit ?? null,
|
|
||||||
projectPlacement,
|
projectPlacement,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@@ -395,27 +391,6 @@ function AgentPanelBody({
|
|||||||
|
|
||||||
const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId }));
|
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 (
|
return (
|
||||||
<ChatAgentContent
|
<ChatAgentContent
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
@@ -480,11 +455,9 @@ function ChatAgentContent({
|
|||||||
return {
|
return {
|
||||||
serverId: agent?.serverId ?? null,
|
serverId: agent?.serverId ?? null,
|
||||||
id: agent?.id ?? null,
|
id: agent?.id ?? null,
|
||||||
terminal: agent?.terminal ?? false,
|
|
||||||
status: agent?.status ?? null,
|
status: agent?.status ?? null,
|
||||||
cwd: agent?.cwd ?? null,
|
cwd: agent?.cwd ?? null,
|
||||||
lastError: agent?.lastError ?? null,
|
lastError: agent?.lastError ?? null,
|
||||||
terminalExit: agent?.terminalExit ?? null,
|
|
||||||
archivedAt: agent?.archivedAt ?? null,
|
archivedAt: agent?.archivedAt ?? null,
|
||||||
requiresAttention: agent?.requiresAttention ?? false,
|
requiresAttention: agent?.requiresAttention ?? false,
|
||||||
attentionReason: agent?.attentionReason ?? null,
|
attentionReason: agent?.attentionReason ?? null,
|
||||||
@@ -716,7 +689,6 @@ function ChatAgentContent({
|
|||||||
status: agentState.status,
|
status: agentState.status,
|
||||||
cwd: agentState.cwd,
|
cwd: agentState.cwd,
|
||||||
lastError: agentState.lastError ?? null,
|
lastError: agentState.lastError ?? null,
|
||||||
terminalExit: agentState.terminalExit ?? null,
|
|
||||||
projectPlacement,
|
projectPlacement,
|
||||||
}
|
}
|
||||||
: null;
|
: 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 { 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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import invariant from "tiny-invariant";
|
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 { usePaneContext } from "@/panels/pane-context";
|
||||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
import { useProviderRecency } from "@/stores/provider-recency-store";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
|
||||||
import { toErrorMessage } from "@/utils/error-messages";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import {
|
import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
|
||||||
getWorkspaceExecutionAuthority,
|
|
||||||
requireWorkspaceRecordId,
|
|
||||||
} from "@/utils/workspace-execution";
|
|
||||||
|
|
||||||
const MAX_VISIBLE_PROVIDER_TILES = 4;
|
|
||||||
|
|
||||||
function useLauncherPanelDescriptor() {
|
function useLauncherPanelDescriptor() {
|
||||||
return {
|
return {
|
||||||
@@ -45,70 +30,10 @@ function LauncherPanel() {
|
|||||||
const workspaceDirectory = workspaceAuthority.ok
|
const workspaceDirectory = workspaceAuthority.ok
|
||||||
? workspaceAuthority.authority.workspaceDirectory
|
? workspaceAuthority.authority.workspaceDirectory
|
||||||
: null;
|
: null;
|
||||||
const { providers, recordUsage } = useProviderRecency();
|
|
||||||
const setAgents = useSessionStore((state) => state.setAgents);
|
|
||||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
invariant(target.kind === "launcher", "LauncherPanel requires launcher target");
|
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(() => {
|
const openDraftTab = useCallback(() => {
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setPendingAction("draft");
|
setPendingAction("draft");
|
||||||
@@ -187,34 +112,6 @@ function LauncherPanel() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
</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}
|
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</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"> = {
|
export const launcherPanelRegistration: PanelRegistration<"launcher"> = {
|
||||||
kind: "launcher",
|
kind: "launcher",
|
||||||
component: LauncherPanel,
|
component: LauncherPanel,
|
||||||
@@ -431,53 +243,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.medium,
|
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: {
|
errorText: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
color: theme.colors.destructive,
|
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,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: false,
|
|
||||||
},
|
},
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
@@ -1182,7 +1181,6 @@ describe("HostRuntimeStore", () => {
|
|||||||
const staleAgent: Agent = {
|
const staleAgent: Agent = {
|
||||||
...stale,
|
...stale,
|
||||||
serverId: host.serverId,
|
serverId: host.serverId,
|
||||||
terminal: false,
|
|
||||||
createdAt: new Date(stale.createdAt),
|
createdAt: new Date(stale.createdAt),
|
||||||
updatedAt: new Date(stale.updatedAt),
|
updatedAt: new Date(stale.updatedAt),
|
||||||
lastUserMessageAt: null,
|
lastUserMessageAt: null,
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: false,
|
supportsReasoningStream: false,
|
||||||
supportsToolInvocations: false,
|
supportsToolInvocations: false,
|
||||||
supportsTerminalMode: false,
|
|
||||||
};
|
};
|
||||||
const PROVIDER_DEFINITION_MAP = new Map(
|
const PROVIDER_DEFINITION_MAP = new Map(
|
||||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||||
@@ -832,7 +831,6 @@ function DraftAgentScreenContent({
|
|||||||
serverId,
|
serverId,
|
||||||
id: draftAgentIdRef.current,
|
id: draftAgentIdRef.current,
|
||||||
provider,
|
provider,
|
||||||
terminal: false,
|
|
||||||
status: "running",
|
status: "running",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ function makeAgent(input: {
|
|||||||
serverId: "srv",
|
serverId: "srv",
|
||||||
id: input.id,
|
id: input.id,
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
terminal: false,
|
|
||||||
status: "idle",
|
status: "idle",
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: createdAt,
|
updatedAt: createdAt,
|
||||||
@@ -32,7 +31,6 @@ function makeAgent(input: {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: false,
|
|
||||||
},
|
},
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
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", () => {
|
it("matches workspace agents using the workspace directory even when the route uses a numeric workspace id", () => {
|
||||||
const sessionAgents = new Map<string, Agent>([
|
const sessionAgents = new Map<string, Agent>([
|
||||||
[
|
[
|
||||||
"terminal-agent",
|
"recent-agent",
|
||||||
makeAgent({
|
makeAgent({
|
||||||
id: "terminal-agent",
|
id: "recent-agent",
|
||||||
cwd: "/tmp/workspace-lifecycle-main",
|
cwd: "/tmp/workspace-lifecycle-main",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -176,8 +174,8 @@ describe("workspace agent visibility", () => {
|
|||||||
workspaceDirectory: "/tmp/workspace-lifecycle-main",
|
workspaceDirectory: "/tmp/workspace-lifecycle-main",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.activeAgentIds).toEqual(new Set(["terminal-agent"]));
|
expect(result.activeAgentIds).toEqual(new Set(["recent-agent"]));
|
||||||
expect(result.knownAgentIds).toEqual(new Set(["terminal-agent"]));
|
expect(result.knownAgentIds).toEqual(new Set(["recent-agent"]));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("workspaceAgentVisibilityEqual", () => {
|
describe("workspaceAgentVisibilityEqual", () => {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: false,
|
supportsReasoningStream: false,
|
||||||
supportsToolInvocations: false,
|
supportsToolInvocations: false,
|
||||||
supportsTerminalMode: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkspaceDraftAgentTabProps = {
|
type WorkspaceDraftAgentTabProps = {
|
||||||
@@ -134,7 +133,6 @@ export function WorkspaceDraftAgentTab({
|
|||||||
serverId,
|
serverId,
|
||||||
id: tabId,
|
id: tabId,
|
||||||
provider: composerState.selectedProvider,
|
provider: composerState.selectedProvider,
|
||||||
terminal: false,
|
|
||||||
status: "running",
|
status: "running",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: 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,
|
version: PROVIDER_RECENCY_STORE_VERSION,
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
|
|||||||
@@ -80,13 +80,10 @@ export interface AgentRuntimeInfo {
|
|||||||
extra?: Record<string, unknown>;
|
extra?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TerminalExitDetails = NonNullable<AgentSnapshotPayload["terminalExit"]>;
|
|
||||||
|
|
||||||
export interface Agent {
|
export interface Agent {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
id: string;
|
id: string;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
terminal: boolean;
|
|
||||||
status: AgentLifecycleStatus;
|
status: AgentLifecycleStatus;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@@ -100,7 +97,6 @@ export interface Agent {
|
|||||||
runtimeInfo?: AgentRuntimeInfo;
|
runtimeInfo?: AgentRuntimeInfo;
|
||||||
lastUsage?: AgentUsage;
|
lastUsage?: AgentUsage;
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
terminalExit?: TerminalExitDetails | null;
|
|
||||||
title: string | null;
|
title: string | null;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
@@ -1127,7 +1123,6 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
id: agent.id,
|
id: agent.id,
|
||||||
serverId,
|
serverId,
|
||||||
title: agent.title ?? null,
|
title: agent.title ?? null,
|
||||||
terminal: agent.terminal,
|
|
||||||
status: agent.status,
|
status: agent.status,
|
||||||
lastActivityAt,
|
lastActivityAt,
|
||||||
cwd: agent.cwd,
|
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"
|
| "id"
|
||||||
| "serverId"
|
| "serverId"
|
||||||
| "title"
|
| "title"
|
||||||
| "terminal"
|
|
||||||
| "status"
|
| "status"
|
||||||
| "lastActivityAt"
|
| "lastActivityAt"
|
||||||
| "cwd"
|
| "cwd"
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
|||||||
serverId,
|
serverId,
|
||||||
id: snapshot.id,
|
id: snapshot.id,
|
||||||
provider: snapshot.provider,
|
provider: snapshot.provider,
|
||||||
terminal: snapshot.terminal === true,
|
|
||||||
status: snapshot.status as AgentLifecycleStatus,
|
status: snapshot.status as AgentLifecycleStatus,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
@@ -45,7 +44,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
|||||||
runtimeInfo: snapshot.runtimeInfo,
|
runtimeInfo: snapshot.runtimeInfo,
|
||||||
lastUsage: snapshot.lastUsage,
|
lastUsage: snapshot.lastUsage,
|
||||||
lastError: snapshot.lastError ?? null,
|
lastError: snapshot.lastError ?? null,
|
||||||
terminalExit: snapshot.terminalExit ?? null,
|
|
||||||
title: snapshot.title ?? null,
|
title: snapshot.title ?? null,
|
||||||
cwd: snapshot.cwd,
|
cwd: snapshot.cwd,
|
||||||
model: snapshot.model ?? null,
|
model: snapshot.model ?? null,
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ vi.mock("@react-native-async-storage/async-storage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import {
|
|
||||||
buildTerminalAgentReopenKey,
|
|
||||||
useTerminalAgentReopenStore,
|
|
||||||
} from "@/stores/terminal-agent-reopen-store";
|
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
const SERVER_ID = "server-1";
|
const SERVER_ID = "server-1";
|
||||||
@@ -40,39 +36,17 @@ describe("prepareWorkspaceTab", () => {
|
|||||||
splitSizesByWorkspace: {},
|
splitSizesByWorkspace: {},
|
||||||
pinnedAgentIdsByWorkspace: {},
|
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({
|
const route = prepareWorkspaceTab({
|
||||||
serverId: SERVER_ID,
|
serverId: SERVER_ID,
|
||||||
workspaceId: WORKSPACE_ID,
|
workspaceId: WORKSPACE_ID,
|
||||||
target: { kind: "agent", agentId: AGENT_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(route).toBe("/h/server-1/workspace/L3JlcG8vd29ya3RyZWU");
|
||||||
expect(
|
const key = "server-1:/repo/worktree";
|
||||||
useTerminalAgentReopenStore.getState().reopenIntentVersionByAgentKey[reopenKey as string],
|
expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(key)).toHaveLength(1);
|
||||||
).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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
import { useTerminalAgentReopenStore } from "@/stores/terminal-agent-reopen-store";
|
|
||||||
import {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
type WorkspaceTabTarget,
|
type WorkspaceTabTarget,
|
||||||
@@ -13,7 +12,6 @@ interface PrepareWorkspaceTabInput {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
pin?: boolean;
|
pin?: boolean;
|
||||||
requestReopen?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput {
|
interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput {
|
||||||
@@ -45,13 +43,6 @@ export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string {
|
|||||||
useWorkspaceLayoutStore.getState().pinAgent(key, target.agentId);
|
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);
|
return buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export interface AgentListItem {
|
|||||||
shortId: string;
|
shortId: string;
|
||||||
name: string;
|
name: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
terminal: boolean;
|
|
||||||
thinking: string;
|
thinking: string;
|
||||||
status: string;
|
status: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -67,7 +66,6 @@ export const agentLsSchema: OutputSchema<AgentListItem> = {
|
|||||||
{ header: "AGENT ID", field: "shortId", width: 12 },
|
{ header: "AGENT ID", field: "shortId", width: 12 },
|
||||||
{ header: "NAME", field: "name", width: 20 },
|
{ header: "NAME", field: "name", width: 20 },
|
||||||
{ header: "PROVIDER", field: "provider", width: 15 },
|
{ header: "PROVIDER", field: "provider", width: 15 },
|
||||||
{ header: "TERM", field: "terminal", width: 6 },
|
|
||||||
{ header: "THINKING", field: "thinking", width: 12 },
|
{ header: "THINKING", field: "thinking", width: 12 },
|
||||||
{
|
{
|
||||||
header: "STATUS",
|
header: "STATUS",
|
||||||
@@ -93,7 +91,6 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
|||||||
shortId: agent.id.slice(0, 7),
|
shortId: agent.id.slice(0, 7),
|
||||||
name: agent.title ?? "-",
|
name: agent.title ?? "-",
|
||||||
provider: model ? `${agent.provider}/${model}` : agent.provider,
|
provider: model ? `${agent.provider}/${model}` : agent.provider,
|
||||||
terminal: agent.terminal === true,
|
|
||||||
thinking: agent.effectiveThinkingOptionId ?? "auto",
|
thinking: agent.effectiveThinkingOptionId ?? "auto",
|
||||||
status: agent.status,
|
status: agent.status,
|
||||||
cwd: shortenPath(agent.cwd),
|
cwd: shortenPath(agent.cwd),
|
||||||
|
|||||||
@@ -17,11 +17,6 @@ export interface AgentSendResult {
|
|||||||
message: string;
|
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 */
|
/** Schema for agent send output */
|
||||||
export const agentSendSchema: OutputSchema<AgentSendResult> = {
|
export const agentSendSchema: OutputSchema<AgentSendResult> = {
|
||||||
idField: "agentId",
|
idField: "agentId",
|
||||||
@@ -265,15 +260,6 @@ export async function runSendCommand(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.close().catch(() => {});
|
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
|
// Re-throw CommandError as-is
|
||||||
if (err && typeof err === "object" && "code" in err) {
|
if (err && typeof err === "object" && "code" in err) {
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
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 { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { randomUUID } from "node:crypto";
|
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 { DbAgentTimelineStore } from "../db/db-agent-timeline-store.js";
|
||||||
import { openPaseoDatabase, type PaseoDatabaseHandle } from "../db/sqlite-database.js";
|
import { openPaseoDatabase, type PaseoDatabaseHandle } from "../db/sqlite-database.js";
|
||||||
import { projects, workspaces } from "../db/schema.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 { 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 {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
@@ -41,47 +38,6 @@ function deferred<T>(): Deferred<T> {
|
|||||||
return { promise, resolve, reject };
|
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 = {
|
const TEST_CAPABILITIES = {
|
||||||
supportsStreaming: false,
|
supportsStreaming: false,
|
||||||
supportsSessionPersistence: false,
|
supportsSessionPersistence: false,
|
||||||
@@ -89,12 +45,6 @@ const TEST_CAPABILITIES = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: false,
|
supportsReasoningStream: false,
|
||||||
supportsToolInvocations: false,
|
supportsToolInvocations: false,
|
||||||
supportsTerminalMode: false,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const TERMINAL_TEST_CAPABILITIES = {
|
|
||||||
...TEST_CAPABILITIES,
|
|
||||||
supportsTerminalMode: true,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
async function seedWorkspace(
|
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", () => {
|
describe("AgentManager", () => {
|
||||||
const logger = createTestLogger();
|
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 () => {
|
test("normalizeConfig does not inject default model when omitted", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { basename, resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { stat } from "node:fs/promises";
|
import { stat } from "node:fs/promises";
|
||||||
import {
|
import {
|
||||||
AGENT_LIFECYCLE_STATUSES,
|
AGENT_LIFECYCLE_STATUSES,
|
||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||||
import type { TerminalExitInfo, TerminalSession } from "../../terminal/terminal.js";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
@@ -31,7 +30,6 @@ import type {
|
|||||||
AgentRuntimeInfo,
|
AgentRuntimeInfo,
|
||||||
ListPersistedAgentsOptions,
|
ListPersistedAgentsOptions,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
TerminalCommand,
|
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
||||||
@@ -114,13 +112,6 @@ export type WaitForAgentStartOptions = {
|
|||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TerminalExitDetails {
|
|
||||||
command: string;
|
|
||||||
message: string;
|
|
||||||
exitCode: number | null;
|
|
||||||
signal: number | null;
|
|
||||||
outputLines: string[];
|
|
||||||
}
|
|
||||||
type AttentionState =
|
type AttentionState =
|
||||||
| { requiresAttention: false }
|
| { requiresAttention: false }
|
||||||
| {
|
| {
|
||||||
@@ -149,7 +140,6 @@ type ManagedAgentBase = {
|
|||||||
id: string;
|
id: string;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
terminal: boolean;
|
|
||||||
capabilities: AgentCapabilityFlags;
|
capabilities: AgentCapabilityFlags;
|
||||||
config: AgentSessionConfig;
|
config: AgentSessionConfig;
|
||||||
runtimeInfo?: AgentRuntimeInfo;
|
runtimeInfo?: AgentRuntimeInfo;
|
||||||
@@ -165,7 +155,6 @@ type ManagedAgentBase = {
|
|||||||
lastUserMessageAt: Date | null;
|
lastUserMessageAt: Date | null;
|
||||||
lastUsage?: AgentUsage;
|
lastUsage?: AgentUsage;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
terminalExit?: TerminalExitDetails;
|
|
||||||
attention: AttentionState;
|
attention: AttentionState;
|
||||||
foregroundTurnWaiters: Set<ForegroundTurnWaiter>;
|
foregroundTurnWaiters: Set<ForegroundTurnWaiter>;
|
||||||
unsubscribeSession: (() => void) | null;
|
unsubscribeSession: (() => void) | null;
|
||||||
@@ -181,7 +170,6 @@ type ManagedAgentBase = {
|
|||||||
|
|
||||||
type ManagedAgentWithSession = ManagedAgentBase & {
|
type ManagedAgentWithSession = ManagedAgentBase & {
|
||||||
session: AgentSession;
|
session: AgentSession;
|
||||||
terminal: false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ManagedAgentInitializing = ManagedAgentWithSession & {
|
type ManagedAgentInitializing = ManagedAgentWithSession & {
|
||||||
@@ -211,24 +199,11 @@ type ManagedAgentClosed = ManagedAgentBase & {
|
|||||||
activeForegroundTurnId: null;
|
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 =
|
export type ManagedAgent =
|
||||||
| ManagedAgentInitializing
|
| ManagedAgentInitializing
|
||||||
| ManagedAgentIdle
|
| ManagedAgentIdle
|
||||||
| ManagedAgentRunning
|
| ManagedAgentRunning
|
||||||
| ManagedAgentError
|
| ManagedAgentError
|
||||||
| ManagedTerminalAgent
|
|
||||||
| ManagedAgentClosed;
|
| ManagedAgentClosed;
|
||||||
|
|
||||||
export interface AgentMetricsSnapshot {
|
export interface AgentMetricsSnapshot {
|
||||||
@@ -247,7 +222,7 @@ type ActiveManagedAgent =
|
|||||||
| ManagedAgentRunning
|
| ManagedAgentRunning
|
||||||
| ManagedAgentError;
|
| ManagedAgentError;
|
||||||
|
|
||||||
type LiveManagedAgent = ActiveManagedAgent | ManagedTerminalAgent;
|
type LiveManagedAgent = ActiveManagedAgent;
|
||||||
|
|
||||||
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
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" });
|
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 {
|
function validateAgentId(agentId: string, source: string): string {
|
||||||
const result = AgentIdSchema.safeParse(agentId);
|
const result = AgentIdSchema.safeParse(agentId);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -396,14 +304,12 @@ export class AgentManager {
|
|||||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||||
private onAgentAttention?: AgentAttentionCallback;
|
private onAgentAttention?: AgentAttentionCallback;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private readonly terminalManager: TerminalManager | null;
|
|
||||||
|
|
||||||
constructor(options: AgentManagerOptions) {
|
constructor(options: AgentManagerOptions) {
|
||||||
this.idFactory = options?.idFactory ?? (() => randomUUID());
|
this.idFactory = options?.idFactory ?? (() => randomUUID());
|
||||||
this.registry = options?.registry;
|
this.registry = options?.registry;
|
||||||
this.durableTimelineStore = options?.durableTimelineStore;
|
this.durableTimelineStore = options?.durableTimelineStore;
|
||||||
this.onAgentAttention = options?.onAgentAttention;
|
this.onAgentAttention = options?.onAgentAttention;
|
||||||
this.terminalManager = options?.terminalManager ?? null;
|
|
||||||
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
||||||
if (options?.clients) {
|
if (options?.clients) {
|
||||||
for (const [provider, client] of Object.entries(options.clients)) {
|
for (const [provider, client] of Object.entries(options.clients)) {
|
||||||
@@ -624,51 +530,6 @@ export class AgentManager {
|
|||||||
return agent ? { ...agent } : null;
|
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[] {
|
getTimeline(id: string): AgentTimelineItem[] {
|
||||||
this.requireAgent(id);
|
this.requireAgent(id);
|
||||||
return this.timelineStore.getItems(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.`,
|
`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);
|
const session = await client.createSession(normalizedConfig, launchContext);
|
||||||
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
|
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
|
||||||
labels: options?.labels,
|
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
|
// Reconstruct an agent from provider persistence. Callers should explicitly
|
||||||
// hydrate timeline history after resume.
|
// hydrate timeline history after resume.
|
||||||
async resumeAgentFromPersistence(
|
async resumeAgentFromPersistence(
|
||||||
@@ -941,13 +689,7 @@ export class AgentManager {
|
|||||||
"closeAgent: start",
|
"closeAgent: start",
|
||||||
);
|
);
|
||||||
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
|
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
|
||||||
if (agent.terminal) {
|
await agent.session.close();
|
||||||
if (agent.terminalId) {
|
|
||||||
this.terminalManager?.killTerminal(agent.terminalId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await agent.session.close();
|
|
||||||
}
|
|
||||||
this.timelineStore.delete(agentId);
|
this.timelineStore.delete(agentId);
|
||||||
this.emitClosedAgent(closedAgent);
|
this.emitClosedAgent(closedAgent);
|
||||||
this.logger.trace({ agentId }, "closeAgent: completed");
|
this.logger.trace({ agentId }, "closeAgent: completed");
|
||||||
@@ -2050,7 +1792,6 @@ export class AgentManager {
|
|||||||
id: resolvedAgentId,
|
id: resolvedAgentId,
|
||||||
provider: config.provider,
|
provider: config.provider,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
terminal: false,
|
|
||||||
session,
|
session,
|
||||||
capabilities: session.capabilities,
|
capabilities: session.capabilities,
|
||||||
config,
|
config,
|
||||||
@@ -2071,7 +1812,6 @@ export class AgentManager {
|
|||||||
lastUserMessageAt: options?.lastUserMessageAt ?? null,
|
lastUserMessageAt: options?.lastUserMessageAt ?? null,
|
||||||
lastUsage: options?.lastUsage,
|
lastUsage: options?.lastUsage,
|
||||||
lastError: options?.lastError,
|
lastError: options?.lastError,
|
||||||
terminalExit: undefined,
|
|
||||||
attention:
|
attention:
|
||||||
options?.attention != null
|
options?.attention != null
|
||||||
? options.attention.requiresAttention
|
? 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(
|
private prepareAgentForClosure(
|
||||||
agent: LiveManagedAgent,
|
agent: LiveManagedAgent,
|
||||||
cancelReason: string,
|
cancelReason: string,
|
||||||
@@ -2284,10 +1868,6 @@ export class AgentManager {
|
|||||||
agent.unsubscribeSession();
|
agent.unsubscribeSession();
|
||||||
agent.unsubscribeSession = null;
|
agent.unsubscribeSession = null;
|
||||||
}
|
}
|
||||||
if (agent.terminal && agent.unsubscribeTerminalExit) {
|
|
||||||
agent.unsubscribeTerminalExit();
|
|
||||||
agent.unsubscribeTerminalExit = null;
|
|
||||||
}
|
|
||||||
for (const waiter of agent.foregroundTurnWaiters) {
|
for (const waiter of agent.foregroundTurnWaiters) {
|
||||||
waiter.callback({
|
waiter.callback({
|
||||||
type: "turn_canceled",
|
type: "turn_canceled",
|
||||||
@@ -2330,7 +1910,7 @@ export class AgentManager {
|
|||||||
if (!current) {
|
if (!current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (current.terminal || current.session == null) {
|
if (current.session == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.dispatchSessionEvent(current, event);
|
await this.dispatchSessionEvent(current, event);
|
||||||
@@ -3112,8 +2692,8 @@ export class AgentManager {
|
|||||||
|
|
||||||
private requireSessionAgent(id: string): ActiveManagedAgent {
|
private requireSessionAgent(id: string): ActiveManagedAgent {
|
||||||
const agent = this.requireAgent(id);
|
const agent = this.requireAgent(id);
|
||||||
if (agent.terminal || agent.session === null) {
|
if (agent.session === null) {
|
||||||
throw new Error(`Agent '${agent.id}' is a terminal agent and has no managed session`);
|
throw new Error(`Agent '${agent.id}' has no managed session`);
|
||||||
}
|
}
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: false,
|
|
||||||
},
|
},
|
||||||
config: { ...baseConfig, ...configOverrides },
|
config: { ...baseConfig, ...configOverrides },
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ export function toStoredAgentRecord(
|
|||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
persistence,
|
persistence,
|
||||||
lastError: agent.lastError ?? undefined,
|
lastError: agent.lastError ?? undefined,
|
||||||
terminalExit: agent.terminalExit ?? undefined,
|
|
||||||
requiresAttention: agent.attention.requiresAttention,
|
requiresAttention: agent.attention.requiresAttention,
|
||||||
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
|
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
|
||||||
attentionTimestamp: agent.attention.requiresAttention
|
attentionTimestamp: agent.attention.requiresAttention
|
||||||
@@ -88,7 +87,6 @@ export function toAgentPayload(
|
|||||||
id: agent.id,
|
id: agent.id,
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
terminal: agent.terminal,
|
|
||||||
model: agent.config.model ?? null,
|
model: agent.config.model ?? null,
|
||||||
thinkingOptionId,
|
thinkingOptionId,
|
||||||
effectiveThinkingOptionId,
|
effectiveThinkingOptionId,
|
||||||
@@ -115,10 +113,6 @@ export function toAgentPayload(
|
|||||||
payload.lastError = agent.lastError;
|
payload.lastError = agent.lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agent.terminalExit) {
|
|
||||||
payload.terminalExit = agent.terminalExit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle attention state
|
// Handle attention state
|
||||||
payload.requiresAttention = agent.attention.requiresAttention;
|
payload.requiresAttention = agent.attention.requiresAttention;
|
||||||
if (agent.attention.requiresAttention) {
|
if (agent.attention.requiresAttention) {
|
||||||
@@ -134,9 +128,6 @@ export function toAgentPayload(
|
|||||||
|
|
||||||
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
|
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
|
||||||
const serializable: SerializableAgentConfig = {};
|
const serializable: SerializableAgentConfig = {};
|
||||||
if (config.terminal !== undefined) {
|
|
||||||
serializable.terminal = config.terminal;
|
|
||||||
}
|
|
||||||
if (Object.prototype.hasOwnProperty.call(config, "title")) {
|
if (Object.prototype.hasOwnProperty.call(config, "title")) {
|
||||||
serializable.title = config.title ?? null;
|
serializable.title = config.title ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ export type AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: boolean;
|
supportsMcpServers: boolean;
|
||||||
supportsReasoningStream: boolean;
|
supportsReasoningStream: boolean;
|
||||||
supportsToolInvocations: boolean;
|
supportsToolInvocations: boolean;
|
||||||
supportsTerminalMode: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentPersistenceHandle = {
|
export type AgentPersistenceHandle = {
|
||||||
@@ -358,16 +357,9 @@ export type PersistedAgentDescriptor = {
|
|||||||
timeline: AgentTimelineItem[];
|
timeline: AgentTimelineItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TerminalCommand = {
|
|
||||||
command: string;
|
|
||||||
args: string[];
|
|
||||||
env?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AgentSessionConfig = {
|
export type AgentSessionConfig = {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
terminal?: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Provider-agnostic system/developer instruction string.
|
* Provider-agnostic system/developer instruction string.
|
||||||
* Mapped by each provider to its native instruction field.
|
* Mapped by each provider to its native instruction field.
|
||||||
@@ -437,12 +429,6 @@ export interface AgentClient {
|
|||||||
): Promise<AgentSession>;
|
): Promise<AgentSession>;
|
||||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
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).
|
* Check if this provider is available (CLI binary is installed).
|
||||||
* Returns true if available, false otherwise.
|
* Returns true if available, false otherwise.
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: false,
|
|
||||||
},
|
},
|
||||||
config,
|
config,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
|||||||
|
|
||||||
const SERIALIZABLE_CONFIG_SCHEMA = z
|
const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||||
.object({
|
.object({
|
||||||
terminal: z.boolean().optional(),
|
|
||||||
title: z.string().nullable().optional(),
|
title: z.string().nullable().optional(),
|
||||||
modeId: z.string().nullable().optional(),
|
modeId: z.string().nullable().optional(),
|
||||||
model: z.string().nullable().optional(),
|
model: z.string().nullable().optional(),
|
||||||
@@ -59,15 +58,6 @@ const STORED_AGENT_SCHEMA = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||||
lastError: z.string().nullable().optional(),
|
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(),
|
requiresAttention: z.boolean().optional(),
|
||||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||||
attentionTimestamp: z.string().nullable().optional(),
|
attentionTimestamp: z.string().nullable().optional(),
|
||||||
@@ -77,7 +67,6 @@ const STORED_AGENT_SCHEMA = z.object({
|
|||||||
|
|
||||||
export type SerializableAgentConfig = Pick<
|
export type SerializableAgentConfig = Pick<
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
| "terminal"
|
|
||||||
| "title"
|
| "title"
|
||||||
| "modeId"
|
| "modeId"
|
||||||
| "model"
|
| "model"
|
||||||
|
|||||||
@@ -125,21 +125,21 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
|||||||
{
|
{
|
||||||
id: "gemini",
|
id: "gemini",
|
||||||
label: "Gemini CLI",
|
label: "Gemini CLI",
|
||||||
description: "Google's terminal-based coding agent",
|
description: "Google's coding agent CLI",
|
||||||
defaultModeId: null,
|
defaultModeId: null,
|
||||||
modes: [],
|
modes: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "amp",
|
id: "amp",
|
||||||
label: "AMP",
|
label: "AMP",
|
||||||
description: "Sourcegraph's terminal-based coding agent",
|
description: "Sourcegraph's coding agent CLI",
|
||||||
defaultModeId: null,
|
defaultModeId: null,
|
||||||
modes: [],
|
modes: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "aider",
|
id: "aider",
|
||||||
label: "Aider",
|
label: "Aider",
|
||||||
description: "Paul Gauthier's terminal-based coding assistant",
|
description: "Paul Gauthier's coding assistant CLI",
|
||||||
defaultModeId: null,
|
defaultModeId: null,
|
||||||
modes: [],
|
modes: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,14 +9,10 @@ import type {
|
|||||||
AgentSession,
|
AgentSession,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
ListModelsOptions,
|
ListModelsOptions,
|
||||||
TerminalCommand,
|
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
applyProviderEnv,
|
|
||||||
findExecutable,
|
findExecutable,
|
||||||
isProviderCommandAvailable,
|
isProviderCommandAvailable,
|
||||||
resolveProviderCommandPrefix,
|
|
||||||
sanitizeTerminalEnv,
|
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
|
|
||||||
@@ -29,11 +25,8 @@ const AIDER_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: false,
|
supportsReasoningStream: false,
|
||||||
supportsToolInvocations: false,
|
supportsToolInvocations: false,
|
||||||
supportsTerminalMode: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type AiderAgentConfig = AgentSessionConfig & { provider: "aider" };
|
|
||||||
|
|
||||||
function resolveAiderBinary(): string {
|
function resolveAiderBinary(): string {
|
||||||
const found = findExecutable("aider");
|
const found = findExecutable("aider");
|
||||||
if (found) {
|
if (found) {
|
||||||
@@ -45,7 +38,7 @@ function resolveAiderBinary(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createUnsupportedSessionError(): Error {
|
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 {
|
export class AiderAgentClient implements AgentClient {
|
||||||
@@ -73,38 +66,10 @@ export class AiderAgentClient implements AgentClient {
|
|||||||
return [];
|
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> {
|
async isAvailable(): Promise<boolean> {
|
||||||
if (this.runtimeSettings?.command?.mode === "replace") {
|
if (this.runtimeSettings?.command?.mode === "replace") {
|
||||||
return existsSync(this.runtimeSettings.command.argv[0]);
|
return existsSync(this.runtimeSettings.command.argv[0]);
|
||||||
}
|
}
|
||||||
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAiderBinary);
|
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,
|
AgentSession,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
ListModelsOptions,
|
ListModelsOptions,
|
||||||
TerminalCommand,
|
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
applyProviderEnv,
|
|
||||||
findExecutable,
|
findExecutable,
|
||||||
isProviderCommandAvailable,
|
isProviderCommandAvailable,
|
||||||
resolveProviderCommandPrefix,
|
|
||||||
sanitizeTerminalEnv,
|
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
|
|
||||||
@@ -29,11 +25,8 @@ const AMP_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: false,
|
supportsReasoningStream: false,
|
||||||
supportsToolInvocations: false,
|
supportsToolInvocations: false,
|
||||||
supportsTerminalMode: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type AmpAgentConfig = AgentSessionConfig & { provider: "amp" };
|
|
||||||
|
|
||||||
function resolveAmpBinary(): string {
|
function resolveAmpBinary(): string {
|
||||||
const found = findExecutable("amp");
|
const found = findExecutable("amp");
|
||||||
if (found) {
|
if (found) {
|
||||||
@@ -45,7 +38,7 @@ function resolveAmpBinary(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createUnsupportedSessionError(): Error {
|
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 {
|
export class AmpAgentClient implements AgentClient {
|
||||||
@@ -73,37 +66,10 @@ export class AmpAgentClient implements AgentClient {
|
|||||||
return [];
|
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> {
|
async isAvailable(): Promise<boolean> {
|
||||||
if (this.runtimeSettings?.command?.mode === "replace") {
|
if (this.runtimeSettings?.command?.mode === "replace") {
|
||||||
return existsSync(this.runtimeSettings.command.argv[0]);
|
return existsSync(this.runtimeSettings.command.argv[0]);
|
||||||
}
|
}
|
||||||
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAmpBinary);
|
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,
|
ListPersistedAgentsOptions,
|
||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
TerminalCommand,
|
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
applyProviderEnv,
|
applyProviderEnv,
|
||||||
findExecutable,
|
findExecutable,
|
||||||
sanitizeTerminalEnv,
|
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
||||||
@@ -104,7 +102,6 @@ const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MODES: AgentMode[] = [
|
const DEFAULT_MODES: AgentMode[] = [
|
||||||
@@ -1099,73 +1096,6 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
return descriptors;
|
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> {
|
async isAvailable(): Promise<boolean> {
|
||||||
const command = this.runtimeSettings?.command;
|
const command = this.runtimeSettings?.command;
|
||||||
if (command?.mode === "replace") {
|
if (command?.mode === "replace") {
|
||||||
|
|||||||
@@ -19,11 +19,9 @@ import type {
|
|||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
ToolCallTimelineItem,
|
ToolCallTimelineItem,
|
||||||
AgentUsage,
|
AgentUsage,
|
||||||
AgentPersistenceHandle,
|
|
||||||
ListModelsOptions,
|
ListModelsOptions,
|
||||||
ListPersistedAgentsOptions,
|
ListPersistedAgentsOptions,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
TerminalCommand,
|
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
@@ -45,7 +43,6 @@ import {
|
|||||||
applyProviderEnv,
|
applyProviderEnv,
|
||||||
findExecutable,
|
findExecutable,
|
||||||
resolveProviderCommandPrefix,
|
resolveProviderCommandPrefix,
|
||||||
sanitizeTerminalEnv,
|
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||||
@@ -62,7 +59,6 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CODEX_MODES: AgentMode[] = [
|
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[]> {
|
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||||
const child = this.spawnAppServer();
|
const child = this.spawnAppServer();
|
||||||
const client = new CodexAppServerClient(child, this.logger);
|
const client = new CodexAppServerClient(child, this.logger);
|
||||||
|
|||||||
@@ -9,14 +9,10 @@ import type {
|
|||||||
AgentSession,
|
AgentSession,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
ListModelsOptions,
|
ListModelsOptions,
|
||||||
TerminalCommand,
|
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
applyProviderEnv,
|
|
||||||
findExecutable,
|
findExecutable,
|
||||||
isProviderCommandAvailable,
|
isProviderCommandAvailable,
|
||||||
resolveProviderCommandPrefix,
|
|
||||||
sanitizeTerminalEnv,
|
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
|
|
||||||
@@ -29,11 +25,8 @@ const GEMINI_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: false,
|
supportsReasoningStream: false,
|
||||||
supportsToolInvocations: false,
|
supportsToolInvocations: false,
|
||||||
supportsTerminalMode: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type GeminiAgentConfig = AgentSessionConfig & { provider: "gemini" };
|
|
||||||
|
|
||||||
function resolveGeminiBinary(): string {
|
function resolveGeminiBinary(): string {
|
||||||
const found = findExecutable("gemini");
|
const found = findExecutable("gemini");
|
||||||
if (found) {
|
if (found) {
|
||||||
@@ -45,7 +38,7 @@ function resolveGeminiBinary(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createUnsupportedSessionError(): Error {
|
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 {
|
export class GeminiAgentClient implements AgentClient {
|
||||||
@@ -73,56 +66,10 @@ export class GeminiAgentClient implements AgentClient {
|
|||||||
return [];
|
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> {
|
async isAvailable(): Promise<boolean> {
|
||||||
if (this.runtimeSettings?.command?.mode === "replace") {
|
if (this.runtimeSettings?.command?.mode === "replace") {
|
||||||
return existsSync(this.runtimeSettings.command.argv[0]);
|
return existsSync(this.runtimeSettings.command.argv[0]);
|
||||||
}
|
}
|
||||||
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveGeminiBinary);
|
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,
|
ListPersistedAgentsOptions,
|
||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
TerminalCommand,
|
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
applyProviderEnv,
|
applyProviderEnv,
|
||||||
findExecutable,
|
findExecutable,
|
||||||
resolveProviderCommandPrefix,
|
resolveProviderCommandPrefix,
|
||||||
sanitizeTerminalEnv,
|
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "../provider-launch-config.js";
|
} from "../provider-launch-config.js";
|
||||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||||
@@ -46,7 +44,6 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MODES: AgentMode[] = [
|
const DEFAULT_MODES: AgentMode[] = [
|
||||||
@@ -562,53 +559,6 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
return [];
|
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> {
|
async isAvailable(): Promise<boolean> {
|
||||||
const command = this.runtimeSettings?.command;
|
const command = this.runtimeSettings?.command;
|
||||||
if (command?.mode === "replace") {
|
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);
|
const durableTimelineStore = new DbAgentTimelineStore(database.db);
|
||||||
let agentManager: AgentManager | null = null;
|
let agentManager: AgentManager | null = null;
|
||||||
const terminalManager = createTerminalManager({
|
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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
agentManager = new AgentManager({
|
agentManager = new AgentManager({
|
||||||
clients: {
|
clients: {
|
||||||
...createAllClients(logger, {
|
...createAllClients(logger, {
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: false,
|
supportsReasoningStream: false,
|
||||||
supportsToolInvocations: false,
|
supportsToolInvocations: false,
|
||||||
supportsTerminalMode: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ScriptedAgentBehavior {
|
interface ScriptedAgentBehavior {
|
||||||
|
|||||||
@@ -785,7 +785,6 @@ export class LoopService {
|
|||||||
model: loop.workerModel ?? loop.model ?? undefined,
|
model: loop.workerModel ?? loop.model ?? undefined,
|
||||||
title: buildWorkerTitle(loop, iteration.index),
|
title: buildWorkerTitle(loop, iteration.index),
|
||||||
internal: true,
|
internal: true,
|
||||||
terminal: false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -796,7 +795,6 @@ export class LoopService {
|
|||||||
model: loop.verifierModel ?? loop.model ?? undefined,
|
model: loop.verifierModel ?? loop.model ?? undefined,
|
||||||
title: buildVerifierTitle(loop, iteration.index),
|
title: buildVerifierTitle(loop, iteration.index),
|
||||||
internal: true,
|
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({
|
const record = createRecord({
|
||||||
provider: "gemini",
|
provider: "gemini",
|
||||||
persistence: {
|
persistence: {
|
||||||
provider: "gemini",
|
provider: "gemini",
|
||||||
sessionId: "session-123",
|
sessionId: "session-123",
|
||||||
},
|
},
|
||||||
config: {
|
config: {},
|
||||||
terminal: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(buildSessionConfig(record)).toMatchObject({
|
expect(buildSessionConfig(record)).toMatchObject({
|
||||||
provider: "gemini",
|
provider: "gemini",
|
||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
terminal: true,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { isValidAgentProvider } from "./agent/provider-manifest.js";
|
|||||||
export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSessionConfig> {
|
export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSessionConfig> {
|
||||||
return {
|
return {
|
||||||
cwd: record.cwd,
|
cwd: record.cwd,
|
||||||
terminal: record.config?.terminal ?? undefined,
|
|
||||||
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
|
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
|
||||||
model: record.config?.model ?? undefined,
|
model: record.config?.model ?? undefined,
|
||||||
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
|
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
|
||||||
@@ -26,7 +25,6 @@ export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfi
|
|||||||
return {
|
return {
|
||||||
provider: record.provider,
|
provider: record.provider,
|
||||||
cwd: record.cwd,
|
cwd: record.cwd,
|
||||||
terminal: overrides.terminal,
|
|
||||||
modeId: overrides.modeId,
|
modeId: overrides.modeId,
|
||||||
model: overrides.model,
|
model: overrides.model,
|
||||||
thinkingOptionId: overrides.thinkingOptionId,
|
thinkingOptionId: overrides.thinkingOptionId,
|
||||||
|
|||||||
@@ -368,9 +368,6 @@ export class ScheduleService {
|
|||||||
private async executeSchedule(schedule: StoredSchedule): Promise<ScheduleExecutionResult> {
|
private async executeSchedule(schedule: StoredSchedule): Promise<ScheduleExecutionResult> {
|
||||||
if (schedule.target.type === "agent") {
|
if (schedule.target.type === "agent") {
|
||||||
const agent = await this.ensureAgentLoaded(schedule.target.agentId);
|
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)) {
|
if (this.agentManager.hasInFlightRun(agent.id)) {
|
||||||
throw new Error(`Agent ${agent.id} already has an active run`);
|
throw new Error(`Agent ${agent.id} already has an active run`);
|
||||||
}
|
}
|
||||||
@@ -401,7 +398,6 @@ export class ScheduleService {
|
|||||||
extra: schedule.target.config.extra,
|
extra: schedule.target.config.extra,
|
||||||
systemPrompt: schedule.target.config.systemPrompt,
|
systemPrompt: schedule.target.config.systemPrompt,
|
||||||
mcpServers: schedule.target.config.mcpServers as AgentSessionConfig["mcpServers"],
|
mcpServers: schedule.target.config.mcpServers as AgentSessionConfig["mcpServers"],
|
||||||
terminal: false,
|
|
||||||
};
|
};
|
||||||
const labels = {
|
const labels = {
|
||||||
"paseo.schedule-id": schedule.id,
|
"paseo.schedule-id": schedule.id,
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ function createSessionForOwnershipTests(options?: {
|
|||||||
hydrateTimelineFromProvider: vi.fn(async () => {
|
hydrateTimelineFromProvider: vi.fn(async () => {
|
||||||
throw new Error("Session should not call hydrateTimelineFromProvider directly");
|
throw new Error("Session should not call hydrateTimelineFromProvider directly");
|
||||||
}),
|
}),
|
||||||
getStructuredSendRejection: vi.fn(async () => null),
|
|
||||||
fetchTimeline: vi.fn(async () => ({
|
fetchTimeline: vi.fn(async () => ({
|
||||||
rows: options?.timelineRows ?? [],
|
rows: options?.timelineRows ?? [],
|
||||||
hasOlder: false,
|
hasOlder: false,
|
||||||
|
|||||||
@@ -57,8 +57,6 @@ import {
|
|||||||
type VoiceTurnController,
|
type VoiceTurnController,
|
||||||
} from "./voice/voice-turn-controller.js";
|
} from "./voice/voice-turn-controller.js";
|
||||||
import {
|
import {
|
||||||
buildSessionConfig,
|
|
||||||
extractTimestamps,
|
|
||||||
toAgentPersistenceHandle,
|
toAgentPersistenceHandle,
|
||||||
} from "./persistence-hooks.js";
|
} from "./persistence-hooks.js";
|
||||||
import { experimental_createMCPClient } from "ai";
|
import { experimental_createMCPClient } from "ai";
|
||||||
@@ -1005,7 +1003,6 @@ export class Session {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: false,
|
supportsReasoningStream: false,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: false,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const createdAt = new Date(record.createdAt);
|
const createdAt = new Date(record.createdAt);
|
||||||
@@ -1033,7 +1030,6 @@ export class Session {
|
|||||||
id: record.id,
|
id: record.id,
|
||||||
provider,
|
provider,
|
||||||
cwd: record.cwd,
|
cwd: record.cwd,
|
||||||
terminal: record.config?.terminal ?? false,
|
|
||||||
model: record.config?.model ?? null,
|
model: record.config?.model ?? null,
|
||||||
thinkingOptionId: record.config?.thinkingOptionId ?? null,
|
thinkingOptionId: record.config?.thinkingOptionId ?? null,
|
||||||
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
|
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
|
||||||
@@ -1052,7 +1048,6 @@ export class Session {
|
|||||||
persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence),
|
persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence),
|
||||||
lastUsage: undefined,
|
lastUsage: undefined,
|
||||||
lastError: record.lastError ?? undefined,
|
lastError: record.lastError ?? undefined,
|
||||||
terminalExit: record.terminalExit,
|
|
||||||
title: record.title ?? record.config?.title ?? null,
|
title: record.title ?? record.config?.title ?? null,
|
||||||
requiresAttention: record.requiresAttention ?? false,
|
requiresAttention: record.requiresAttention ?? false,
|
||||||
attentionReason: record.attentionReason ?? null,
|
attentionReason: record.attentionReason ?? null,
|
||||||
@@ -1091,10 +1086,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initPromise = (async () => {
|
const initPromise = (async () => {
|
||||||
const record = await this.requireStoredAgentRecord(agentId);
|
await this.requireStoredAgentRecord(agentId);
|
||||||
if (record.config?.terminal) {
|
|
||||||
return this.ensureTerminalAgentLoaded(agentId, record);
|
|
||||||
}
|
|
||||||
return this.agentLoadingService.ensureAgentLoaded({ agentId });
|
return this.agentLoadingService.ensureAgentLoaded({ agentId });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -1118,39 +1110,6 @@ export class Session {
|
|||||||
return record;
|
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: {
|
private matchesAgentFilter(options: {
|
||||||
agent: AgentSnapshotPayload;
|
agent: AgentSnapshotPayload;
|
||||||
project: ProjectPlacementPayload;
|
project: ProjectPlacementPayload;
|
||||||
@@ -2618,9 +2577,6 @@ export class Session {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
await this.forwardAgentUpdate(snapshot);
|
await this.forwardAgentUpdate(snapshot);
|
||||||
if (sessionConfig.terminal) {
|
|
||||||
void this.emitInitialTerminalsChangedSnapshot(resolvedWorkspace.directory);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
const agentPayload = await this.getAgentPayloadById(snapshot.id);
|
const agentPayload = await this.getAgentPayloadById(snapshot.id);
|
||||||
@@ -2649,29 +2605,27 @@ export class Session {
|
|||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!sessionConfig.terminal) {
|
void this.handleSendAgentMessage(
|
||||||
void this.handleSendAgentMessage(
|
snapshot.id,
|
||||||
snapshot.id,
|
trimmedPrompt,
|
||||||
trimmedPrompt,
|
resolveClientMessageId(clientMessageId),
|
||||||
resolveClientMessageId(clientMessageId),
|
images,
|
||||||
images,
|
outputSchema ? { outputSchema } : undefined,
|
||||||
outputSchema ? { outputSchema } : undefined,
|
).catch((promptError) => {
|
||||||
).catch((promptError) => {
|
this.sessionLogger.error(
|
||||||
this.sessionLogger.error(
|
{ err: promptError, agentId: snapshot.id },
|
||||||
{ err: promptError, agentId: snapshot.id },
|
`Failed to run initial prompt for agent ${snapshot.id}`,
|
||||||
`Failed to run initial prompt for agent ${snapshot.id}`,
|
);
|
||||||
);
|
this.emit({
|
||||||
this.emit({
|
type: "activity_log",
|
||||||
type: "activity_log",
|
payload: {
|
||||||
payload: {
|
id: uuidv4(),
|
||||||
id: uuidv4(),
|
timestamp: new Date(),
|
||||||
timestamp: new Date(),
|
type: "error",
|
||||||
type: "error",
|
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
|
||||||
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
|
},
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (worktreeBootstrap) {
|
if (worktreeBootstrap) {
|
||||||
@@ -5694,13 +5648,6 @@ export class Session {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
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 snapshot = await this.ensureAgentLoaded(msg.agentId);
|
||||||
const agentPayload = await this.buildAgentPayload(snapshot);
|
const agentPayload = await this.buildAgentPayload(snapshot);
|
||||||
const timeline = await this.agentManager.fetchTimeline(msg.agentId, {
|
const timeline = await this.agentManager.fetchTimeline(msg.agentId, {
|
||||||
@@ -5773,22 +5720,6 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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;
|
const agentId = resolved.agentId;
|
||||||
await this.unarchiveAgentState(agentId);
|
await this.unarchiveAgentState(agentId);
|
||||||
|
|
||||||
@@ -7221,7 +7152,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private filterStandaloneTerminals<T extends { id: string }>(terminals: T[]): T[] {
|
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">): {
|
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[]> {
|
private async getAllTerminalSessions(): Promise<TerminalSession[]> {
|
||||||
if (!this.terminalManager) {
|
if (!this.terminalManager) {
|
||||||
return [];
|
return [];
|
||||||
@@ -7395,18 +7286,11 @@ export class Session {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (msg.agentId) {
|
if (msg.agentId) {
|
||||||
const terminal = await this.createOrResumeAgentTerminal(msg.agentId);
|
|
||||||
this.ensureTerminalExitSubscription(terminal);
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "create_terminal_response",
|
type: "create_terminal_response",
|
||||||
payload: {
|
payload: {
|
||||||
terminal: {
|
terminal: null,
|
||||||
id: terminal.id,
|
error: `Agent-backed terminals are no longer supported for agent ${msg.agentId}`,
|
||||||
name: terminal.name,
|
|
||||||
cwd: terminal.cwd,
|
|
||||||
...(terminal.getTitle() ? { title: terminal.getTitle() } : {}),
|
|
||||||
},
|
|
||||||
error: null,
|
|
||||||
requestId: msg.requestId,
|
requestId: msg.requestId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
createPersistedProjectRecord,
|
createPersistedProjectRecord,
|
||||||
createPersistedWorkspaceRecord,
|
createPersistedWorkspaceRecord,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
|
||||||
|
|
||||||
function makeAgent(input: {
|
function makeAgent(input: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,7 +38,6 @@ function makeAgent(input: {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: false,
|
|
||||||
},
|
},
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
@@ -222,50 +220,6 @@ function seedWorkspace(options: {
|
|||||||
return record;
|
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?: {
|
function createTempGitRepo(options?: {
|
||||||
remoteUrl?: string;
|
remoteUrl?: string;
|
||||||
branchName?: string;
|
branchName?: string;
|
||||||
@@ -635,89 +589,6 @@ describe("workspace aggregation", () => {
|
|||||||
expect(sessionLogger.warn).toHaveBeenCalled();
|
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 () => {
|
test("uses persisted workspace names and stable status aggregation", async () => {
|
||||||
const { session, projects, workspaces } = createSessionForWorkspaceTests();
|
const { session, projects, workspaces } = createSessionForWorkspaceTests();
|
||||||
seedProject({
|
seedProject({
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
supportsTerminalMode: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Deferred<T> = {
|
type Deferred<T> = {
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
|
|||||||
supportsMcpServers: z.boolean(),
|
supportsMcpServers: z.boolean(),
|
||||||
supportsReasoningStream: z.boolean(),
|
supportsReasoningStream: z.boolean(),
|
||||||
supportsToolInvocations: z.boolean(),
|
supportsToolInvocations: z.boolean(),
|
||||||
supportsTerminalMode: z.boolean(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
|
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
|
||||||
@@ -133,7 +132,6 @@ const McpServerConfigSchema = z.discriminatedUnion("type", [
|
|||||||
const AgentSessionConfigSchema = z.object({
|
const AgentSessionConfigSchema = z.object({
|
||||||
provider: AgentProviderSchema,
|
provider: AgentProviderSchema,
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
terminal: z.boolean().optional(),
|
|
||||||
modeId: z.string().optional(),
|
modeId: z.string().optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
thinkingOptionId: z.string().optional(),
|
thinkingOptionId: z.string().optional(),
|
||||||
@@ -458,19 +456,10 @@ const AgentRuntimeInfoSchema: z.ZodType<AgentRuntimeInfo> = z.object({
|
|||||||
extra: z.record(z.unknown()).optional(),
|
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({
|
export const AgentSnapshotPayloadSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
provider: AgentProviderSchema,
|
provider: AgentProviderSchema,
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
terminal: z.boolean().optional(),
|
|
||||||
model: z.string().nullable(),
|
model: z.string().nullable(),
|
||||||
thinkingOptionId: z.string().nullable().optional(),
|
thinkingOptionId: z.string().nullable().optional(),
|
||||||
effectiveThinkingOptionId: z.string().nullable().optional(),
|
effectiveThinkingOptionId: z.string().nullable().optional(),
|
||||||
@@ -486,7 +475,6 @@ export const AgentSnapshotPayloadSchema = z.object({
|
|||||||
runtimeInfo: AgentRuntimeInfoSchema.optional(),
|
runtimeInfo: AgentRuntimeInfoSchema.optional(),
|
||||||
lastUsage: AgentUsageSchema.optional(),
|
lastUsage: AgentUsageSchema.optional(),
|
||||||
lastError: z.string().optional(),
|
lastError: z.string().optional(),
|
||||||
terminalExit: TerminalExitDetailsSchema.optional(),
|
|
||||||
title: z.string().nullable(),
|
title: z.string().nullable(),
|
||||||
labels: z.record(z.string()).default({}),
|
labels: z.record(z.string()).default({}),
|
||||||
requiresAttention: z.boolean().optional(),
|
requiresAttention: z.boolean().optional(),
|
||||||
|
|||||||
@@ -359,69 +359,6 @@ describe("TerminalManager", () => {
|
|||||||
10000,
|
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 () => {
|
it("emits empty snapshot when last terminal is removed", async () => {
|
||||||
manager = createTerminalManager();
|
manager = createTerminalManager();
|
||||||
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
|
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
|
||||||
|
|||||||
@@ -33,12 +33,7 @@ export interface TerminalManager {
|
|||||||
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
|
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentBoundTerminalTitleHandler = (input: { agentId: string; title: string }) => Promise<void> | void;
|
export function createTerminalManager(): TerminalManager {
|
||||||
|
|
||||||
export function createTerminalManager(options?: {
|
|
||||||
resolveAgentIdForTerminal?: (terminalId: string) => string | null;
|
|
||||||
onAgentBoundTerminalTitleChange?: AgentBoundTerminalTitleHandler;
|
|
||||||
}): TerminalManager {
|
|
||||||
const terminalsByCwd = new Map<string, TerminalSession[]>();
|
const terminalsByCwd = new Map<string, TerminalSession[]>();
|
||||||
const terminalsById = new Map<string, TerminalSession>();
|
const terminalsById = new Map<string, TerminalSession>();
|
||||||
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||||
@@ -111,24 +106,8 @@ export function createTerminalManager(options?: {
|
|||||||
const unsubscribeExit = session.onExit(() => {
|
const unsubscribeExit = session.onExit(() => {
|
||||||
removeSessionById(session.id, { kill: false });
|
removeSessionById(session.id, { kill: false });
|
||||||
});
|
});
|
||||||
const unsubscribeTitle = session.onTitleChange((title) => {
|
const unsubscribeTitle = session.onTitleChange(() => {
|
||||||
emitTerminalsChanged({ cwd: session.cwd });
|
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);
|
terminalExitUnsubscribeById.set(session.id, unsubscribeExit);
|
||||||
terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle);
|
terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle);
|
||||||
|
|||||||
Reference in New Issue
Block a user