refactor: remove terminal agent concept entirely

Remove the "agent can be a terminal" branching from the entire codebase.
An agent is now always a session-backed chat agent. Standalone terminal
infrastructure (terminal component, ANSI handling, terminal-stream-protocol)
is preserved.

Server: delete ManagedTerminalAgent, AgentKind, TerminalExitDetails,
launchTerminalAgent, registerTerminalAgent, handleTerminalAgentExited,
supportsTerminalMode capability, buildTerminalCreate/ResumeCommand from
all providers, terminal agent persistence/projections.

App: delete terminal-agent-panel.tsx, terminal-agent-reopen-store.ts,
terminal/terminalExit fields on agent state, "Terminal Agents" launcher
section, terminal-agent workspace setup flow, terminal badge in agent list.

CLI: remove terminal column from ls, terminal-agent error from send.

60 files changed, -3592 lines
This commit is contained in:
Mohamed Boudra
2026-04-02 10:55:54 +07:00
parent 59cb4d7499
commit 060f3d457c
60 changed files with 60 additions and 3592 deletions
@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
@@ -9,11 +9,8 @@ import { DbAgentSnapshotStore } from "../db/db-agent-snapshot-store.js";
import { DbAgentTimelineStore } from "../db/db-agent-timeline-store.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "../db/sqlite-database.js";
import { projects, workspaces } from "../db/schema.js";
import { AgentManager, type AgentManagerEvent } from "./agent-manager.js";
import { AgentManager } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { createTerminalManager } from "../../terminal/terminal-manager.js";
import type { TerminalExitInfo, TerminalSession } from "../../terminal/terminal.js";
import type {
AgentClient,
AgentLaunchContext,
@@ -41,47 +38,6 @@ function deferred<T>(): Deferred<T> {
return { promise, resolve, reject };
}
class EventPushable<T> implements AsyncIterable<T> {
private queue: T[] = [];
private resolvers: Array<(value: IteratorResult<T, void>) => void> = [];
private closed = false;
push(value: T): void {
if (this.closed) {
return;
}
const resolver = this.resolvers.shift();
if (resolver) {
resolver({ value, done: false });
return;
}
this.queue.push(value);
}
end(): void {
this.closed = true;
while (this.resolvers.length > 0) {
const resolver = this.resolvers.shift();
resolver?.({ value: undefined, done: true });
}
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: () => {
if (this.queue.length > 0) {
const value = this.queue.shift()!;
return Promise.resolve({ value, done: false });
}
if (this.closed) {
return Promise.resolve({ value: undefined, done: true });
}
return new Promise((resolve) => this.resolvers.push(resolve));
},
};
}
}
const TEST_CAPABILITIES = {
supportsStreaming: false,
supportsSessionPersistence: false,
@@ -89,12 +45,6 @@ const TEST_CAPABILITIES = {
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsTerminalMode: false,
} as const;
const TERMINAL_TEST_CAPABILITIES = {
...TEST_CAPABILITIES,
supportsTerminalMode: true,
} as const;
async function seedWorkspace(
@@ -354,596 +304,9 @@ class StreamingAssistantClient implements AgentClient {
}
}
class TerminalTestAgentClient extends TestAgentClient {
override readonly capabilities = TERMINAL_TEST_CAPABILITIES;
public lastTerminalCreateHandle: AgentPersistenceHandle | null = null;
public lastTerminalInitialPrompt: string | undefined;
override buildTerminalCreateCommand(
_config: AgentSessionConfig,
handle: AgentPersistenceHandle,
initialPrompt?: string,
) {
this.lastTerminalCreateHandle = handle;
this.lastTerminalInitialPrompt = initialPrompt;
return {
command: "terminal-test-cli",
args: ["--session-id", handle.sessionId],
env: { TEST_SESSION_ID: handle.sessionId },
};
}
override buildTerminalResumeCommand(handle: AgentPersistenceHandle) {
return {
command: "terminal-test-cli",
args: ["resume", handle.nativeHandle ?? handle.sessionId],
};
}
}
function createStubTerminalManager(): TerminalManager {
const terminals = new Map<
string,
TerminalSession & {
emitExit: (info?: TerminalExitInfo) => void;
emitTitleChange: (title?: string) => void;
}
>();
return {
async getTerminals() {
return Array.from(terminals.values());
},
async createTerminal(options) {
const id = options.id ?? `term-${terminals.size + 1}`;
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
const titleListeners = new Set<(title?: string) => void>();
let title: string | undefined;
let exitInfo: TerminalExitInfo | null = null;
const session: TerminalSession & {
emitExit: (info?: TerminalExitInfo) => void;
emitTitleChange: (title?: string) => void;
} = {
id,
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {},
subscribe: () => () => {},
onExit(listener) {
exitListeners.add(listener);
return () => {
exitListeners.delete(listener);
};
},
onTitleChange(listener) {
titleListeners.add(listener);
return () => {
titleListeners.delete(listener);
};
},
getSize: () => ({ rows: 24, cols: 80 }),
getState: () => ({ rows: 24, cols: 80, cursor: { row: 0, col: 0 }, scrollback: [], grid: [] }),
getTitle() {
return title;
},
getExitInfo() {
return exitInfo;
},
kill() {
for (const listener of Array.from(exitListeners)) {
listener(exitInfo ?? { exitCode: null, signal: null, lastOutputLines: [] });
}
},
emitExit(info = { exitCode: null, signal: null, lastOutputLines: [] }) {
exitInfo = info;
for (const listener of Array.from(exitListeners)) {
listener(info);
}
},
emitTitleChange(nextTitle) {
title = nextTitle;
for (const listener of Array.from(titleListeners)) {
listener(nextTitle);
}
},
};
terminals.set(id, session);
return session;
},
registerCwdEnv() {},
getTerminal(id) {
return terminals.get(id);
},
killTerminal(id) {
terminals.get(id)?.kill();
terminals.delete(id);
},
listDirectories() {
return [];
},
killAll() {
terminals.clear();
},
subscribeTerminalsChanged() {
return () => {};
},
};
}
describe("AgentManager", () => {
const logger = createTestLogger();
test("terminal agents persist a deterministic handle and expose terminal kind after unload", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new TerminalTestAgentClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
terminalManager: createStubTerminalManager(),
logger,
idFactory: () => "00000000-0000-4000-8000-0000000073e1",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
terminal: true,
});
expect(snapshot.terminal).toBe(true);
expect(snapshot.persistence).toMatchObject({
provider: "codex",
sessionId: "00000000-0000-4000-8000-0000000073e1",
nativeHandle: "00000000-0000-4000-8000-0000000073e1",
});
expect(client.lastTerminalCreateHandle?.sessionId).toBe("00000000-0000-4000-8000-0000000073e1");
expect(client.lastTerminalInitialPrompt).toBeUndefined();
expect(await manager.getAgentKind(snapshot.id)).toBe("terminal");
await manager.closeAgent(snapshot.id);
expect(await manager.getAgentKind(snapshot.id)).toBe("terminal");
expect(await manager.getStructuredSendRejection(snapshot.id)).toBe(
"Terminal agents do not support structured send operations",
);
});
test("terminal agents reserve the terminal binding before terminal creation completes", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-binding-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new TerminalTestAgentClient();
let manager: AgentManager;
const terminalManager: TerminalManager = {
async getTerminals() {
return [];
},
async createTerminal(options) {
expect(options.id).toBeTruthy();
expect(manager.isTerminalBoundToAgent(options.id!)).toBe(true);
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
return {
id: options.id!,
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {},
subscribe: () => () => {},
onExit(listener) {
exitListeners.add(listener);
return () => {
exitListeners.delete(listener);
};
},
getSize: () => ({ rows: 24, cols: 80 }),
getState: () => ({ rows: 24, cols: 80, cursor: { row: 0, col: 0 }, scrollback: [], grid: [] }),
getTitle: () => undefined,
getExitInfo: () => null,
onTitleChange: () => () => {},
kill() {
for (const listener of Array.from(exitListeners)) {
listener({ exitCode: null, signal: null, lastOutputLines: [] });
}
},
};
},
registerCwdEnv() {},
getTerminal() {
return undefined;
},
killTerminal() {},
listDirectories() {
return [];
},
killAll() {},
subscribeTerminalsChanged() {
return () => {};
},
};
manager = new AgentManager({
clients: { codex: client },
registry: storage,
terminalManager,
logger,
idFactory: () => "00000000-0000-4000-8000-0000000b01d0",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
terminal: true,
});
expect(snapshot.terminalId).toBeTruthy();
expect(manager.isTerminalBoundToAgent(snapshot.terminalId!)).toBe(true);
});
test("setTitle persists and emits state for live terminal agents", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-title-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TerminalTestAgentClient() },
registry: storage,
terminalManager: createStubTerminalManager(),
logger,
idFactory: () => "00000000-0000-4000-8000-00000000aa11",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
terminal: true,
});
let stateEventCount = 0;
const unsubscribe = manager.subscribe((event) => {
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
stateEventCount += 1;
}
}, { agentId: snapshot.id, replayState: false });
await manager.setTitle(snapshot.id, "Agent Shell");
const stored = await storage.get(snapshot.id);
expect(stored?.title).toBe("Agent Shell");
expect(manager.getAgentIdForTerminal(snapshot.terminalId!)).toBe(snapshot.id);
expect(stateEventCount).toBe(1);
unsubscribe();
});
test("terminal agent creation ignores title propagation before the initial snapshot is persisted", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-title-race-"));
const dataDir = join(workdir, "db");
const database = await openPaseoDatabase(dataDir);
let manager: AgentManager | null = null;
try {
const workspaceId = await seedWorkspace(database, { directory: workdir });
const storage = new DbAgentSnapshotStore(database.db);
const terminalManager: TerminalManager = {
async getTerminals() {
return [];
},
async createTerminal(options) {
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
const titleListeners = new Set<(title?: string) => void>();
const session: TerminalSession = {
id: options.id,
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {},
subscribe: () => () => {},
onExit(listener) {
exitListeners.add(listener);
return () => {
exitListeners.delete(listener);
};
},
onTitleChange(listener) {
titleListeners.add(listener);
return () => {
titleListeners.delete(listener);
};
},
getSize: () => ({ rows: 24, cols: 80 }),
getState: () => ({
rows: 24,
cols: 80,
cursor: { row: 0, col: 0 },
scrollback: [],
grid: [],
}),
getTitle: () => "Agent Shell",
getExitInfo: () => null,
kill() {
for (const listener of Array.from(exitListeners)) {
listener({ exitCode: null, signal: null, lastOutputLines: [] });
}
},
};
const agentId = manager?.getAgentIdForTerminal(options.id) ?? null;
if (agentId) {
await manager?.setTitle(agentId, "Agent Shell");
}
return session;
},
registerCwdEnv() {},
getTerminal() {
return undefined;
},
killTerminal() {},
listDirectories() {
return [];
},
killAll() {},
subscribeTerminalsChanged() {
return () => {};
},
};
manager = new AgentManager({
clients: { codex: new TerminalTestAgentClient() },
registry: storage,
terminalManager,
logger,
idFactory: () => "00000000-0000-4000-8000-00000000aa13",
});
const snapshot = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
terminal: true,
},
undefined,
{ workspaceId },
);
const stored = await storage.get(snapshot.id);
expect(stored?.title).toBe("Agent Shell");
} finally {
await database.close();
rmSync(workdir, { recursive: true, force: true });
}
});
test("terminal agent creation preserves titles propagated during terminal registration", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-registration-title-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const scriptPath = join(workdir, "npm-cli.js");
let manager: AgentManager | null = null;
const terminalManager = createTerminalManager({
resolveAgentIdForTerminal: (terminalId) => manager?.getAgentIdForTerminal(terminalId) ?? null,
onAgentBoundTerminalTitleChange: async ({ agentId, title }) => {
if (!manager) {
return;
}
await manager.setTitle(agentId, title);
},
});
class TitleReplayTerminalAgentClient extends TerminalTestAgentClient {
override buildTerminalCreateCommand(
_config: AgentSessionConfig,
handle: AgentPersistenceHandle,
) {
return {
command: process.execPath,
args: [scriptPath, "--session-id", handle.sessionId],
};
}
}
manager = new AgentManager({
clients: { codex: new TitleReplayTerminalAgentClient() },
registry: storage,
terminalManager,
logger,
idFactory: () => "00000000-0000-4000-8000-00000000aa12",
});
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
terminal: true,
});
const deadline = Date.now() + 2000;
let storedTitle: string | null = null;
while (Date.now() < deadline) {
storedTitle = (await storage.get(snapshot.id))?.title ?? null;
if (storedTitle?.startsWith("npm --session-id ")) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
expect(storedTitle?.startsWith("npm --session-id ")).toBe(true);
terminalManager.killAll();
});
test("getMetricsSnapshot skips agents without in-memory timeline state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-metrics-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TerminalTestAgentClient() },
registry: storage,
terminalManager: createStubTerminalManager(),
logger,
idFactory: () => "00000000-0000-4000-8000-00000000aa14",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
terminal: true,
});
expect(manager.getMetricsSnapshot()).toEqual({
total: 1,
byLifecycle: { idle: 1 },
withActiveForegroundTurn: 0,
timelineStats: {
totalItems: 0,
maxItemsPerAgent: 0,
},
});
expect(snapshot.terminal).toBe(true);
});
test("terminal agent closure preserves exit diagnostics for failed launches", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-exit-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TerminalTestAgentClient() },
registry: storage,
terminalManager: createStubTerminalManager(),
logger,
idFactory: () => "00000000-0000-4000-8000-00000000aa12",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
terminal: true,
});
const terminal = manager.getTerminalSessionForAgent(snapshot.id) as TerminalSession & {
emitExit: (info?: TerminalExitInfo) => void;
};
expect(terminal).toBeTruthy();
let closedEvent: Extract<AgentManagerEvent, { type: "agent_state" }>["agent"] | null = null;
manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
closedEvent = event.agent;
}
},
{ agentId: snapshot.id, replayState: false },
);
terminal.emitExit({
exitCode: 127,
signal: null,
lastOutputLines: ["gemini: command not found"],
});
await vi.waitFor(async () => {
const stored = await storage.get(snapshot.id);
expect(stored?.terminalExit).toEqual({
command: "terminal-test-cli",
message: "gemini: command not found",
exitCode: 127,
signal: null,
outputLines: ["gemini: command not found"],
});
expect(stored?.lastError).toContain("Exit code: 127");
});
expect(closedEvent?.lifecycle).toBe("closed");
expect(closedEvent?.lastError).toContain("gemini: command not found");
expect(closedEvent?.terminalExit).toEqual({
command: "terminal-test-cli",
message: "gemini: command not found",
exitCode: 127,
signal: null,
outputLines: ["gemini: command not found"],
});
});
test("structured send rejection is null for managed agents", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000100",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
expect(await manager.getAgentKind(snapshot.id)).toBe("session");
expect(await manager.getStructuredSendRejection(snapshot.id)).toBeNull();
});
test("createAgent passes initialPrompt into terminal command builders", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-prompt-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new TerminalTestAgentClient();
const terminalManager: TerminalManager = {
async getTerminals() {
return [];
},
async createTerminal(options) {
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
return {
id: options.id ?? "00000000-0000-4000-8000-00000000abcd",
name: options.name ?? "Terminal",
cwd: options.cwd,
send: () => {},
subscribe: () => () => {},
onExit(listener) {
exitListeners.add(listener);
return () => {
exitListeners.delete(listener);
};
},
getSize: () => ({ rows: 24, cols: 80 }),
getState: () => ({ rows: 24, cols: 80, cursor: { row: 0, col: 0 }, scrollback: [], grid: [] }),
getTitle: () => undefined,
getExitInfo: () => null,
onTitleChange: () => () => {},
kill() {
for (const listener of Array.from(exitListeners)) {
listener({ exitCode: null, signal: null, lastOutputLines: [] });
}
},
};
},
registerCwdEnv() {},
getTerminal() {
return undefined;
},
killTerminal() {},
listDirectories() {
return [];
},
killAll() {},
subscribeTerminalsChanged() {
return () => {};
},
};
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
terminalManager,
logger,
idFactory: () => "00000000-0000-4000-8000-00000000abcd",
});
await manager.createAgent(
{
provider: "codex",
cwd: workdir,
terminal: true,
},
undefined,
{ initialPrompt: "Implement terminal prompt routing" },
);
expect(client.lastTerminalInitialPrompt).toBe("Implement terminal prompt routing");
});
test("normalizeConfig does not inject default model when omitted", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { basename, resolve } from "node:path";
import { resolve } from "node:path";
import { stat } from "node:fs/promises";
import {
AGENT_LIFECYCLE_STATUSES,
@@ -8,7 +8,6 @@ import {
import type { Logger } from "pino";
import { z } from "zod";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import type { TerminalExitInfo, TerminalSession } from "../../terminal/terminal.js";
import type {
AgentCapabilityFlags,
@@ -31,7 +30,6 @@ import type {
AgentRuntimeInfo,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
TerminalCommand,
} from "./agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent-storage.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
@@ -114,13 +112,6 @@ export type WaitForAgentStartOptions = {
signal?: AbortSignal;
};
export interface TerminalExitDetails {
command: string;
message: string;
exitCode: number | null;
signal: number | null;
outputLines: string[];
}
type AttentionState =
| { requiresAttention: false }
| {
@@ -149,7 +140,6 @@ type ManagedAgentBase = {
id: string;
provider: AgentProvider;
cwd: string;
terminal: boolean;
capabilities: AgentCapabilityFlags;
config: AgentSessionConfig;
runtimeInfo?: AgentRuntimeInfo;
@@ -165,7 +155,6 @@ type ManagedAgentBase = {
lastUserMessageAt: Date | null;
lastUsage?: AgentUsage;
lastError?: string;
terminalExit?: TerminalExitDetails;
attention: AttentionState;
foregroundTurnWaiters: Set<ForegroundTurnWaiter>;
unsubscribeSession: (() => void) | null;
@@ -181,7 +170,6 @@ type ManagedAgentBase = {
type ManagedAgentWithSession = ManagedAgentBase & {
session: AgentSession;
terminal: false;
};
type ManagedAgentInitializing = ManagedAgentWithSession & {
@@ -211,24 +199,11 @@ type ManagedAgentClosed = ManagedAgentBase & {
activeForegroundTurnId: null;
};
type ManagedTerminalAgent = ManagedAgentBase & {
terminal: true;
lifecycle: "idle";
session: null;
activeForegroundTurnId: null;
terminalCommand: TerminalCommand;
terminalId: string | null;
unsubscribeTerminalExit: (() => void) | null;
};
export type AgentKind = "session" | "terminal";
export type ManagedAgent =
| ManagedAgentInitializing
| ManagedAgentIdle
| ManagedAgentRunning
| ManagedAgentError
| ManagedTerminalAgent
| ManagedAgentClosed;
export interface AgentMetricsSnapshot {
@@ -247,7 +222,7 @@ type ActiveManagedAgent =
| ManagedAgentRunning
| ManagedAgentError;
type LiveManagedAgent = ActiveManagedAgent | ManagedTerminalAgent;
type LiveManagedAgent = ActiveManagedAgent;
const SYSTEM_ERROR_PREFIX = "[System Error]";
@@ -298,73 +273,6 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
return Object.assign(new Error(message), { name: "AbortError" });
}
function formatTerminalExitSummary(input: {
command: string;
exitCode: number | null;
signal: number | null;
outputLines: string[];
}): string {
const commandLabel = basename(input.command) || input.command;
const commandNotFoundLine = input.outputLines.find((line) =>
/command not found|not recognized|no such file or directory/i.test(line),
);
if (input.exitCode === 127) {
return commandNotFoundLine ?? `${commandLabel}: command not found`;
}
if (input.exitCode !== null) {
return `${commandLabel} exited with code ${input.exitCode}.`;
}
if (input.signal !== null) {
return `${commandLabel} exited with signal ${input.signal}.`;
}
return `${commandLabel} exited unexpectedly.`;
}
function buildTerminalExitDetails(input: {
command: string;
exit: TerminalExitInfo;
}): TerminalExitDetails | null {
const outputLines = input.exit.lastOutputLines.map((line) => line.trimEnd());
while (outputLines[0]?.length === 0) {
outputLines.shift();
}
while (outputLines[outputLines.length - 1]?.length === 0) {
outputLines.pop();
}
if (input.exit.exitCode === null && input.exit.signal === null && outputLines.length === 0) {
return null;
}
return {
command: input.command,
message: formatTerminalExitSummary({
command: input.command,
exitCode: input.exit.exitCode,
signal: input.exit.signal,
outputLines,
}),
exitCode: input.exit.exitCode,
signal: input.exit.signal,
outputLines,
};
}
function buildTerminalExitErrorMessage(details: TerminalExitDetails): string {
const lines = [details.message];
if (details.exitCode !== null) {
lines.push(`Exit code: ${details.exitCode}`);
} else if (details.signal !== null) {
lines.push(`Signal: ${details.signal}`);
}
if (details.outputLines.length > 0) {
lines.push("Last output:");
lines.push(...details.outputLines);
}
return lines.join("\n");
}
function validateAgentId(agentId: string, source: string): string {
const result = AgentIdSchema.safeParse(agentId);
if (!result.success) {
@@ -396,14 +304,12 @@ export class AgentManager {
private readonly backgroundTasks = new Set<Promise<void>>();
private onAgentAttention?: AgentAttentionCallback;
private logger: Logger;
private readonly terminalManager: TerminalManager | null;
constructor(options: AgentManagerOptions) {
this.idFactory = options?.idFactory ?? (() => randomUUID());
this.registry = options?.registry;
this.durableTimelineStore = options?.durableTimelineStore;
this.onAgentAttention = options?.onAgentAttention;
this.terminalManager = options?.terminalManager ?? null;
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
if (options?.clients) {
for (const [provider, client] of Object.entries(options.clients)) {
@@ -624,51 +530,6 @@ export class AgentManager {
return agent ? { ...agent } : null;
}
async getAgentKind(id: string): Promise<AgentKind | null> {
const normalizedId = validateAgentId(id, "getAgentKind");
const liveAgent = this.agents.get(normalizedId);
if (liveAgent) {
return liveAgent.terminal ? "terminal" : "session";
}
if (!this.registry) {
return null;
}
const stored = await this.registry.get(normalizedId);
if (!stored) {
return null;
}
return stored.config?.terminal === true ? "terminal" : "session";
}
async getStructuredSendRejection(id: string): Promise<string | null> {
const kind = await this.getAgentKind(id);
return kind === "terminal"
? "Terminal agents do not support structured send operations"
: null;
}
getTerminalSessionForAgent(id: string): TerminalSession | null {
const agent = this.agents.get(id);
if (!agent || !agent.terminal || !("terminalId" in agent) || !agent.terminalId) {
return null;
}
return this.terminalManager?.getTerminal(agent.terminalId) ?? null;
}
getAgentIdForTerminal(terminalId: string): string | null {
for (const agent of this.agents.values()) {
if (!agent.terminal || !("terminalId" in agent) || agent.terminalId !== terminalId) {
continue;
}
return agent.id;
}
return null;
}
isTerminalBoundToAgent(terminalId: string): boolean {
return this.getAgentIdForTerminal(terminalId) !== null;
}
getTimeline(id: string): AgentTimelineItem[] {
this.requireAgent(id);
return this.timelineStore.getItems(id);
@@ -713,34 +574,6 @@ export class AgentManager {
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
);
}
if (normalizedConfig.terminal) {
const buildCommand = client.buildTerminalCreateCommand;
if (!buildCommand) {
throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`);
}
const persistence = this.buildTerminalPersistenceHandle(
resolvedAgentId,
normalizedConfig.provider,
normalizedConfig.cwd,
);
const command = buildCommand.call(
client,
normalizedConfig,
persistence,
options?.initialPrompt,
);
return this.registerTerminalAgent(
resolvedAgentId,
normalizedConfig,
client.capabilities,
command,
persistence,
{
labels: options?.labels,
workspaceId: options?.workspaceId,
},
);
}
const session = await client.createSession(normalizedConfig, launchContext);
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
labels: options?.labels,
@@ -748,91 +581,6 @@ export class AgentManager {
});
}
// Reconstruct an agent from provider persistence. When a durable timeline
// store is configured, the live timeline buffer only seeds seq metadata from
// the durable store instead of loading committed history back into memory.
// Tests without a durable timeline store can still call
// hydrateTimelineFromProvider() for backward compatibility.
async launchTerminalAgent(
config: AgentSessionConfig,
agentId: string,
options?: {
persistence?: AgentPersistenceHandle | null;
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
attention?: {
requiresAttention: boolean;
attentionReason?: "finished" | "error" | "permission" | null;
attentionTimestamp?: Date | null;
};
},
): Promise<ManagedTerminalAgent> {
const resolvedAgentId = validateAgentId(agentId, "launchTerminalAgent");
const normalizedConfig = await this.normalizeConfig(config);
const client = this.requireClient(normalizedConfig.provider);
const available = await client.isAvailable();
if (!available) {
throw new Error(
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const resumeCommand =
options?.persistence && client.buildTerminalResumeCommand
? client.buildTerminalResumeCommand.call(client, options.persistence)
: null;
const createCommand = client.buildTerminalCreateCommand;
const terminalCommand =
resumeCommand ??
(createCommand
? createCommand.call(
client,
normalizedConfig,
options?.persistence ??
this.buildTerminalPersistenceHandle(
resolvedAgentId,
normalizedConfig.provider,
normalizedConfig.cwd,
),
)
: null);
if (!terminalCommand) {
throw new Error(`Provider '${normalizedConfig.provider}' does not support terminal mode`);
}
return this.registerTerminalAgent(
resolvedAgentId,
normalizedConfig,
client.capabilities,
terminalCommand,
options?.persistence ??
this.buildTerminalPersistenceHandle(
resolvedAgentId,
normalizedConfig.provider,
normalizedConfig.cwd,
),
{
createdAt: options?.createdAt,
updatedAt: options?.updatedAt,
lastUserMessageAt: options?.lastUserMessageAt,
labels: options?.labels,
attention:
options?.attention?.requiresAttention &&
options.attention.attentionReason &&
options.attention.attentionTimestamp
? {
requiresAttention: true,
attentionReason: options.attention.attentionReason,
attentionTimestamp: options.attention.attentionTimestamp,
}
: undefined,
},
);
}
// Reconstruct an agent from provider persistence. Callers should explicitly
// hydrate timeline history after resume.
async resumeAgentFromPersistence(
@@ -941,13 +689,7 @@ export class AgentManager {
"closeAgent: start",
);
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
if (agent.terminal) {
if (agent.terminalId) {
this.terminalManager?.killTerminal(agent.terminalId);
}
} else {
await agent.session.close();
}
await agent.session.close();
this.timelineStore.delete(agentId);
this.emitClosedAgent(closedAgent);
this.logger.trace({ agentId }, "closeAgent: completed");
@@ -2050,7 +1792,6 @@ export class AgentManager {
id: resolvedAgentId,
provider: config.provider,
cwd: config.cwd,
terminal: false,
session,
capabilities: session.capabilities,
config,
@@ -2071,7 +1812,6 @@ export class AgentManager {
lastUserMessageAt: options?.lastUserMessageAt ?? null,
lastUsage: options?.lastUsage,
lastError: options?.lastError,
terminalExit: undefined,
attention:
options?.attention != null
? options.attention.requiresAttention
@@ -2118,162 +1858,6 @@ export class AgentManager {
};
}
private async registerTerminalAgent(
agentId: string,
config: AgentSessionConfig,
capabilities: AgentCapabilityFlags,
terminalCommand: TerminalCommand,
persistence: AgentPersistenceHandle,
options?: {
workspaceId?: number;
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
attention?: AttentionState;
},
): Promise<ManagedTerminalAgent> {
if (!this.terminalManager) {
throw new Error("Terminal manager is not configured");
}
const resolvedAgentId = validateAgentId(agentId, "registerTerminalAgent");
if (this.agents.has(resolvedAgentId)) {
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
}
const initialPersistedTitle = await this.resolveInitialPersistedTitle(resolvedAgentId, config);
const now = new Date();
const reservedTerminalId = randomUUID();
const managed: ManagedTerminalAgent = {
id: resolvedAgentId,
provider: config.provider,
cwd: config.cwd,
terminal: true,
session: null,
capabilities,
config,
runtimeInfo: undefined,
lifecycle: "idle",
createdAt: options?.createdAt ?? now,
updatedAt: options?.updatedAt ?? now,
availableModes: [],
currentModeId: config.modeId ?? null,
pendingPermissions: new Map<string, AgentPermissionRequest>(),
pendingReplacement: false,
activeForegroundTurnId: null,
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
unsubscribeSession: null,
provisionalAssistantText: null,
persistence: attachPersistenceCwd(persistence, config.cwd),
historyPrimed: false,
lastUserMessageAt: options?.lastUserMessageAt ?? null,
lastUsage: undefined,
lastError: undefined,
terminalExit: undefined,
attention:
options?.attention != null
? options.attention.requiresAttention
? {
requiresAttention: true,
attentionReason: options.attention.attentionReason,
attentionTimestamp: new Date(options.attention.attentionTimestamp),
}
: { requiresAttention: false }
: { requiresAttention: false },
internal: config.internal ?? false,
labels: options?.labels ?? {},
terminalCommand,
terminalId: reservedTerminalId,
unsubscribeTerminalExit: null,
};
this.agents.set(resolvedAgentId, managed);
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
this.agentsAwaitingInitialSnapshotPersist.add(resolvedAgentId);
let terminalSession: TerminalSession;
try {
terminalSession = await this.terminalManager.createTerminal({
id: reservedTerminalId,
cwd: config.cwd,
name: initialPersistedTitle ?? undefined,
command: terminalCommand.command,
args: terminalCommand.args,
env: terminalCommand.env,
});
} catch (error) {
this.agents.delete(resolvedAgentId);
this.previousStatuses.delete(resolvedAgentId);
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
throw error;
}
if (terminalSession.id !== reservedTerminalId) {
this.agents.delete(resolvedAgentId);
this.previousStatuses.delete(resolvedAgentId);
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
throw new Error(
`Reserved terminal id ${reservedTerminalId} but terminal manager returned ${terminalSession.id}`,
);
}
const unsubscribeTerminalExit = terminalSession.onExit((exit) => {
void this.handleTerminalAgentExited(resolvedAgentId, exit);
});
managed.unsubscribeTerminalExit = unsubscribeTerminalExit;
const terminalSessionTitle = terminalSession.getTitle()?.trim();
try {
await this.persistSnapshot(managed, {
workspaceId: options?.workspaceId,
title:
terminalSessionTitle && terminalSessionTitle.length > 0
? terminalSessionTitle
: initialPersistedTitle,
});
} finally {
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
}
this.emitState(managed);
return { ...managed };
}
private async handleTerminalAgentExited(agentId: string, exit: TerminalExitInfo): Promise<void> {
const agent = this.agents.get(agentId);
if (!agent || !agent.terminal) {
return;
}
const terminalExit = buildTerminalExitDetails({
command: agent.terminalCommand.command,
exit,
});
if (terminalExit) {
agent.terminalExit = terminalExit;
if (terminalExit.exitCode !== null && terminalExit.exitCode !== 0) {
agent.lastError = buildTerminalExitErrorMessage(terminalExit);
} else if (terminalExit.signal !== null) {
agent.lastError = buildTerminalExitErrorMessage(terminalExit);
}
}
const closedAgent = this.prepareAgentForClosure(agent, "agent terminal exited");
await this.persistSnapshot(closedAgent);
this.emitClosedAgent(closedAgent);
}
private buildTerminalPersistenceHandle(
agentId: string,
provider: AgentProvider,
cwd: string,
): AgentPersistenceHandle {
return attachPersistenceCwd(
{
provider,
sessionId: agentId,
nativeHandle: agentId,
},
cwd,
)!;
}
private prepareAgentForClosure(
agent: LiveManagedAgent,
cancelReason: string,
@@ -2284,10 +1868,6 @@ export class AgentManager {
agent.unsubscribeSession();
agent.unsubscribeSession = null;
}
if (agent.terminal && agent.unsubscribeTerminalExit) {
agent.unsubscribeTerminalExit();
agent.unsubscribeTerminalExit = null;
}
for (const waiter of agent.foregroundTurnWaiters) {
waiter.callback({
type: "turn_canceled",
@@ -2330,7 +1910,7 @@ export class AgentManager {
if (!current) {
return;
}
if (current.terminal || current.session == null) {
if (current.session == null) {
return;
}
await this.dispatchSessionEvent(current, event);
@@ -3112,8 +2692,8 @@ export class AgentManager {
private requireSessionAgent(id: string): ActiveManagedAgent {
const agent = this.requireAgent(id);
if (agent.terminal || agent.session === null) {
throw new Error(`Agent '${agent.id}' is a terminal agent and has no managed session`);
if (agent.session === null) {
throw new Error(`Agent '${agent.id}' has no managed session`);
}
return agent;
}
@@ -58,7 +58,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
config: { ...baseConfig, ...configOverrides },
lifecycle,
@@ -63,7 +63,6 @@ export function toStoredAgentRecord(
runtimeInfo,
persistence,
lastError: agent.lastError ?? undefined,
terminalExit: agent.terminalExit ?? undefined,
requiresAttention: agent.attention.requiresAttention,
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
attentionTimestamp: agent.attention.requiresAttention
@@ -88,7 +87,6 @@ export function toAgentPayload(
id: agent.id,
provider: agent.provider,
cwd: agent.cwd,
terminal: agent.terminal,
model: agent.config.model ?? null,
thinkingOptionId,
effectiveThinkingOptionId,
@@ -115,10 +113,6 @@ export function toAgentPayload(
payload.lastError = agent.lastError;
}
if (agent.terminalExit) {
payload.terminalExit = agent.terminalExit;
}
// Handle attention state
payload.requiresAttention = agent.attention.requiresAttention;
if (agent.attention.requiresAttention) {
@@ -134,9 +128,6 @@ export function toAgentPayload(
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
const serializable: SerializableAgentConfig = {};
if (config.terminal !== undefined) {
serializable.terminal = config.terminal;
}
if (Object.prototype.hasOwnProperty.call(config, "title")) {
serializable.title = config.title ?? null;
}
@@ -71,7 +71,6 @@ export type AgentCapabilityFlags = {
supportsMcpServers: boolean;
supportsReasoningStream: boolean;
supportsToolInvocations: boolean;
supportsTerminalMode: boolean;
};
export type AgentPersistenceHandle = {
@@ -358,16 +357,9 @@ export type PersistedAgentDescriptor = {
timeline: AgentTimelineItem[];
};
export type TerminalCommand = {
command: string;
args: string[];
env?: Record<string, string>;
};
export type AgentSessionConfig = {
provider: AgentProvider;
cwd: string;
terminal?: boolean;
/**
* Provider-agnostic system/developer instruction string.
* Mapped by each provider to its native instruction field.
@@ -437,12 +429,6 @@ export interface AgentClient {
): Promise<AgentSession>;
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
buildTerminalCreateCommand?(
config: AgentSessionConfig,
handle: AgentPersistenceHandle,
initialPrompt?: string,
): TerminalCommand;
buildTerminalResumeCommand?(handle: AgentPersistenceHandle): TerminalCommand;
/**
* Check if this provider is available (CLI binary is installed).
* Returns true if available, false otherwise.
@@ -57,7 +57,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
config,
lifecycle,
@@ -12,7 +12,6 @@ import type { AgentSessionConfig } from "./agent-sdk-types.js";
const SERIALIZABLE_CONFIG_SCHEMA = z
.object({
terminal: z.boolean().optional(),
title: z.string().nullable().optional(),
modeId: z.string().nullable().optional(),
model: z.string().nullable().optional(),
@@ -59,15 +58,6 @@ const STORED_AGENT_SCHEMA = z.object({
.optional(),
persistence: PERSISTENCE_HANDLE_SCHEMA,
lastError: z.string().nullable().optional(),
terminalExit: z
.object({
command: z.string(),
message: z.string(),
exitCode: z.number().nullable(),
signal: z.number().nullable(),
outputLines: z.array(z.string()),
})
.optional(),
requiresAttention: z.boolean().optional(),
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: z.string().nullable().optional(),
@@ -77,7 +67,6 @@ const STORED_AGENT_SCHEMA = z.object({
export type SerializableAgentConfig = Pick<
AgentSessionConfig,
| "terminal"
| "title"
| "modeId"
| "model"
@@ -125,21 +125,21 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
{
id: "gemini",
label: "Gemini CLI",
description: "Google's terminal-based coding agent",
description: "Google's coding agent CLI",
defaultModeId: null,
modes: [],
},
{
id: "amp",
label: "AMP",
description: "Sourcegraph's terminal-based coding agent",
description: "Sourcegraph's coding agent CLI",
defaultModeId: null,
modes: [],
},
{
id: "aider",
label: "Aider",
description: "Paul Gauthier's terminal-based coding assistant",
description: "Paul Gauthier's coding assistant CLI",
defaultModeId: null,
modes: [],
},
@@ -9,14 +9,10 @@ import type {
AgentSession,
AgentSessionConfig,
ListModelsOptions,
TerminalCommand,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
findExecutable,
isProviderCommandAvailable,
resolveProviderCommandPrefix,
sanitizeTerminalEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
@@ -29,11 +25,8 @@ const AIDER_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsTerminalMode: true,
};
type AiderAgentConfig = AgentSessionConfig & { provider: "aider" };
function resolveAiderBinary(): string {
const found = findExecutable("aider");
if (found) {
@@ -45,7 +38,7 @@ function resolveAiderBinary(): string {
}
function createUnsupportedSessionError(): Error {
return new Error("Aider currently supports terminal mode only in Paseo.");
return new Error("Aider does not support session-backed agents in Paseo.");
}
export class AiderAgentClient implements AgentClient {
@@ -73,38 +66,10 @@ export class AiderAgentClient implements AgentClient {
return [];
}
buildTerminalCreateCommand(
config: AgentSessionConfig,
_handle: AgentPersistenceHandle,
_initialPrompt?: string,
): TerminalCommand {
this.assertConfig(config);
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveAiderBinary,
);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
// Aider uses positional arguments for file paths, not interactive prompts.
args: [...launchPrefix.args, "--no-auto-commits"],
env: terminalEnv,
};
}
async isAvailable(): Promise<boolean> {
if (this.runtimeSettings?.command?.mode === "replace") {
return existsSync(this.runtimeSettings.command.argv[0]);
}
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAiderBinary);
}
private assertConfig(config: AgentSessionConfig): AiderAgentConfig {
if (config.provider !== AIDER_PROVIDER) {
throw new Error(`AiderAgentClient received config for provider '${config.provider}'`);
}
return { ...config, provider: AIDER_PROVIDER };
}
}
@@ -9,14 +9,10 @@ import type {
AgentSession,
AgentSessionConfig,
ListModelsOptions,
TerminalCommand,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
findExecutable,
isProviderCommandAvailable,
resolveProviderCommandPrefix,
sanitizeTerminalEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
@@ -29,11 +25,8 @@ const AMP_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsTerminalMode: true,
};
type AmpAgentConfig = AgentSessionConfig & { provider: "amp" };
function resolveAmpBinary(): string {
const found = findExecutable("amp");
if (found) {
@@ -45,7 +38,7 @@ function resolveAmpBinary(): string {
}
function createUnsupportedSessionError(): Error {
return new Error("AMP currently supports terminal mode only in Paseo.");
return new Error("AMP does not support session-backed agents in Paseo.");
}
export class AmpAgentClient implements AgentClient {
@@ -73,37 +66,10 @@ export class AmpAgentClient implements AgentClient {
return [];
}
buildTerminalCreateCommand(
config: AgentSessionConfig,
_handle: AgentPersistenceHandle,
_initialPrompt?: string,
): TerminalCommand {
this.assertConfig(config);
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveAmpBinary,
);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args: [...launchPrefix.args],
env: terminalEnv,
};
}
async isAvailable(): Promise<boolean> {
if (this.runtimeSettings?.command?.mode === "replace") {
return existsSync(this.runtimeSettings.command.argv[0]);
}
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAmpBinary);
}
private assertConfig(config: AgentSessionConfig): AmpAgentConfig {
if (config.provider !== AMP_PROVIDER) {
throw new Error(`AmpAgentClient received config for provider '${config.provider}'`);
}
return { ...config, provider: AMP_PROVIDER };
}
}
@@ -66,12 +66,10 @@ import type {
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
TerminalCommand,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
findExecutable,
sanitizeTerminalEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
@@ -104,7 +102,6 @@ const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: true,
};
const DEFAULT_MODES: AgentMode[] = [
@@ -1099,73 +1096,6 @@ export class ClaudeAgentClient implements AgentClient {
return descriptors;
}
buildTerminalCreateCommand(
config: AgentSessionConfig,
handle: AgentPersistenceHandle,
initialPrompt?: string,
): TerminalCommand {
const claudeConfig = this.assertConfig(config);
const baseCommand = findExecutable("claude") ?? "claude";
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
const spawnCommand = resolveClaudeSpawnCommand(
{
command: baseCommand,
args: [],
cwd: claudeConfig.cwd,
env: terminalEnv,
signal: new AbortController().signal,
},
this.runtimeSettings,
);
const args = [...spawnCommand.args, "--session-id", handle.sessionId];
if (claudeConfig.modeId === "bypassPermissions") {
args.push("--dangerously-skip-permissions");
} else if (claudeConfig.modeId) {
args.push("--permission-mode", claudeConfig.modeId);
}
if (claudeConfig.model) {
args.push("--model", claudeConfig.model);
}
if (claudeConfig.thinkingOptionId && claudeConfig.thinkingOptionId !== "default") {
args.push("--effort", claudeConfig.thinkingOptionId);
}
if (claudeConfig.systemPrompt?.trim()) {
args.push("--append-system-prompt", claudeConfig.systemPrompt.trim());
}
if (initialPrompt?.trim()) {
args.push(initialPrompt.trim());
}
return {
command: spawnCommand.command,
args,
env: terminalEnv,
};
}
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
const baseCommand = findExecutable("claude") ?? "claude";
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
const spawnCommand = resolveClaudeSpawnCommand(
{
command: baseCommand,
args: [],
cwd: process.cwd(),
env: terminalEnv,
signal: new AbortController().signal,
},
this.runtimeSettings,
);
return {
command: spawnCommand.command,
args: [...spawnCommand.args, "--resume", handle.sessionId],
env: terminalEnv,
};
}
async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command;
if (command?.mode === "replace") {
@@ -19,11 +19,9 @@ import type {
AgentTimelineItem,
ToolCallTimelineItem,
AgentUsage,
AgentPersistenceHandle,
ListModelsOptions,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
TerminalCommand,
} from "../agent-sdk-types.js";
import type { Logger } from "pino";
@@ -45,7 +43,6 @@ import {
applyProviderEnv,
findExecutable,
resolveProviderCommandPrefix,
sanitizeTerminalEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
@@ -62,7 +59,6 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: true,
};
const CODEX_MODES: AgentMode[] = [
@@ -3536,59 +3532,6 @@ export class CodexAppServerAgentClient implements AgentClient {
}
}
buildTerminalCreateCommand(
config: AgentSessionConfig,
handle: AgentPersistenceHandle,
initialPrompt?: string,
): TerminalCommand {
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings);
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
const modeId = sessionConfig.modeId ?? DEFAULT_CODEX_MODE_ID;
validateCodexMode(modeId);
const preset = MODE_PRESETS[modeId] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
const approvalPolicy = sessionConfig.approvalPolicy ?? preset.approvalPolicy;
const sandbox = sessionConfig.sandboxMode ?? preset.sandbox;
const args = [...launchPrefix.args, "-c", `sessionId=\"${handle.sessionId}\"`];
if (sessionConfig.model) {
args.push("--model", sessionConfig.model);
}
args.push("--ask-for-approval", approvalPolicy, "--sandbox", sandbox);
if (
typeof sessionConfig.networkAccess === "boolean"
? sessionConfig.networkAccess
: preset.networkAccess === true
) {
args.push("--search");
}
if (initialPrompt?.trim()) {
args.push(initialPrompt.trim());
}
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args,
env: terminalEnv,
};
}
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args: [
...launchPrefix.args,
"resume",
handle.nativeHandle ?? handle.sessionId,
],
env: terminalEnv,
};
}
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
const child = this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger);
@@ -9,14 +9,10 @@ import type {
AgentSession,
AgentSessionConfig,
ListModelsOptions,
TerminalCommand,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
findExecutable,
isProviderCommandAvailable,
resolveProviderCommandPrefix,
sanitizeTerminalEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
@@ -29,11 +25,8 @@ const GEMINI_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsTerminalMode: true,
};
type GeminiAgentConfig = AgentSessionConfig & { provider: "gemini" };
function resolveGeminiBinary(): string {
const found = findExecutable("gemini");
if (found) {
@@ -45,7 +38,7 @@ function resolveGeminiBinary(): string {
}
function createUnsupportedSessionError(): Error {
return new Error("Gemini CLI currently supports terminal mode only in Paseo.");
return new Error("Gemini CLI does not support session-backed agents in Paseo.");
}
export class GeminiAgentClient implements AgentClient {
@@ -73,56 +66,10 @@ export class GeminiAgentClient implements AgentClient {
return [];
}
buildTerminalCreateCommand(
config: AgentSessionConfig,
_handle: AgentPersistenceHandle,
initialPrompt?: string,
): TerminalCommand {
this.assertConfig(config);
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveGeminiBinary,
);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
const args = [...launchPrefix.args];
if (initialPrompt?.trim()) {
args.push("-i", initialPrompt.trim());
}
return {
command: launchPrefix.command,
args,
env: terminalEnv,
};
}
buildTerminalResumeCommand(_handle: AgentPersistenceHandle): TerminalCommand {
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveGeminiBinary,
);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args: [...launchPrefix.args, "--resume"],
env: terminalEnv,
};
}
async isAvailable(): Promise<boolean> {
if (this.runtimeSettings?.command?.mode === "replace") {
return existsSync(this.runtimeSettings.command.argv[0]);
}
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveGeminiBinary);
}
private assertConfig(config: AgentSessionConfig): GeminiAgentConfig {
if (config.provider !== GEMINI_PROVIDER) {
throw new Error(`GeminiAgentClient received config for provider '${config.provider}'`);
}
return { ...config, provider: GEMINI_PROVIDER };
}
}
@@ -28,13 +28,11 @@ import type {
ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor,
TerminalCommand,
} from "../agent-sdk-types.js";
import {
applyProviderEnv,
findExecutable,
resolveProviderCommandPrefix,
sanitizeTerminalEnv,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
@@ -46,7 +44,6 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: true,
};
const DEFAULT_MODES: AgentMode[] = [
@@ -562,53 +559,6 @@ export class OpenCodeAgentClient implements AgentClient {
return [];
}
buildTerminalCreateCommand(
config: AgentSessionConfig,
handle: AgentPersistenceHandle,
initialPrompt?: string,
): TerminalCommand {
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveOpenCodeBinary,
);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
const args = [...launchPrefix.args, "--session", handle.nativeHandle ?? handle.sessionId];
if (config.cwd) {
args.push(config.cwd);
}
if (config.model) {
args.push("--model", config.model);
}
if (config.modeId) {
args.push("--agent", config.modeId);
}
if (initialPrompt?.trim()) {
args.push(initialPrompt.trim());
}
return {
command: launchPrefix.command,
args,
env: terminalEnv,
};
}
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
const launchPrefix = resolveProviderCommandPrefix(
this.runtimeSettings?.command,
resolveOpenCodeBinary,
);
const terminalEnv = sanitizeTerminalEnv(
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
);
return {
command: launchPrefix.command,
args: [...launchPrefix.args, "--session", handle.nativeHandle ?? handle.sessionId],
env: terminalEnv,
};
}
async isAvailable(): Promise<boolean> {
const command = this.runtimeSettings?.command;
if (command?.mode === "replace") {
@@ -1,103 +0,0 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import type { AgentSessionConfig } from "../agent-sdk-types.js";
import { AiderAgentClient } from "./aider-agent.js";
import { AmpAgentClient } from "./amp-agent.js";
import { GeminiAgentClient } from "./gemini-agent.js";
function createExecutable(): string {
const dir = mkdtempSync(path.join(os.tmpdir(), "terminal-provider-test-"));
const file = path.join(dir, "provider-bin");
writeFileSync(file, "#!/bin/sh\nexit 0\n");
chmodSync(file, 0o755);
return file;
}
const buildConfig = (provider: "gemini" | "amp" | "aider"): AgentSessionConfig => ({
provider,
cwd: "/tmp/worktree",
terminal: true,
});
describe("terminal-only providers", () => {
test("Gemini builds an interactive prompt command without injecting cwd flags", () => {
const executable = createExecutable();
try {
const client = new GeminiAgentClient({
command: { mode: "replace", argv: [executable] },
});
const command = client.buildTerminalCreateCommand(
buildConfig("gemini"),
{ provider: "gemini", sessionId: "session-1" },
"Fix the bug",
);
expect(command.command).toBe(executable);
expect(command.args).toEqual(["-i", "Fix the bug"]);
} finally {
rmSync(path.dirname(executable), { recursive: true, force: true });
}
});
test("AMP launches without unsupported cwd flags", () => {
const executable = createExecutable();
try {
const client = new AmpAgentClient({
command: { mode: "replace", argv: [executable] },
});
const command = client.buildTerminalCreateCommand(buildConfig("amp"), {
provider: "amp",
sessionId: "session-1",
});
expect(command.command).toBe(executable);
expect(command.args).toEqual([]);
} finally {
rmSync(path.dirname(executable), { recursive: true, force: true });
}
});
test("Aider does not treat initial prompts as positional CLI arguments", () => {
const executable = createExecutable();
try {
const client = new AiderAgentClient({
command: { mode: "replace", argv: [executable] },
});
const command = client.buildTerminalCreateCommand(
buildConfig("aider"),
{ provider: "aider", sessionId: "session-1" },
"Refactor the parser",
);
expect(command.command).toBe(executable);
expect(command.args).toEqual(["--no-auto-commits"]);
} finally {
rmSync(path.dirname(executable), { recursive: true, force: true });
}
});
test("provider availability respects missing replacement binaries", async () => {
const missingPath = path.join(os.tmpdir(), "missing-terminal-provider");
await expect(
new GeminiAgentClient({
command: { mode: "replace", argv: [missingPath] },
}).isAvailable(),
).resolves.toBe(false);
await expect(
new AmpAgentClient({
command: { mode: "replace", argv: [missingPath] },
}).isAvailable(),
).resolves.toBe(false);
await expect(
new AiderAgentClient({
command: { mode: "replace", argv: [missingPath] },
}).isAvailable(),
).resolves.toBe(false);
});
});
+1 -16
View File
@@ -408,22 +408,7 @@ export async function createPaseoDaemon(
});
const durableTimelineStore = new DbAgentTimelineStore(database.db);
let agentManager: AgentManager | null = null;
const terminalManager = createTerminalManager({
resolveAgentIdForTerminal: (terminalId) => agentManager?.getAgentIdForTerminal(terminalId) ?? null,
onAgentBoundTerminalTitleChange: async ({ agentId, title }) => {
if (!agentManager) {
return;
}
try {
await agentManager.setTitle(agentId, title);
} catch (error) {
logger.warn(
{ err: error, agentId },
"Failed to propagate bound terminal title to agent state",
);
}
},
});
const terminalManager = createTerminalManager();
agentManager = new AgentManager({
clients: {
...createAllClients(logger, {
@@ -33,7 +33,6 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsTerminalMode: false,
};
interface ScriptedAgentBehavior {
@@ -785,7 +785,6 @@ export class LoopService {
model: loop.workerModel ?? loop.model ?? undefined,
title: buildWorkerTitle(loop, iteration.index),
internal: true,
terminal: false,
};
}
@@ -796,7 +795,6 @@ export class LoopService {
model: loop.verifierModel ?? loop.model ?? undefined,
title: buildVerifierTitle(loop, iteration.index),
internal: true,
terminal: false,
};
}
@@ -95,22 +95,19 @@ describe("persistence hooks", () => {
});
});
test("buildSessionConfig accepts terminal-only providers from the canonical manifest", () => {
test("buildSessionConfig accepts providers from the canonical manifest", () => {
const record = createRecord({
provider: "gemini",
persistence: {
provider: "gemini",
sessionId: "session-123",
},
config: {
terminal: true,
},
config: {},
});
expect(buildSessionConfig(record)).toMatchObject({
provider: "gemini",
cwd: "/tmp/project",
terminal: true,
});
});
});
@@ -7,7 +7,6 @@ import { isValidAgentProvider } from "./agent/provider-manifest.js";
export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSessionConfig> {
return {
cwd: record.cwd,
terminal: record.config?.terminal ?? undefined,
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined,
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
@@ -26,7 +25,6 @@ export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfi
return {
provider: record.provider,
cwd: record.cwd,
terminal: overrides.terminal,
modeId: overrides.modeId,
model: overrides.model,
thinkingOptionId: overrides.thinkingOptionId,
@@ -368,9 +368,6 @@ export class ScheduleService {
private async executeSchedule(schedule: StoredSchedule): Promise<ScheduleExecutionResult> {
if (schedule.target.type === "agent") {
const agent = await this.ensureAgentLoaded(schedule.target.agentId);
if (agent.terminal) {
throw new Error(`Agent ${agent.id} is a terminal agent and cannot be targeted by schedules`);
}
if (this.agentManager.hasInFlightRun(agent.id)) {
throw new Error(`Agent ${agent.id} already has an active run`);
}
@@ -401,7 +398,6 @@ export class ScheduleService {
extra: schedule.target.config.extra,
systemPrompt: schedule.target.config.systemPrompt,
mcpServers: schedule.target.config.mcpServers as AgentSessionConfig["mcpServers"],
terminal: false,
};
const labels = {
"paseo.schedule-id": schedule.id,
@@ -77,7 +77,6 @@ function createSessionForOwnershipTests(options?: {
hydrateTimelineFromProvider: vi.fn(async () => {
throw new Error("Session should not call hydrateTimelineFromProvider directly");
}),
getStructuredSendRejection: vi.fn(async () => null),
fetchTimeline: vi.fn(async () => ({
rows: options?.timelineRows ?? [],
hasOlder: false,
+24 -140
View File
@@ -57,8 +57,6 @@ import {
type VoiceTurnController,
} from "./voice/voice-turn-controller.js";
import {
buildSessionConfig,
extractTimestamps,
toAgentPersistenceHandle,
} from "./persistence-hooks.js";
import { experimental_createMCPClient } from "ai";
@@ -1005,7 +1003,6 @@ export class Session {
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
supportsTerminalMode: false,
} as const;
const createdAt = new Date(record.createdAt);
@@ -1033,7 +1030,6 @@ export class Session {
id: record.id,
provider,
cwd: record.cwd,
terminal: record.config?.terminal ?? false,
model: record.config?.model ?? null,
thinkingOptionId: record.config?.thinkingOptionId ?? null,
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
@@ -1052,7 +1048,6 @@ export class Session {
persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence),
lastUsage: undefined,
lastError: record.lastError ?? undefined,
terminalExit: record.terminalExit,
title: record.title ?? record.config?.title ?? null,
requiresAttention: record.requiresAttention ?? false,
attentionReason: record.attentionReason ?? null,
@@ -1091,10 +1086,7 @@ export class Session {
}
const initPromise = (async () => {
const record = await this.requireStoredAgentRecord(agentId);
if (record.config?.terminal) {
return this.ensureTerminalAgentLoaded(agentId, record);
}
await this.requireStoredAgentRecord(agentId);
return this.agentLoadingService.ensureAgentLoaded({ agentId });
})();
@@ -1118,39 +1110,6 @@ export class Session {
return record;
}
private async getAgentMode(agentId: string): Promise<"chat" | "terminal"> {
const existing = this.agentManager.getAgent(agentId);
if (existing) {
return existing.terminal ? "terminal" : "chat";
}
const record = await this.requireStoredAgentRecord(agentId);
return record.config?.terminal ? "terminal" : "chat";
}
private async ensureTerminalAgentLoaded(
agentId: string,
record: StoredAgentRecord,
): Promise<ManagedAgent> {
const timestamps = extractTimestamps(record);
const snapshot = await this.agentManager.launchTerminalAgent(buildSessionConfig(record), agentId, {
persistence: record.persistence ?? null,
createdAt: timestamps.createdAt,
updatedAt: timestamps.updatedAt,
lastUserMessageAt: timestamps.lastUserMessageAt,
labels: timestamps.labels,
attention: {
requiresAttention: record.requiresAttention ?? false,
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ? new Date(record.attentionTimestamp) : null,
},
});
this.sessionLogger.info(
{ agentId, provider: record.provider },
"Terminal agent loaded from stored config",
);
return this.agentManager.getAgent(agentId) ?? snapshot;
}
private matchesAgentFilter(options: {
agent: AgentSnapshotPayload;
project: ProjectPlacementPayload;
@@ -2618,9 +2577,6 @@ export class Session {
},
);
await this.forwardAgentUpdate(snapshot);
if (sessionConfig.terminal) {
void this.emitInitialTerminalsChangedSnapshot(resolvedWorkspace.directory);
}
if (requestId) {
const agentPayload = await this.getAgentPayloadById(snapshot.id);
@@ -2649,29 +2605,27 @@ export class Session {
logger: this.sessionLogger,
});
if (!sessionConfig.terminal) {
void this.handleSendAgentMessage(
snapshot.id,
trimmedPrompt,
resolveClientMessageId(clientMessageId),
images,
outputSchema ? { outputSchema } : undefined,
).catch((promptError) => {
this.sessionLogger.error(
{ err: promptError, agentId: snapshot.id },
`Failed to run initial prompt for agent ${snapshot.id}`,
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
},
});
void this.handleSendAgentMessage(
snapshot.id,
trimmedPrompt,
resolveClientMessageId(clientMessageId),
images,
outputSchema ? { outputSchema } : undefined,
).catch((promptError) => {
this.sessionLogger.error(
{ err: promptError, agentId: snapshot.id },
`Failed to run initial prompt for agent ${snapshot.id}`,
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
},
});
}
});
}
if (worktreeBootstrap) {
@@ -5694,13 +5648,6 @@ export class Session {
: undefined;
try {
const agentMode = await this.getAgentMode(msg.agentId);
if (agentMode === "terminal") {
throw new SessionRequestError(
"unsupported_agent_kind",
`Agent ${msg.agentId} is a terminal agent and has no timeline history`,
);
}
const snapshot = await this.ensureAgentLoaded(msg.agentId);
const agentPayload = await this.buildAgentPayload(snapshot);
const timeline = await this.agentManager.fetchTimeline(msg.agentId, {
@@ -5773,22 +5720,6 @@ export class Session {
}
try {
const structuredSendRejection = await this.agentManager.getStructuredSendRejection(
resolved.agentId,
);
if (structuredSendRejection) {
this.emit({
type: "send_agent_message_response",
payload: {
requestId: msg.requestId,
agentId: resolved.agentId,
accepted: false,
error: structuredSendRejection,
},
});
return;
}
const agentId = resolved.agentId;
await this.unarchiveAgentState(agentId);
@@ -7221,7 +7152,7 @@ export class Session {
}
private filterStandaloneTerminals<T extends { id: string }>(terminals: T[]): T[] {
return terminals.filter((terminal) => !this.agentManager.isTerminalBoundToAgent(terminal.id));
return terminals;
}
private toTerminalInfo(terminal: Pick<TerminalSession, "id" | "name" | "getTitle">): {
@@ -7328,46 +7259,6 @@ export class Session {
}
}
private async createOrResumeAgentTerminal(agentId: string): Promise<TerminalSession> {
if (!this.terminalManager) {
throw new Error("Terminal manager not available");
}
const existingTerminal = this.agentManager.getTerminalSessionForAgent(agentId);
if (existingTerminal) {
return existingTerminal;
}
const record = await this.agentStorage.get(agentId);
if (!record || record.internal) {
throw new Error(`Agent not found: ${agentId}`);
}
if (!record.config?.terminal) {
throw new Error(`Agent ${agentId} is not a terminal agent`);
}
const timestamps = extractTimestamps(record);
const launched = await this.agentManager.launchTerminalAgent(buildSessionConfig(record), agentId, {
persistence: record.persistence ?? null,
createdAt: timestamps.createdAt,
updatedAt: timestamps.updatedAt,
lastUserMessageAt: timestamps.lastUserMessageAt,
labels: timestamps.labels,
attention: {
requiresAttention: record.requiresAttention ?? false,
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ? new Date(record.attentionTimestamp) : null,
},
});
const terminal = launched.terminalId
? this.terminalManager.getTerminal(launched.terminalId)
: null;
if (!terminal) {
throw new Error(`Terminal not available for agent ${agentId}`);
}
return terminal;
}
private async getAllTerminalSessions(): Promise<TerminalSession[]> {
if (!this.terminalManager) {
return [];
@@ -7395,18 +7286,11 @@ export class Session {
try {
if (msg.agentId) {
const terminal = await this.createOrResumeAgentTerminal(msg.agentId);
this.ensureTerminalExitSubscription(terminal);
this.emit({
type: "create_terminal_response",
payload: {
terminal: {
id: terminal.id,
name: terminal.name,
cwd: terminal.cwd,
...(terminal.getTitle() ? { title: terminal.getTitle() } : {}),
},
error: null,
terminal: null,
error: `Agent-backed terminals are no longer supported for agent ${msg.agentId}`,
requestId: msg.requestId,
},
});
@@ -9,7 +9,6 @@ import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
function makeAgent(input: {
id: string;
@@ -39,7 +38,6 @@ function makeAgent(input: {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
},
currentModeId: null,
availableModes: [],
@@ -222,50 +220,6 @@ function seedWorkspace(options: {
return record;
}
function createStoredTerminalAgentRecord(input: {
id: string;
cwd: string;
}): StoredAgentRecord {
return {
id: input.id,
provider: "codex",
cwd: input.cwd,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
lastActivityAt: "2026-03-01T12:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "closed",
lastModeId: null,
config: {
terminal: true,
},
runtimeInfo: {
provider: "codex",
sessionId: null,
},
persistence: {
provider: "codex",
sessionId: input.id,
nativeHandle: input.id,
},
lastError: null,
terminalExit: {
command: "codex",
message: "Terminal session ended",
exitCode: 0,
signal: null,
outputLines: [],
},
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: null,
};
}
function createTempGitRepo(options?: {
remoteUrl?: string;
branchName?: string;
@@ -635,89 +589,6 @@ describe("workspace aggregation", () => {
expect(sessionLogger.warn).toHaveBeenCalled();
});
test("terminal agents reject timeline fetch without reloading as chat sessions", async () => {
const emitted: Array<{ type: string; payload: any }> = [];
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const resumeAgentFromPersistence = vi.fn();
const launchTerminalAgent = vi.fn();
const hydrateTimelineFromProvider = vi.fn();
const session = new Session({
clientId: "test-client",
onMessage: (message) => emitted.push(message as any),
logger: logger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
resumeAgentFromPersistence,
launchTerminalAgent,
hydrateTimelineFromProvider,
} as any,
agentStorage: {
list: async () => [],
get: async (agentId: string) =>
agentId === "terminal-1"
? createStoredTerminalAgentRecord({ id: agentId, cwd: "/tmp/repo" })
: null,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: null,
}) as any;
await session.handleMessage({
type: "fetch_agent_timeline_request",
requestId: "req-terminal-timeline",
agentId: "terminal-1",
});
expect(resumeAgentFromPersistence).not.toHaveBeenCalled();
expect(launchTerminalAgent).not.toHaveBeenCalled();
expect(hydrateTimelineFromProvider).not.toHaveBeenCalled();
expect(emitted).toContainEqual(
expect.objectContaining({
type: "fetch_agent_timeline_response",
payload: expect.objectContaining({
requestId: "req-terminal-timeline",
agentId: "terminal-1",
error: "Agent terminal-1 is a terminal agent and has no timeline history",
}),
}),
);
});
test("uses persisted workspace names and stable status aggregation", async () => {
const { session, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
@@ -30,7 +30,6 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsTerminalMode: false,
};
type Deferred<T> = {
-12
View File
@@ -95,7 +95,6 @@ const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
supportsMcpServers: z.boolean(),
supportsReasoningStream: z.boolean(),
supportsToolInvocations: z.boolean(),
supportsTerminalMode: z.boolean(),
});
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
@@ -133,7 +132,6 @@ const McpServerConfigSchema = z.discriminatedUnion("type", [
const AgentSessionConfigSchema = z.object({
provider: AgentProviderSchema,
cwd: z.string(),
terminal: z.boolean().optional(),
modeId: z.string().optional(),
model: z.string().optional(),
thinkingOptionId: z.string().optional(),
@@ -458,19 +456,10 @@ const AgentRuntimeInfoSchema: z.ZodType<AgentRuntimeInfo> = z.object({
extra: z.record(z.unknown()).optional(),
});
const TerminalExitDetailsSchema = z.object({
command: z.string(),
message: z.string(),
exitCode: z.number().nullable(),
signal: z.number().nullable(),
outputLines: z.array(z.string()),
});
export const AgentSnapshotPayloadSchema = z.object({
id: z.string(),
provider: AgentProviderSchema,
cwd: z.string(),
terminal: z.boolean().optional(),
model: z.string().nullable(),
thinkingOptionId: z.string().nullable().optional(),
effectiveThinkingOptionId: z.string().nullable().optional(),
@@ -486,7 +475,6 @@ export const AgentSnapshotPayloadSchema = z.object({
runtimeInfo: AgentRuntimeInfoSchema.optional(),
lastUsage: AgentUsageSchema.optional(),
lastError: z.string().optional(),
terminalExit: TerminalExitDetailsSchema.optional(),
title: z.string().nullable(),
labels: z.record(z.string()).default({}),
requiresAttention: z.boolean().optional(),
@@ -359,69 +359,6 @@ describe("TerminalManager", () => {
10000,
);
it("forwards bound terminal titles through the agent bridge without changing standalone lists", async () => {
await withShell("/bin/sh", async () => {
const onAgentBoundTerminalTitleChange = vi.fn();
manager = createTerminalManager({
resolveAgentIdForTerminal: () => "agent-1",
onAgentBoundTerminalTitleChange,
});
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(
input.terminals.map((terminal) => ({
id: terminal.id,
...(terminal.title ? { title: terminal.title } : {}),
})),
);
});
const session = await manager.createTerminal({ cwd: "/tmp" });
session.send({ type: "input", data: "printf '\\033]0;Agent Shell\\007'\r" });
await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000);
expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({
agentId: "agent-1",
title: "Agent Shell",
});
expect(
snapshots.some((snapshot) =>
snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Agent Shell"),
),
).toBe(true);
unsubscribe();
});
});
it("forwards initial titles for agent-bound terminals created with command args", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-manager-title-script-"));
temporaryDirs.push(packageRoot);
const scriptPath = join(packageRoot, "npm-cli.js");
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
const onAgentBoundTerminalTitleChange = vi.fn();
manager = createTerminalManager({
resolveAgentIdForTerminal: () => "agent-1",
onAgentBoundTerminalTitleChange,
});
await manager.createTerminal({
cwd: packageRoot,
command: process.execPath,
args: [scriptPath, "run", "dev"],
});
await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000);
expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({
agentId: "agent-1",
title: "npm run dev",
});
});
it("emits empty snapshot when last terminal is removed", async () => {
manager = createTerminalManager();
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
@@ -33,12 +33,7 @@ export interface TerminalManager {
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
}
type AgentBoundTerminalTitleHandler = (input: { agentId: string; title: string }) => Promise<void> | void;
export function createTerminalManager(options?: {
resolveAgentIdForTerminal?: (terminalId: string) => string | null;
onAgentBoundTerminalTitleChange?: AgentBoundTerminalTitleHandler;
}): TerminalManager {
export function createTerminalManager(): TerminalManager {
const terminalsByCwd = new Map<string, TerminalSession[]>();
const terminalsById = new Map<string, TerminalSession>();
const terminalExitUnsubscribeById = new Map<string, () => void>();
@@ -111,24 +106,8 @@ export function createTerminalManager(options?: {
const unsubscribeExit = session.onExit(() => {
removeSessionById(session.id, { kill: false });
});
const unsubscribeTitle = session.onTitleChange((title) => {
const unsubscribeTitle = session.onTitleChange(() => {
emitTerminalsChanged({ cwd: session.cwd });
const normalizedTitle = title?.trim();
if (!normalizedTitle) {
return;
}
const agentId = options?.resolveAgentIdForTerminal?.(session.id) ?? null;
if (!agentId) {
return;
}
void Promise.resolve(
options?.onAgentBoundTerminalTitleChange?.({
agentId,
title: normalizedTitle,
}),
).catch(() => {
// no-op
});
});
terminalExitUnsubscribeById.set(session.id, unsubscribeExit);
terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle);