Files
paseo/docs/TERMINAL-MODE.md
2026-03-29 23:20:18 +07:00

16 KiB

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

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

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:

terminal: z.boolean().optional(),

Add to the AgentStateSchema (the wire format sent to clients):

terminal: z.boolean().optional(),

1.4 Add terminal command builders to AgentClient

File: packages/server/src/server/agent/agent-sdk-types.ts

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

export type AgentCapabilityFlags = {
  // ...existing flags...
  supportsTerminalMode: boolean;  // NEW
};

Also add to the Zod schema in messages.ts:

supportsTerminalMode: z.boolean(),

1.6 Implement terminal command builders in providers

Claude (packages/server/src/server/agent/providers/claude-agent.ts):

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):

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):

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)
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

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

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():

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:

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:

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

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.tsxuseAgentPanelDescriptor

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

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)

 {
   provider: string;
   cwd: string;
   model?: string;
   modeId?: string;
+  terminal?: boolean;
   ...
 }

AgentState (server → client)

 {
   id: string;
   provider: string;
   lifecycle: string;
+  terminal?: boolean;
   ...
 }

AgentCapabilityFlags

 {
   supportsStreaming: boolean;
   supportsSessionPersistence: boolean;
+  supportsTerminalMode: boolean;
   ...
 }

CreateTerminalRequest

 {
   type: "create_terminal_request";
   cwd: string;
   name?: string;
+  agentId?: string;
   requestId: string;
 }

TerminalCommand (new type)

{
  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)