# Conflicts: # nix/package.nix # packages/app/src/app/_layout.tsx # packages/app/src/components/sidebar-workspace-list.tsx # packages/app/src/hooks/use-command-center.ts # packages/app/src/screens/agent/draft-agent-screen.tsx # packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx # packages/app/src/screens/workspace/workspace-screen.tsx # packages/server/src/server/session.ts # packages/server/src/server/session.workspaces.test.ts # packages/server/src/terminal/terminal.test.ts # packages/server/src/terminal/terminal.ts
3122 lines
94 KiB
TypeScript
3122 lines
94 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { basename, resolve } from "node:path";
|
|
import { stat } from "node:fs/promises";
|
|
import {
|
|
AGENT_LIFECYCLE_STATUSES,
|
|
type AgentLifecycleStatus,
|
|
} from "../../shared/agent-lifecycle.js";
|
|
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,
|
|
AgentClient,
|
|
AgentLaunchContext,
|
|
AgentSlashCommand,
|
|
AgentMode,
|
|
AgentPermissionRequest,
|
|
AgentPermissionResponse,
|
|
AgentPersistenceHandle,
|
|
AgentPromptInput,
|
|
AgentProvider,
|
|
AgentRunOptions,
|
|
AgentRunResult,
|
|
AgentSession,
|
|
AgentSessionConfig,
|
|
AgentStreamEvent,
|
|
AgentTimelineItem,
|
|
AgentUsage,
|
|
AgentRuntimeInfo,
|
|
ListPersistedAgentsOptions,
|
|
PersistedAgentDescriptor,
|
|
TerminalCommand,
|
|
} from "./agent-sdk-types.js";
|
|
import type { StoredAgentRecord } from "./agent-storage.js";
|
|
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
|
import {
|
|
InMemoryAgentTimelineStore,
|
|
type SeedAgentTimelineOptions,
|
|
} from "./agent-timeline-store.js";
|
|
import type {
|
|
AgentTimelineFetchOptions,
|
|
AgentTimelineFetchResult,
|
|
AgentTimelineRow,
|
|
AgentTimelineStore,
|
|
} from "./agent-timeline-store-types.js";
|
|
import { AGENT_PROVIDER_IDS } from "./provider-manifest.js";
|
|
|
|
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
|
|
export type {
|
|
AgentTimelineCursor,
|
|
AgentTimelineFetchDirection,
|
|
AgentTimelineFetchOptions,
|
|
AgentTimelineFetchResult,
|
|
AgentTimelineRow,
|
|
AgentTimelineWindow,
|
|
} from "./agent-timeline-store-types.js";
|
|
|
|
export type AgentManagerEvent =
|
|
| { type: "agent_state"; agent: ManagedAgent }
|
|
| {
|
|
type: "agent_stream";
|
|
agentId: string;
|
|
event: AgentStreamEvent;
|
|
seq?: number;
|
|
};
|
|
|
|
export type AgentSubscriber = (event: AgentManagerEvent) => void;
|
|
|
|
export type SubscribeOptions = {
|
|
agentId?: string;
|
|
replayState?: boolean;
|
|
};
|
|
|
|
export type PersistedAgentQueryOptions = ListPersistedAgentsOptions & {
|
|
provider?: AgentProvider;
|
|
};
|
|
|
|
export type AgentAttentionCallback = (params: {
|
|
agentId: string;
|
|
provider: AgentProvider;
|
|
reason: "finished" | "error" | "permission";
|
|
}) => void;
|
|
|
|
export type ProviderAvailability = {
|
|
provider: AgentProvider;
|
|
available: boolean;
|
|
error: string | null;
|
|
};
|
|
|
|
export type AgentManagerOptions = {
|
|
clients?: Partial<Record<AgentProvider, AgentClient>>;
|
|
idFactory?: () => string;
|
|
registry?: AgentSnapshotStore;
|
|
onAgentAttention?: AgentAttentionCallback;
|
|
durableTimelineStore?: AgentTimelineStore;
|
|
terminalManager?: TerminalManager | null;
|
|
logger: Logger;
|
|
};
|
|
|
|
export type WaitForAgentOptions = {
|
|
signal?: AbortSignal;
|
|
waitForActive?: boolean;
|
|
};
|
|
|
|
export type WaitForAgentResult = {
|
|
status: AgentLifecycleStatus;
|
|
permission: AgentPermissionRequest | null;
|
|
lastMessage: string | null;
|
|
};
|
|
|
|
export type WaitForAgentStartOptions = {
|
|
signal?: AbortSignal;
|
|
};
|
|
|
|
export interface TerminalExitDetails {
|
|
command: string;
|
|
message: string;
|
|
exitCode: number | null;
|
|
signal: number | null;
|
|
outputLines: string[];
|
|
}
|
|
type AttentionState =
|
|
| { requiresAttention: false }
|
|
| {
|
|
requiresAttention: true;
|
|
attentionReason: "finished" | "error" | "permission";
|
|
attentionTimestamp: Date;
|
|
};
|
|
|
|
type ForegroundTurnWaiter = {
|
|
turnId: string;
|
|
callback: (event: AgentStreamEvent) => void;
|
|
settled: boolean;
|
|
settledPromise: Promise<void>;
|
|
resolveSettled: () => void;
|
|
};
|
|
|
|
type PendingForegroundRun = {
|
|
token: string;
|
|
started: boolean;
|
|
settled: boolean;
|
|
settledPromise: Promise<void>;
|
|
resolveSettled: () => void;
|
|
};
|
|
|
|
type ManagedAgentBase = {
|
|
id: string;
|
|
provider: AgentProvider;
|
|
cwd: string;
|
|
terminal: boolean;
|
|
capabilities: AgentCapabilityFlags;
|
|
config: AgentSessionConfig;
|
|
runtimeInfo?: AgentRuntimeInfo;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
availableModes: AgentMode[];
|
|
currentModeId: string | null;
|
|
pendingPermissions: Map<string, AgentPermissionRequest>;
|
|
pendingReplacement: boolean;
|
|
provisionalAssistantText: string | null;
|
|
persistence: AgentPersistenceHandle | null;
|
|
historyPrimed: boolean;
|
|
lastUserMessageAt: Date | null;
|
|
lastUsage?: AgentUsage;
|
|
lastError?: string;
|
|
terminalExit?: TerminalExitDetails;
|
|
attention: AttentionState;
|
|
foregroundTurnWaiters: Set<ForegroundTurnWaiter>;
|
|
unsubscribeSession: (() => void) | null;
|
|
/**
|
|
* Internal agents are hidden from listings and don't trigger notifications.
|
|
*/
|
|
internal?: boolean;
|
|
/**
|
|
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
|
|
*/
|
|
labels: Record<string, string>;
|
|
};
|
|
|
|
type ManagedAgentWithSession = ManagedAgentBase & {
|
|
session: AgentSession;
|
|
terminal: false;
|
|
};
|
|
|
|
type ManagedAgentInitializing = ManagedAgentWithSession & {
|
|
lifecycle: "initializing";
|
|
activeForegroundTurnId: null;
|
|
};
|
|
|
|
type ManagedAgentIdle = ManagedAgentWithSession & {
|
|
lifecycle: "idle";
|
|
activeForegroundTurnId: null;
|
|
};
|
|
|
|
type ManagedAgentRunning = ManagedAgentWithSession & {
|
|
lifecycle: "running";
|
|
activeForegroundTurnId: string | null;
|
|
};
|
|
|
|
type ManagedAgentError = ManagedAgentWithSession & {
|
|
lifecycle: "error";
|
|
activeForegroundTurnId: null;
|
|
lastError: string;
|
|
};
|
|
|
|
type ManagedAgentClosed = ManagedAgentBase & {
|
|
lifecycle: "closed";
|
|
session: null;
|
|
activeForegroundTurnId: null;
|
|
};
|
|
|
|
type ManagedTerminalAgent = ManagedAgentBase & {
|
|
terminal: true;
|
|
lifecycle: "idle";
|
|
session: null;
|
|
activeForegroundTurnId: null;
|
|
terminalCommand: TerminalCommand;
|
|
terminalId: string | null;
|
|
unsubscribeTerminalExit: (() => void) | null;
|
|
};
|
|
|
|
export type AgentKind = "session" | "terminal";
|
|
|
|
export type ManagedAgent =
|
|
| ManagedAgentInitializing
|
|
| ManagedAgentIdle
|
|
| ManagedAgentRunning
|
|
| ManagedAgentError
|
|
| ManagedTerminalAgent
|
|
| ManagedAgentClosed;
|
|
|
|
export interface AgentMetricsSnapshot {
|
|
total: number;
|
|
byLifecycle: Record<string, number>;
|
|
withActiveForegroundTurn: number;
|
|
timelineStats: {
|
|
totalItems: number;
|
|
maxItemsPerAgent: number;
|
|
};
|
|
}
|
|
|
|
type ActiveManagedAgent =
|
|
| ManagedAgentInitializing
|
|
| ManagedAgentIdle
|
|
| ManagedAgentRunning
|
|
| ManagedAgentError;
|
|
|
|
type LiveManagedAgent = ActiveManagedAgent | ManagedTerminalAgent;
|
|
|
|
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
|
|
|
function attachPersistenceCwd(
|
|
handle: AgentPersistenceHandle | null,
|
|
cwd: string,
|
|
): AgentPersistenceHandle | null {
|
|
if (!handle) {
|
|
return null;
|
|
}
|
|
return {
|
|
...handle,
|
|
metadata: {
|
|
...(handle.metadata ?? {}),
|
|
cwd,
|
|
},
|
|
};
|
|
}
|
|
|
|
type SubscriptionRecord = {
|
|
callback: AgentSubscriber;
|
|
agentId: string | null;
|
|
};
|
|
|
|
const BUSY_STATUSES: AgentLifecycleStatus[] = ["initializing", "running"];
|
|
const AgentIdSchema = z.string().uuid();
|
|
|
|
function isAgentBusy(status: AgentLifecycleStatus): boolean {
|
|
return BUSY_STATUSES.includes(status);
|
|
}
|
|
|
|
function isTurnTerminalEvent(event: AgentStreamEvent): boolean {
|
|
return (
|
|
event.type === "turn_completed" ||
|
|
event.type === "turn_failed" ||
|
|
event.type === "turn_canceled"
|
|
);
|
|
}
|
|
|
|
function createAbortError(signal: AbortSignal | undefined, fallbackMessage: string): Error {
|
|
const reason = signal?.reason;
|
|
const message =
|
|
typeof reason === "string"
|
|
? reason
|
|
: reason instanceof Error
|
|
? reason.message
|
|
: fallbackMessage;
|
|
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) {
|
|
throw new Error(`${source}: agentId must be a UUID`);
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
function normalizeMessageId(messageId: string | undefined): string | undefined {
|
|
if (typeof messageId !== "string") {
|
|
return undefined;
|
|
}
|
|
const trimmed = messageId.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
|
|
export class AgentManager {
|
|
private readonly clients = new Map<AgentProvider, AgentClient>();
|
|
private readonly agents = new Map<string, LiveManagedAgent>();
|
|
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
|
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
|
private readonly sessionEventTails = new Map<string, Promise<void>>();
|
|
private readonly pendingForegroundRuns = new Map<string, PendingForegroundRun>();
|
|
private readonly subscribers = new Set<SubscriptionRecord>();
|
|
private readonly idFactory: () => string;
|
|
private readonly registry?: AgentSnapshotStore;
|
|
private readonly durableTimelineStore?: AgentTimelineStore;
|
|
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
|
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)) {
|
|
if (client) {
|
|
this.registerClient(provider as AgentProvider, client);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
registerClient(provider: AgentProvider, client: AgentClient): void {
|
|
this.clients.set(provider, client);
|
|
}
|
|
|
|
setAgentAttentionCallback(callback: AgentAttentionCallback): void {
|
|
this.onAgentAttention = callback;
|
|
}
|
|
|
|
public getMetricsSnapshot(): AgentMetricsSnapshot {
|
|
const byLifecycle: Record<string, number> = {};
|
|
let withActiveForegroundTurn = 0;
|
|
let totalItems = 0;
|
|
let maxItemsPerAgent = 0;
|
|
|
|
for (const agent of this.agents.values()) {
|
|
byLifecycle[agent.lifecycle] = (byLifecycle[agent.lifecycle] ?? 0) + 1;
|
|
|
|
if (agent.activeForegroundTurnId !== null) {
|
|
withActiveForegroundTurn++;
|
|
}
|
|
|
|
if (!this.timelineStore.has(agent.id)) {
|
|
continue;
|
|
}
|
|
|
|
const len = this.timelineStore.getItems(agent.id).length;
|
|
totalItems += len;
|
|
if (len > maxItemsPerAgent) {
|
|
maxItemsPerAgent = len;
|
|
}
|
|
}
|
|
|
|
return {
|
|
total: this.agents.size,
|
|
byLifecycle,
|
|
withActiveForegroundTurn,
|
|
timelineStats: {
|
|
totalItems,
|
|
maxItemsPerAgent,
|
|
},
|
|
};
|
|
}
|
|
|
|
private touchUpdatedAt(agent: ManagedAgent): Date {
|
|
const nowMs = Date.now();
|
|
const previousMs = agent.updatedAt.getTime();
|
|
const nextMs = nowMs > previousMs ? nowMs : previousMs + 1;
|
|
const next = new Date(nextMs);
|
|
agent.updatedAt = next;
|
|
return next;
|
|
}
|
|
|
|
hasInFlightRun(agentId: string): boolean {
|
|
const agent = this.agents.get(agentId);
|
|
if (!agent) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
agent.lifecycle === "running" ||
|
|
Boolean(agent.activeForegroundTurnId) ||
|
|
this.hasPendingForegroundRun(agentId)
|
|
);
|
|
}
|
|
|
|
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void {
|
|
const targetAgentId =
|
|
options?.agentId == null ? null : validateAgentId(options.agentId, "subscribe");
|
|
const record: SubscriptionRecord = {
|
|
callback,
|
|
agentId: targetAgentId,
|
|
};
|
|
this.subscribers.add(record);
|
|
|
|
if (options?.replayState !== false) {
|
|
if (record.agentId) {
|
|
const agent = this.agents.get(record.agentId);
|
|
if (agent) {
|
|
callback({
|
|
type: "agent_state",
|
|
agent: { ...agent },
|
|
});
|
|
}
|
|
} else {
|
|
// For global subscribers, skip internal agents during replay
|
|
for (const agent of this.agents.values()) {
|
|
if (agent.internal) {
|
|
continue;
|
|
}
|
|
callback({
|
|
type: "agent_state",
|
|
agent: { ...agent },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
this.subscribers.delete(record);
|
|
};
|
|
}
|
|
|
|
listAgents(): ManagedAgent[] {
|
|
return Array.from(this.agents.values())
|
|
.filter((agent) => !agent.internal)
|
|
.map((agent) => ({
|
|
...agent,
|
|
}));
|
|
}
|
|
|
|
async listPersistedAgents(
|
|
options?: PersistedAgentQueryOptions,
|
|
): Promise<PersistedAgentDescriptor[]> {
|
|
if (options?.provider) {
|
|
const client = this.requireClient(options.provider);
|
|
if (!client.listPersistedAgents) {
|
|
return [];
|
|
}
|
|
return client.listPersistedAgents({ limit: options.limit });
|
|
}
|
|
|
|
const descriptors: PersistedAgentDescriptor[] = [];
|
|
for (const [provider, client] of this.clients.entries()) {
|
|
if (!client.listPersistedAgents) {
|
|
continue;
|
|
}
|
|
try {
|
|
const entries = await client.listPersistedAgents({
|
|
limit: options?.limit,
|
|
});
|
|
descriptors.push(...entries);
|
|
} catch (error) {
|
|
this.logger.warn({ err: error, provider }, "Failed to list persisted agents for provider");
|
|
}
|
|
}
|
|
|
|
const limit = options?.limit ?? 20;
|
|
return descriptors
|
|
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime())
|
|
.slice(0, limit);
|
|
}
|
|
|
|
async listProviderAvailability(): Promise<ProviderAvailability[]> {
|
|
const checks = AGENT_PROVIDER_IDS.map(async (providerId) => {
|
|
const provider = providerId as AgentProvider;
|
|
const client = this.clients.get(provider);
|
|
if (!client) {
|
|
return {
|
|
provider,
|
|
available: false,
|
|
error: `No client registered for provider '${provider}'`,
|
|
} satisfies ProviderAvailability;
|
|
}
|
|
|
|
try {
|
|
const available = await client.isAvailable();
|
|
return {
|
|
provider,
|
|
available,
|
|
error: null,
|
|
} satisfies ProviderAvailability;
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
this.logger.warn({ err: error, provider }, "Failed to check provider availability");
|
|
return {
|
|
provider,
|
|
available: false,
|
|
error: message,
|
|
} satisfies ProviderAvailability;
|
|
}
|
|
});
|
|
|
|
return Promise.all(checks);
|
|
}
|
|
|
|
async listDraftCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
|
|
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 session = await client.createSession(normalizedConfig);
|
|
try {
|
|
if (!session.listCommands) {
|
|
throw new Error(
|
|
`Provider '${normalizedConfig.provider}' does not support listing commands`,
|
|
);
|
|
}
|
|
return await session.listCommands();
|
|
} finally {
|
|
try {
|
|
await session.close();
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
{ err: error, provider: normalizedConfig.provider },
|
|
"Failed to close draft command listing session",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
getAgent(id: string): ManagedAgent | null {
|
|
const agent = this.agents.get(id);
|
|
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);
|
|
}
|
|
|
|
async getTimelineRows(id: string): Promise<AgentTimelineRow[]> {
|
|
this.requireAgent(id);
|
|
if (this.durableTimelineStore) {
|
|
return await this.durableTimelineStore.getCommittedRows(id);
|
|
}
|
|
return this.timelineStore.getRows(id);
|
|
}
|
|
|
|
async fetchTimeline(
|
|
id: string,
|
|
options?: AgentTimelineFetchOptions,
|
|
): Promise<AgentTimelineFetchResult> {
|
|
this.requireAgent(id);
|
|
if (this.durableTimelineStore) {
|
|
return await this.durableTimelineStore.fetchCommitted(id, options);
|
|
}
|
|
return this.timelineStore.fetch(id, options);
|
|
}
|
|
|
|
async createAgent(
|
|
config: AgentSessionConfig,
|
|
agentId?: string,
|
|
options?: {
|
|
labels?: Record<string, string>;
|
|
workspaceId?: number;
|
|
initialPrompt?: string;
|
|
},
|
|
): Promise<ManagedAgent> {
|
|
// Generate agent ID early so we can use it in MCP config
|
|
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
|
const normalizedConfig = await this.normalizeConfig(config);
|
|
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
|
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.`,
|
|
);
|
|
}
|
|
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,
|
|
workspaceId: options?.workspaceId,
|
|
});
|
|
}
|
|
|
|
// 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(
|
|
handle: AgentPersistenceHandle,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
agentId?: string,
|
|
options?: {
|
|
createdAt?: Date;
|
|
updatedAt?: Date;
|
|
lastUserMessageAt?: Date | null;
|
|
labels?: Record<string, string>;
|
|
},
|
|
): Promise<ManagedAgent> {
|
|
const resolvedAgentId = validateAgentId(
|
|
agentId ?? this.idFactory(),
|
|
"resumeAgentFromPersistence",
|
|
);
|
|
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
|
const mergedConfig = {
|
|
...metadata,
|
|
...overrides,
|
|
provider: handle.provider,
|
|
} as AgentSessionConfig;
|
|
const normalizedConfig = await this.normalizeConfig(mergedConfig);
|
|
const resumeOverrides =
|
|
normalizedConfig.model !== mergedConfig.model
|
|
? { ...overrides, model: normalizedConfig.model }
|
|
: overrides;
|
|
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
|
const client = this.requireClient(handle.provider);
|
|
const session = await client.resumeSession(handle, resumeOverrides, launchContext);
|
|
return this.registerSession(session, normalizedConfig, resolvedAgentId, options);
|
|
}
|
|
|
|
// Hot-reload an active agent session with config overrides while preserving
|
|
// in-memory timeline state.
|
|
async reloadAgentSession(
|
|
agentId: string,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
): Promise<ManagedAgent> {
|
|
let existing = this.requireSessionAgent(agentId);
|
|
if (this.hasInFlightRun(agentId)) {
|
|
await this.cancelAgentRun(agentId);
|
|
existing = this.requireSessionAgent(agentId);
|
|
}
|
|
const preservedProvisionalAssistantText = existing.provisionalAssistantText;
|
|
const preservedHistoryPrimed = existing.historyPrimed;
|
|
const preservedLastUsage = existing.lastUsage;
|
|
const preservedLastError = existing.lastError;
|
|
const preservedAttention = existing.attention;
|
|
const handle = existing.persistence;
|
|
const provider = handle?.provider ?? existing.provider;
|
|
const client = this.requireClient(provider);
|
|
const refreshConfig = {
|
|
...existing.config,
|
|
...overrides,
|
|
provider,
|
|
} as AgentSessionConfig;
|
|
const normalizedConfig = await this.normalizeConfig(refreshConfig);
|
|
const launchContext = this.buildLaunchContext(agentId);
|
|
|
|
const session = handle
|
|
? await client.resumeSession(handle, normalizedConfig, launchContext)
|
|
: await client.createSession(normalizedConfig, launchContext);
|
|
|
|
// Remove the existing agent entry before swapping sessions
|
|
this.agents.delete(agentId);
|
|
if (existing.unsubscribeSession) {
|
|
existing.unsubscribeSession();
|
|
existing.unsubscribeSession = null;
|
|
}
|
|
for (const waiter of existing.foregroundTurnWaiters) {
|
|
this.settleForegroundTurnWaiter(waiter);
|
|
}
|
|
existing.foregroundTurnWaiters.clear();
|
|
this.settlePendingForegroundRun(agentId);
|
|
try {
|
|
await existing.session.close();
|
|
} catch (error) {
|
|
this.logger.warn({ err: error, agentId }, "Failed to close previous session during refresh");
|
|
}
|
|
|
|
// Preserve existing labels and timeline during reload.
|
|
return this.registerSession(session, normalizedConfig, agentId, {
|
|
labels: existing.labels,
|
|
createdAt: existing.createdAt,
|
|
updatedAt: existing.updatedAt,
|
|
lastUserMessageAt: existing.lastUserMessageAt,
|
|
provisionalAssistantText: preservedProvisionalAssistantText,
|
|
historyPrimed: preservedHistoryPrimed,
|
|
lastUsage: preservedLastUsage,
|
|
lastError: preservedLastError,
|
|
attention: preservedAttention,
|
|
});
|
|
}
|
|
|
|
async closeAgent(agentId: string): Promise<void> {
|
|
const agent = this.requireAgent(agentId);
|
|
this.logger.trace(
|
|
{
|
|
agentId,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
pendingPermissions: agent.pendingPermissions.size,
|
|
},
|
|
"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();
|
|
}
|
|
this.timelineStore.delete(agentId);
|
|
this.emitClosedAgent(closedAgent);
|
|
this.logger.trace({ agentId }, "closeAgent: completed");
|
|
}
|
|
|
|
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
|
const agent = this.requireAgent(agentId);
|
|
if (!this.registry) {
|
|
throw new Error("Agent storage is not configured");
|
|
}
|
|
|
|
await this.registry.applySnapshot(agent, {
|
|
internal: agent.internal,
|
|
});
|
|
const stored = await this.registry.get(agentId);
|
|
if (!stored) {
|
|
throw new Error(`Agent ${agentId} not found in storage after snapshot`);
|
|
}
|
|
|
|
const archivedAt = new Date().toISOString();
|
|
const normalizedStatus =
|
|
stored.lastStatus === "running" || stored.lastStatus === "initializing"
|
|
? "idle"
|
|
: stored.lastStatus;
|
|
|
|
await this.registry.upsert({
|
|
...stored,
|
|
archivedAt,
|
|
updatedAt: archivedAt,
|
|
lastStatus: normalizedStatus,
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
attentionTimestamp: null,
|
|
});
|
|
this.notifyAgentState(agentId);
|
|
await this.closeAgent(agentId);
|
|
|
|
return { archivedAt };
|
|
}
|
|
|
|
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
await agent.session.setMode(modeId);
|
|
agent.currentModeId = modeId;
|
|
// Update runtimeInfo to reflect the new mode
|
|
if (agent.runtimeInfo) {
|
|
agent.runtimeInfo = { ...agent.runtimeInfo, modeId };
|
|
}
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
}
|
|
|
|
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
const normalizedModelId =
|
|
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
|
|
|
if (agent.session.setModel) {
|
|
await agent.session.setModel(normalizedModelId);
|
|
}
|
|
|
|
agent.config.model = normalizedModelId ?? undefined;
|
|
if (agent.runtimeInfo) {
|
|
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
|
|
}
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
}
|
|
|
|
async setAgentThinkingOption(agentId: string, thinkingOptionId: string | null): Promise<void> {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
const normalizedThinkingOptionId =
|
|
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
|
|
? thinkingOptionId
|
|
: null;
|
|
|
|
if (agent.session.setThinkingOption) {
|
|
await agent.session.setThinkingOption(normalizedThinkingOptionId);
|
|
}
|
|
|
|
agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
|
|
if (agent.runtimeInfo) {
|
|
agent.runtimeInfo = {
|
|
...agent.runtimeInfo,
|
|
thinkingOptionId: normalizedThinkingOptionId,
|
|
};
|
|
}
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
}
|
|
|
|
async setTitle(agentId: string, title: string): Promise<void> {
|
|
const agent = this.requireAgent(agentId);
|
|
const normalizedTitle = title.trim();
|
|
if (!normalizedTitle) {
|
|
return;
|
|
}
|
|
if (
|
|
this.agentsAwaitingInitialSnapshotPersist.has(agent.id) &&
|
|
this.registry &&
|
|
(await this.registry.get(agent.id)) === null
|
|
) {
|
|
return;
|
|
}
|
|
this.touchUpdatedAt(agent);
|
|
await this.persistSnapshot(agent, { title: normalizedTitle });
|
|
this.emitState(agent, { persist: false });
|
|
}
|
|
|
|
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
|
const agent = this.requireAgent(agentId);
|
|
agent.labels = { ...agent.labels, ...labels };
|
|
this.touchUpdatedAt(agent);
|
|
await this.persistSnapshot(agent);
|
|
this.emitState(agent, { persist: false });
|
|
}
|
|
|
|
notifyAgentState(agentId: string): void {
|
|
const agent = this.agents.get(agentId);
|
|
if (!agent || agent.internal) {
|
|
return;
|
|
}
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
}
|
|
|
|
async clearAgentAttention(agentId: string): Promise<void> {
|
|
const agent = this.requireAgent(agentId);
|
|
if (agent.attention.requiresAttention) {
|
|
agent.attention = { requiresAttention: false };
|
|
await this.persistSnapshot(agent);
|
|
this.emitState(agent, { persist: false });
|
|
}
|
|
}
|
|
|
|
async archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord> {
|
|
const registry = this.requireRegistry();
|
|
const liveAgent = this.getAgent(agentId);
|
|
if (liveAgent) {
|
|
await this.persistSnapshot(liveAgent, {
|
|
internal: liveAgent.internal,
|
|
});
|
|
}
|
|
|
|
const record = await registry.get(agentId);
|
|
if (!record) {
|
|
throw new Error(`Agent not found: ${agentId}`);
|
|
}
|
|
|
|
const normalizedStatus =
|
|
record.lastStatus === "running" || record.lastStatus === "initializing"
|
|
? "idle"
|
|
: record.lastStatus;
|
|
|
|
const nextRecord: StoredAgentRecord = {
|
|
...record,
|
|
archivedAt,
|
|
lastStatus: normalizedStatus,
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
attentionTimestamp: null,
|
|
};
|
|
await registry.upsert(nextRecord);
|
|
return nextRecord;
|
|
}
|
|
|
|
async unarchiveSnapshot(agentId: string): Promise<boolean> {
|
|
const registry = this.requireRegistry();
|
|
const record = await registry.get(agentId);
|
|
if (!record || !record.archivedAt) {
|
|
return false;
|
|
}
|
|
|
|
await registry.upsert({
|
|
...record,
|
|
archivedAt: null,
|
|
});
|
|
|
|
if (this.getAgent(agentId)) {
|
|
this.notifyAgentState(agentId);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async unarchiveSnapshotByHandle(handle: AgentPersistenceHandle): Promise<void> {
|
|
const registry = this.requireRegistry();
|
|
const records = await registry.list();
|
|
const matched = records.find(
|
|
(record) =>
|
|
record.persistence?.provider === handle.provider &&
|
|
record.persistence?.sessionId === handle.sessionId,
|
|
);
|
|
if (!matched) {
|
|
return;
|
|
}
|
|
|
|
await this.unarchiveSnapshot(matched.id);
|
|
}
|
|
|
|
async updateAgentMetadata(
|
|
agentId: string,
|
|
updates: {
|
|
title?: string;
|
|
labels?: Record<string, string>;
|
|
},
|
|
): Promise<void> {
|
|
const liveAgent = this.getAgent(agentId);
|
|
if (liveAgent) {
|
|
if (updates.title) {
|
|
await this.setTitle(agentId, updates.title);
|
|
}
|
|
if (updates.labels) {
|
|
await this.setLabels(agentId, updates.labels);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const registry = this.requireRegistry();
|
|
const existing = await registry.get(agentId);
|
|
if (!existing) {
|
|
throw new Error(`Agent not found: ${agentId}`);
|
|
}
|
|
|
|
await registry.upsert({
|
|
...existing,
|
|
...(updates.title ? { title: updates.title } : {}),
|
|
...(updates.labels ? { labels: { ...existing.labels, ...updates.labels } } : {}),
|
|
});
|
|
}
|
|
|
|
async runAgent(
|
|
agentId: string,
|
|
prompt: AgentPromptInput,
|
|
options?: AgentRunOptions,
|
|
): Promise<AgentRunResult> {
|
|
const events = this.streamAgent(agentId, prompt, options);
|
|
const timeline: AgentTimelineItem[] = [];
|
|
let finalText = "";
|
|
let usage: AgentUsage | undefined;
|
|
let canceled = false;
|
|
|
|
for await (const event of events) {
|
|
if (event.type === "timeline") {
|
|
timeline.push(event.item);
|
|
} else if (event.type === "turn_completed") {
|
|
usage = event.usage;
|
|
} else if (event.type === "turn_failed") {
|
|
throw new Error(this.formatTurnFailedMessage(event));
|
|
} else if (event.type === "turn_canceled") {
|
|
canceled = true;
|
|
}
|
|
}
|
|
|
|
finalText = this.getLastAssistantMessageFromTimeline(timeline) ?? "";
|
|
|
|
const agent = this.requireAgent(agentId);
|
|
const sessionId = agent.persistence?.sessionId;
|
|
if (!sessionId) {
|
|
throw new Error(`Agent ${agentId} has no persistence.sessionId after run completed`);
|
|
}
|
|
return {
|
|
sessionId,
|
|
finalText,
|
|
usage,
|
|
timeline,
|
|
canceled,
|
|
};
|
|
}
|
|
|
|
recordUserMessage(
|
|
agentId: string,
|
|
text: string,
|
|
options?: { messageId?: string; emitState?: boolean },
|
|
): void {
|
|
const agent = this.requireAgent(agentId);
|
|
const normalizedMessageId = normalizeMessageId(options?.messageId);
|
|
const item: AgentTimelineItem = {
|
|
type: "user_message",
|
|
text,
|
|
messageId: normalizedMessageId,
|
|
};
|
|
const updatedAt = this.touchUpdatedAt(agent);
|
|
agent.lastUserMessageAt = updatedAt;
|
|
const row = this.recordTimeline(agentId, item);
|
|
this.dispatchStream(
|
|
agentId,
|
|
{
|
|
type: "timeline",
|
|
item,
|
|
provider: agent.provider,
|
|
},
|
|
{
|
|
seq: row.seq,
|
|
},
|
|
);
|
|
if (options?.emitState !== false) {
|
|
this.emitState(agent);
|
|
}
|
|
}
|
|
|
|
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
|
const agent = this.requireAgent(agentId);
|
|
this.touchUpdatedAt(agent);
|
|
const row = this.recordTimeline(agentId, item);
|
|
this.dispatchStream(
|
|
agentId,
|
|
{
|
|
type: "timeline",
|
|
item,
|
|
provider: agent.provider,
|
|
},
|
|
{
|
|
seq: row.seq,
|
|
},
|
|
);
|
|
await this.persistSnapshot(agent);
|
|
}
|
|
|
|
async emitLiveTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
|
const agent = this.requireAgent(agentId);
|
|
this.touchUpdatedAt(agent);
|
|
this.dispatchStream(agentId, {
|
|
type: "timeline",
|
|
item,
|
|
provider: agent.provider,
|
|
});
|
|
}
|
|
|
|
streamAgent(
|
|
agentId: string,
|
|
prompt: AgentPromptInput,
|
|
options?: AgentRunOptions,
|
|
): AsyncGenerator<AgentStreamEvent> {
|
|
const existingAgent = this.requireSessionAgent(agentId);
|
|
this.logger.trace(
|
|
{
|
|
agentId,
|
|
lifecycle: existingAgent.lifecycle,
|
|
activeForegroundTurnId: existingAgent.activeForegroundTurnId,
|
|
hasPendingForegroundRun: this.hasPendingForegroundRun(agentId),
|
|
promptType: typeof prompt === "string" ? "string" : "structured",
|
|
hasRunOptions: Boolean(options),
|
|
},
|
|
"streamAgent: requested",
|
|
);
|
|
if (existingAgent.activeForegroundTurnId || this.hasPendingForegroundRun(agentId)) {
|
|
this.logger.trace(
|
|
{
|
|
agentId,
|
|
lifecycle: existingAgent.lifecycle,
|
|
hasPendingForegroundRun: this.hasPendingForegroundRun(agentId),
|
|
},
|
|
"streamAgent: rejected because a foreground run is already in flight",
|
|
);
|
|
throw new Error(`Agent ${agentId} already has an active run`);
|
|
}
|
|
|
|
const agent = existingAgent as ActiveManagedAgent;
|
|
agent.pendingReplacement = false;
|
|
agent.lastError = undefined;
|
|
|
|
const self = this;
|
|
|
|
const streamForwarder = (async function* streamForwarder() {
|
|
const pendingRun = self.createPendingForegroundRun();
|
|
self.pendingForegroundRuns.set(agentId, pendingRun);
|
|
|
|
let turnId: string;
|
|
let waiter: ForegroundTurnWaiter | null = null;
|
|
try {
|
|
const result = await agent.session.startTurn(prompt, options);
|
|
turnId = result.turnId;
|
|
} catch (error) {
|
|
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
|
|
self.handleStreamEvent(agent, {
|
|
type: "turn_failed",
|
|
provider: agent.provider,
|
|
error: errorMsg,
|
|
});
|
|
self.finalizeForegroundTurn(agent);
|
|
throw error;
|
|
}
|
|
|
|
pendingRun.started = true;
|
|
agent.activeForegroundTurnId = turnId;
|
|
agent.lifecycle = "running";
|
|
self.touchUpdatedAt(agent);
|
|
self.emitState(agent);
|
|
self.logger.trace(
|
|
{
|
|
agentId,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
},
|
|
"streamAgent: started",
|
|
);
|
|
|
|
// Create a pushable queue for this foreground turn
|
|
const queue: AgentStreamEvent[] = [];
|
|
let queueResolve: (() => void) | null = null;
|
|
let done = false;
|
|
let resolveSettled!: () => void;
|
|
const settledPromise = new Promise<void>((resolve) => {
|
|
resolveSettled = resolve;
|
|
});
|
|
|
|
waiter = {
|
|
turnId,
|
|
settled: false,
|
|
settledPromise,
|
|
resolveSettled,
|
|
callback: (event: AgentStreamEvent) => {
|
|
queue.push(event);
|
|
if (queueResolve) {
|
|
queueResolve();
|
|
queueResolve = null;
|
|
}
|
|
},
|
|
};
|
|
agent.foregroundTurnWaiters.add(waiter);
|
|
|
|
try {
|
|
while (!done) {
|
|
while (queue.length > 0) {
|
|
const event = queue.shift()!;
|
|
yield event;
|
|
if (isTurnTerminalEvent(event)) {
|
|
done = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!done && queue.length === 0) {
|
|
if (waiter.settled) {
|
|
break;
|
|
}
|
|
await new Promise<void>((resolve) => {
|
|
queueResolve = resolve;
|
|
});
|
|
}
|
|
}
|
|
} finally {
|
|
if (waiter) {
|
|
agent.foregroundTurnWaiters.delete(waiter);
|
|
self.settleForegroundTurnWaiter(waiter);
|
|
}
|
|
self.settlePendingForegroundRun(agentId, pendingRun.token);
|
|
if (!agent.activeForegroundTurnId) {
|
|
await self.refreshRuntimeInfo(agent);
|
|
}
|
|
}
|
|
})();
|
|
|
|
return streamForwarder;
|
|
}
|
|
|
|
private finalizeForegroundTurn(agent: ActiveManagedAgent): void {
|
|
const mutableAgent = agent as ActiveManagedAgent;
|
|
mutableAgent.activeForegroundTurnId = null;
|
|
const terminalError = mutableAgent.lastError;
|
|
const shouldHoldBusyForReplacement = mutableAgent.pendingReplacement && !terminalError;
|
|
mutableAgent.lifecycle = shouldHoldBusyForReplacement
|
|
? "running"
|
|
: terminalError
|
|
? "error"
|
|
: "idle";
|
|
const persistenceHandle =
|
|
mutableAgent.session.describePersistence() ??
|
|
(mutableAgent.runtimeInfo?.sessionId
|
|
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
|
|
: null);
|
|
if (persistenceHandle) {
|
|
mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd);
|
|
}
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
lifecycle: mutableAgent.lifecycle,
|
|
terminalError,
|
|
pendingReplacement: mutableAgent.pendingReplacement,
|
|
},
|
|
"finalizeForegroundTurn: applying terminal state",
|
|
);
|
|
if (!shouldHoldBusyForReplacement) {
|
|
this.touchUpdatedAt(mutableAgent);
|
|
this.emitState(mutableAgent);
|
|
}
|
|
}
|
|
|
|
replaceAgentRun(
|
|
agentId: string,
|
|
prompt: AgentPromptInput,
|
|
options?: AgentRunOptions,
|
|
): AsyncGenerator<AgentStreamEvent> {
|
|
const snapshot = this.requireAgent(agentId);
|
|
if (
|
|
snapshot.lifecycle !== "running" &&
|
|
!snapshot.activeForegroundTurnId &&
|
|
!this.hasPendingForegroundRun(agentId)
|
|
) {
|
|
return this.streamAgent(agentId, prompt, options);
|
|
}
|
|
|
|
const agent = this.requireSessionAgent(agentId);
|
|
agent.pendingReplacement = true;
|
|
|
|
const self = this;
|
|
return (async function* replaceRunForwarder() {
|
|
try {
|
|
await self.cancelAgentRun(agentId);
|
|
const nextRun = self.streamAgent(agentId, prompt, options);
|
|
for await (const event of nextRun) {
|
|
yield event;
|
|
}
|
|
} catch (error) {
|
|
const latest = self.agents.get(agentId);
|
|
if (latest) {
|
|
const latestActive = latest as ActiveManagedAgent;
|
|
latestActive.pendingReplacement = false;
|
|
if (!latestActive.activeForegroundTurnId && latestActive.lifecycle === "running") {
|
|
(latestActive as ActiveManagedAgent).lifecycle = "idle";
|
|
self.touchUpdatedAt(latestActive);
|
|
self.emitState(latestActive);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
})();
|
|
}
|
|
|
|
async waitForAgentRunStart(agentId: string, options?: WaitForAgentStartOptions): Promise<void> {
|
|
const snapshot = this.getAgent(agentId);
|
|
if (!snapshot) {
|
|
throw new Error(`Agent ${agentId} not found`);
|
|
}
|
|
|
|
const pendingRun = this.getPendingForegroundRun(agentId);
|
|
if (
|
|
(snapshot.lifecycle === "running" || pendingRun?.started) &&
|
|
!snapshot.pendingReplacement
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!snapshot.activeForegroundTurnId && !pendingRun && !snapshot.pendingReplacement) {
|
|
throw new Error(`Agent ${agentId} has no pending run`);
|
|
}
|
|
|
|
if (options?.signal?.aborted) {
|
|
throw createAbortError(options.signal, "wait_for_agent_start aborted");
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
if (options?.signal?.aborted) {
|
|
reject(createAbortError(options.signal, "wait_for_agent_start aborted"));
|
|
return;
|
|
}
|
|
|
|
let unsubscribe: (() => void) | null = null;
|
|
let abortHandler: (() => void) | null = null;
|
|
|
|
const cleanup = () => {
|
|
if (unsubscribe) {
|
|
try {
|
|
unsubscribe();
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
unsubscribe = null;
|
|
}
|
|
if (abortHandler && options?.signal) {
|
|
try {
|
|
options.signal.removeEventListener("abort", abortHandler);
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
abortHandler = null;
|
|
}
|
|
};
|
|
|
|
const finishOk = () => {
|
|
cleanup();
|
|
resolve();
|
|
};
|
|
|
|
const finishErr = (error: unknown) => {
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
|
|
if (options?.signal) {
|
|
abortHandler = () =>
|
|
finishErr(createAbortError(options.signal!, "wait_for_agent_start aborted"));
|
|
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
}
|
|
|
|
const checkCurrentState = () => {
|
|
const current = this.getAgent(agentId);
|
|
if (!current) {
|
|
finishErr(new Error(`Agent ${agentId} not found`));
|
|
return true;
|
|
}
|
|
|
|
const currentPendingRun = this.getPendingForegroundRun(agentId);
|
|
if (
|
|
(current.lifecycle === "running" || currentPendingRun?.started) &&
|
|
!current.pendingReplacement
|
|
) {
|
|
finishOk();
|
|
return true;
|
|
}
|
|
|
|
if (current.lifecycle === "error" && !currentPendingRun?.started) {
|
|
finishErr(new Error(current.lastError ?? `Agent ${agentId} failed to start`));
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
!currentPendingRun &&
|
|
!current.activeForegroundTurnId &&
|
|
!current.pendingReplacement
|
|
) {
|
|
finishErr(new Error(`Agent ${agentId} run finished before starting`));
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
unsubscribe = this.subscribe(
|
|
(event) => {
|
|
if (event.type !== "agent_state" || event.agent.id !== agentId) {
|
|
return;
|
|
}
|
|
checkCurrentState();
|
|
},
|
|
{ agentId, replayState: false },
|
|
);
|
|
|
|
checkCurrentState();
|
|
});
|
|
}
|
|
|
|
async respondToPermission(
|
|
agentId: string,
|
|
requestId: string,
|
|
response: AgentPermissionResponse,
|
|
): Promise<void> {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
await agent.session.respondToPermission(requestId, response);
|
|
agent.pendingPermissions.delete(requestId);
|
|
|
|
// Update currentModeId - the session may have changed mode internally
|
|
// (e.g., plan approval changes mode from "plan" to "acceptEdits")
|
|
try {
|
|
agent.currentModeId = await agent.session.getCurrentMode();
|
|
if (agent.runtimeInfo) {
|
|
agent.runtimeInfo = {
|
|
...agent.runtimeInfo,
|
|
modeId: agent.currentModeId,
|
|
};
|
|
}
|
|
} catch {
|
|
// Ignore errors from getCurrentMode - mode tracking is best effort
|
|
}
|
|
|
|
this.emitState(agent);
|
|
}
|
|
|
|
async cancelAgentRun(agentId: string): Promise<boolean> {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
const pendingRun = this.getPendingForegroundRun(agentId);
|
|
const foregroundTurnId = agent.activeForegroundTurnId;
|
|
const hasForegroundTurn = Boolean(foregroundTurnId);
|
|
const isAutonomousRunning =
|
|
agent.lifecycle === "running" && !hasForegroundTurn && !pendingRun;
|
|
|
|
if (!hasForegroundTurn && !isAutonomousRunning && !pendingRun) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await agent.session.interrupt();
|
|
} catch (error) {
|
|
this.logger.error({ err: error, agentId }, "Failed to interrupt session");
|
|
}
|
|
|
|
// The interrupt will produce a turn_canceled/turn_failed event via subscribe(),
|
|
// which flows through the session event dispatcher and settles the foreground turn waiter.
|
|
// Wait briefly for the event to propagate if there's an active foreground turn.
|
|
if (foregroundTurnId) {
|
|
const waiter = Array.from(agent.foregroundTurnWaiters).find(
|
|
(candidate) => candidate.turnId === foregroundTurnId,
|
|
);
|
|
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000));
|
|
if (waiter) {
|
|
await Promise.race([waiter.settledPromise, timeout]);
|
|
} else if (agent.activeForegroundTurnId === foregroundTurnId) {
|
|
await Promise.race([
|
|
new Promise<void>((resolve) => {
|
|
const unsubscribe = this.subscribe(
|
|
(event) => {
|
|
if (
|
|
event.type === "agent_state" &&
|
|
event.agent.id === agentId &&
|
|
!event.agent.activeForegroundTurnId
|
|
) {
|
|
unsubscribe();
|
|
resolve();
|
|
}
|
|
},
|
|
{ agentId, replayState: false },
|
|
);
|
|
}),
|
|
timeout,
|
|
]);
|
|
}
|
|
// The waiter settling wakes up the streamForwarder generator, but its
|
|
// finally block (which deletes the pendingForegroundRun) runs asynchronously.
|
|
// Wait for the pending run to be fully cleaned up so the next streamAgent
|
|
// call doesn't see a stale entry and reject with "already has an active run".
|
|
if (pendingRun && !pendingRun.settled) {
|
|
await Promise.race([pendingRun.settledPromise, timeout]);
|
|
}
|
|
} else if (pendingRun) {
|
|
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000));
|
|
await Promise.race([pendingRun.settledPromise, timeout]);
|
|
}
|
|
|
|
// If the foreground turn is still stuck after the timeout, force-dispatch a
|
|
// synthetic turn_canceled so the normal event pipeline cleans up
|
|
// activeForegroundTurnId, settles waiters, and unblocks the streamForwarder.
|
|
if (foregroundTurnId && agent.activeForegroundTurnId === foregroundTurnId) {
|
|
this.logger.warn(
|
|
{ agentId, foregroundTurnId },
|
|
"cancelAgentRun: foreground turn still active after timeout, force-canceling",
|
|
);
|
|
this.dispatchSessionEvent(agent, {
|
|
type: "turn_canceled",
|
|
provider: agent.provider,
|
|
reason: "interrupted",
|
|
turnId: foregroundTurnId,
|
|
});
|
|
// The synthetic event unblocks the streamForwarder generator, whose finally
|
|
// block settles the pending foreground run asynchronously. Wait for it.
|
|
const staleRun = this.getPendingForegroundRun(agentId);
|
|
if (staleRun && !staleRun.settled) {
|
|
await staleRun.settledPromise;
|
|
}
|
|
}
|
|
|
|
// Clear any pending permissions that weren't cleaned up by handleStreamEvent.
|
|
if (agent.pendingPermissions.size > 0) {
|
|
for (const [requestId] of agent.pendingPermissions) {
|
|
this.dispatchStream(agent.id, {
|
|
type: "permission_resolved",
|
|
provider: agent.provider,
|
|
requestId,
|
|
resolution: { behavior: "deny", message: "Interrupted" },
|
|
});
|
|
}
|
|
agent.pendingPermissions.clear();
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
getPendingPermissions(agentId: string): AgentPermissionRequest[] {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
return Array.from(agent.pendingPermissions.values());
|
|
}
|
|
|
|
private peekPendingPermission(agent: ManagedAgent): AgentPermissionRequest | null {
|
|
const iterator = agent.pendingPermissions.values().next();
|
|
return iterator.done ? null : iterator.value;
|
|
}
|
|
|
|
/**
|
|
* Test-only compatibility hook for managers constructed without a durable
|
|
* timeline store. Production loads committed history from the durable store
|
|
* during session registration instead of replaying provider history here.
|
|
*/
|
|
async hydrateTimelineFromProvider(agentId: string): Promise<void> {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
if (this.durableTimelineStore) {
|
|
return;
|
|
}
|
|
await this.hydrateTimelineFromLegacyProviderHistory(agent);
|
|
}
|
|
|
|
async deleteCommittedTimeline(agentId: string): Promise<void> {
|
|
if (!this.durableTimelineStore) {
|
|
return;
|
|
}
|
|
await this.durableTimelineStore.deleteAgent(agentId);
|
|
}
|
|
|
|
async getLastAssistantMessage(agentId: string): Promise<string | null> {
|
|
const agent = this.agents.get(agentId);
|
|
if (!agent) {
|
|
return null;
|
|
}
|
|
|
|
return await this.getLastAssistantMessageFromStores(agentId);
|
|
}
|
|
|
|
private getLastAssistantMessageFromTimeline(
|
|
timeline: readonly AgentTimelineItem[],
|
|
): string | null {
|
|
return this.getLastAssistantMessageSegmentFromTimeline(timeline)?.text ?? null;
|
|
}
|
|
|
|
private getLastAssistantMessageSegmentFromTimeline(
|
|
timeline: readonly AgentTimelineItem[],
|
|
): { text: string; startsAtBeginning: boolean } | null {
|
|
// Collect the last contiguous assistant messages (Claude streams chunks)
|
|
const chunks: string[] = [];
|
|
let startsAtBeginning = false;
|
|
for (let i = timeline.length - 1; i >= 0; i--) {
|
|
const item = timeline[i];
|
|
if (item.type !== "assistant_message") {
|
|
if (chunks.length) {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
chunks.push(item.text);
|
|
startsAtBeginning = i === 0;
|
|
}
|
|
|
|
if (!chunks.length) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
text: chunks.reverse().join(""),
|
|
startsAtBeginning,
|
|
};
|
|
}
|
|
|
|
private async getLastAssistantMessageFromStores(agentId: string): Promise<string | null> {
|
|
const liveTimeline = this.timelineStore.getItems(agentId);
|
|
const liveSegment = this.getLastAssistantMessageSegmentFromTimeline(liveTimeline);
|
|
if (!this.durableTimelineStore) {
|
|
return liveSegment?.text ?? null;
|
|
}
|
|
|
|
if (!liveSegment) {
|
|
return await this.durableTimelineStore.getLastAssistantMessage(agentId);
|
|
}
|
|
|
|
if (!liveSegment.startsAtBeginning) {
|
|
return liveSegment.text;
|
|
}
|
|
|
|
const lastDurableItem = await this.durableTimelineStore.getLastItem(agentId);
|
|
if (lastDurableItem?.type !== "assistant_message") {
|
|
return liveSegment.text;
|
|
}
|
|
|
|
const durableMessage = await this.durableTimelineStore.getLastAssistantMessage(agentId);
|
|
return durableMessage ? `${durableMessage}${liveSegment.text}` : liveSegment.text;
|
|
}
|
|
|
|
private async getLastItemFromStores(agentId: string): Promise<AgentTimelineItem | null> {
|
|
const lastLiveItem = this.timelineStore.getLastItem(agentId);
|
|
if (lastLiveItem) {
|
|
return lastLiveItem;
|
|
}
|
|
if (!this.durableTimelineStore) {
|
|
return null;
|
|
}
|
|
return await this.durableTimelineStore.getLastItem(agentId);
|
|
}
|
|
|
|
private async hasCommittedUserMessageFromStores(
|
|
agentId: string,
|
|
options: { messageId: string; text: string },
|
|
): Promise<boolean> {
|
|
if (this.timelineStore.hasCommittedUserMessage(agentId, options)) {
|
|
return true;
|
|
}
|
|
if (!this.durableTimelineStore) {
|
|
return false;
|
|
}
|
|
return await this.durableTimelineStore.hasCommittedUserMessage(agentId, options);
|
|
}
|
|
|
|
async waitForAgentEvent(
|
|
agentId: string,
|
|
options?: WaitForAgentOptions,
|
|
): Promise<WaitForAgentResult> {
|
|
const snapshot = this.getAgent(agentId);
|
|
if (!snapshot) {
|
|
throw new Error(`Agent ${agentId} not found`);
|
|
}
|
|
|
|
const hasForegroundTurn =
|
|
Boolean(snapshot.activeForegroundTurnId) || this.hasPendingForegroundRun(agentId);
|
|
|
|
const immediatePermission = this.peekPendingPermission(snapshot);
|
|
if (immediatePermission) {
|
|
return {
|
|
status: snapshot.lifecycle,
|
|
permission: immediatePermission,
|
|
lastMessage: await this.getLastAssistantMessage(agentId),
|
|
};
|
|
}
|
|
|
|
const initialStatus = snapshot.lifecycle;
|
|
const initialBusy = isAgentBusy(initialStatus) || hasForegroundTurn;
|
|
const waitForActive = options?.waitForActive ?? false;
|
|
if (!waitForActive && !initialBusy) {
|
|
return {
|
|
status: initialStatus,
|
|
permission: null,
|
|
lastMessage: await this.getLastAssistantMessage(agentId),
|
|
};
|
|
}
|
|
if (waitForActive && !initialBusy && !hasForegroundTurn) {
|
|
return {
|
|
status: initialStatus,
|
|
permission: null,
|
|
lastMessage: await this.getLastAssistantMessage(agentId),
|
|
};
|
|
}
|
|
|
|
if (options?.signal?.aborted) {
|
|
throw createAbortError(options.signal, "wait_for_agent aborted");
|
|
}
|
|
|
|
return await new Promise<WaitForAgentResult>((resolve, reject) => {
|
|
// Bug #1 Fix: Check abort signal AGAIN inside Promise constructor
|
|
// to avoid race condition between pre-Promise check and abort listener registration
|
|
if (options?.signal?.aborted) {
|
|
reject(createAbortError(options.signal, "wait_for_agent aborted"));
|
|
return;
|
|
}
|
|
|
|
let currentStatus: AgentLifecycleStatus = initialStatus;
|
|
let hasStarted = initialBusy || hasForegroundTurn;
|
|
let terminalStatusOverride: AgentLifecycleStatus | null = null;
|
|
let finished = false;
|
|
|
|
// Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them
|
|
let unsubscribe: (() => void) | null = null;
|
|
let abortHandler: (() => void) | null = null;
|
|
|
|
const cleanup = () => {
|
|
// Clean up subscription
|
|
if (unsubscribe) {
|
|
try {
|
|
unsubscribe();
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
unsubscribe = null;
|
|
}
|
|
|
|
// Clean up abort listener
|
|
if (abortHandler && options?.signal) {
|
|
try {
|
|
options.signal.removeEventListener("abort", abortHandler);
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
abortHandler = null;
|
|
}
|
|
};
|
|
|
|
const finish = (permission: AgentPermissionRequest | null) => {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
finished = true;
|
|
cleanup();
|
|
void this.getLastAssistantMessage(agentId)
|
|
.then((lastMessage) => {
|
|
resolve({
|
|
status: currentStatus,
|
|
permission,
|
|
lastMessage,
|
|
});
|
|
})
|
|
.catch(reject);
|
|
};
|
|
|
|
// Bug #3 Fix: Set up abort handler BEFORE subscription
|
|
// to ensure cleanup handlers exist before callback can fire
|
|
if (options?.signal) {
|
|
abortHandler = () => {
|
|
cleanup();
|
|
reject(createAbortError(options.signal, "wait_for_agent aborted"));
|
|
};
|
|
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
}
|
|
|
|
// Bug #3 Fix: Now subscribe with cleanup handlers already in place
|
|
// This prevents race condition if callback fires synchronously with replayState: true
|
|
unsubscribe = this.subscribe(
|
|
(event) => {
|
|
if (event.type === "agent_state") {
|
|
currentStatus = event.agent.lifecycle;
|
|
const pending = this.peekPendingPermission(event.agent);
|
|
if (pending) {
|
|
finish(pending);
|
|
return;
|
|
}
|
|
if (isAgentBusy(event.agent.lifecycle)) {
|
|
hasStarted = true;
|
|
return;
|
|
}
|
|
if (!waitForActive || hasStarted) {
|
|
if (terminalStatusOverride) {
|
|
currentStatus = terminalStatusOverride;
|
|
}
|
|
finish(null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.type === "agent_stream") {
|
|
if (event.event.type === "permission_requested") {
|
|
finish(event.event.request);
|
|
return;
|
|
}
|
|
if (event.event.type === "turn_failed") {
|
|
hasStarted = true;
|
|
terminalStatusOverride = "error";
|
|
return;
|
|
}
|
|
if (event.event.type === "turn_completed") {
|
|
hasStarted = true;
|
|
}
|
|
if (event.event.type === "turn_canceled") {
|
|
hasStarted = true;
|
|
}
|
|
}
|
|
},
|
|
{ agentId, replayState: true },
|
|
);
|
|
});
|
|
}
|
|
|
|
private async registerSession(
|
|
session: AgentSession,
|
|
config: AgentSessionConfig,
|
|
agentId: string,
|
|
options?: {
|
|
workspaceId?: number;
|
|
createdAt?: Date;
|
|
updatedAt?: Date;
|
|
lastUserMessageAt?: Date | null;
|
|
labels?: Record<string, string>;
|
|
timeline?: AgentTimelineItem[];
|
|
timelineRows?: AgentTimelineRow[];
|
|
timelineNextSeq?: number;
|
|
provisionalAssistantText?: string | null;
|
|
historyPrimed?: boolean;
|
|
lastUsage?: AgentUsage;
|
|
lastError?: string;
|
|
attention?: AttentionState;
|
|
},
|
|
): Promise<ManagedAgent> {
|
|
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
|
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 explicitTimelineSeed: SeedAgentTimelineOptions | null =
|
|
options?.timeline?.length ||
|
|
options?.timelineRows?.length ||
|
|
options?.timelineNextSeq !== undefined
|
|
? {
|
|
items: options?.timeline,
|
|
rows: options?.timelineRows,
|
|
nextSeq: options?.timelineNextSeq,
|
|
timestamp: (options?.updatedAt ?? options?.createdAt ?? now).toISOString(),
|
|
}
|
|
: null;
|
|
const shouldSeedFromDurable =
|
|
!explicitTimelineSeed &&
|
|
!this.timelineStore.has(resolvedAgentId) &&
|
|
this.durableTimelineStore !== undefined;
|
|
const durableTimelineSeed = shouldSeedFromDurable
|
|
? await this.loadCommittedTimelineSeed(resolvedAgentId, now)
|
|
: null;
|
|
const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed;
|
|
if (timelineSeed || !this.timelineStore.has(resolvedAgentId)) {
|
|
this.timelineStore.initialize(resolvedAgentId, timelineSeed ?? { timestamp: now.toISOString() });
|
|
}
|
|
if (options?.timelineRows?.length) {
|
|
this.enqueueDurableTimelineBulkInsert(resolvedAgentId, options.timelineRows);
|
|
}
|
|
|
|
const managed = {
|
|
id: resolvedAgentId,
|
|
provider: config.provider,
|
|
cwd: config.cwd,
|
|
terminal: false,
|
|
session,
|
|
capabilities: session.capabilities,
|
|
config,
|
|
runtimeInfo: undefined,
|
|
lifecycle: "initializing",
|
|
createdAt: options?.createdAt ?? now,
|
|
updatedAt: options?.updatedAt ?? now,
|
|
availableModes: [],
|
|
currentModeId: null,
|
|
pendingPermissions: new Map<string, AgentPermissionRequest>(),
|
|
pendingReplacement: false,
|
|
activeForegroundTurnId: null,
|
|
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
|
|
unsubscribeSession: null,
|
|
provisionalAssistantText: options?.provisionalAssistantText ?? null,
|
|
persistence: attachPersistenceCwd(session.describePersistence(), config.cwd),
|
|
historyPrimed: options?.historyPrimed ?? shouldSeedFromDurable,
|
|
lastUserMessageAt: options?.lastUserMessageAt ?? null,
|
|
lastUsage: options?.lastUsage,
|
|
lastError: options?.lastError,
|
|
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 ?? {},
|
|
} as ActiveManagedAgent;
|
|
|
|
this.agents.set(resolvedAgentId, managed);
|
|
// Initialize previousStatus to track transitions
|
|
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
|
|
await this.refreshRuntimeInfo(managed);
|
|
await this.persistSnapshot(managed, {
|
|
workspaceId: options?.workspaceId,
|
|
title: initialPersistedTitle,
|
|
});
|
|
this.emitState(managed, { persist: false });
|
|
|
|
await this.refreshSessionState(managed);
|
|
managed.lifecycle = "idle";
|
|
await this.persistSnapshot(managed, { workspaceId: options?.workspaceId });
|
|
this.emitState(managed, { persist: false });
|
|
this.subscribeToSession(managed);
|
|
return { ...managed };
|
|
}
|
|
|
|
private async loadCommittedTimelineSeed(
|
|
agentId: string,
|
|
now: Date,
|
|
): Promise<SeedAgentTimelineOptions> {
|
|
if (!this.durableTimelineStore) {
|
|
return { timestamp: now.toISOString() };
|
|
}
|
|
|
|
return {
|
|
nextSeq: (await this.durableTimelineStore.getLatestCommittedSeq(agentId)) + 1,
|
|
timestamp: now.toISOString(),
|
|
};
|
|
}
|
|
|
|
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,
|
|
): ManagedAgentClosed {
|
|
this.agents.delete(agent.id);
|
|
this.previousStatuses.delete(agent.id);
|
|
if (agent.unsubscribeSession) {
|
|
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",
|
|
provider: agent.provider,
|
|
reason: cancelReason,
|
|
turnId: waiter.turnId,
|
|
});
|
|
this.settleForegroundTurnWaiter(waiter);
|
|
}
|
|
agent.foregroundTurnWaiters.clear();
|
|
this.settlePendingForegroundRun(agent.id);
|
|
return {
|
|
...agent,
|
|
lifecycle: "closed",
|
|
session: null,
|
|
activeForegroundTurnId: null,
|
|
};
|
|
}
|
|
|
|
private emitClosedAgent(agent: ManagedAgentClosed): void {
|
|
this.emitState(agent);
|
|
}
|
|
private subscribeToSession(agent: ActiveManagedAgent): void {
|
|
if (agent.unsubscribeSession) {
|
|
return;
|
|
}
|
|
const agentId = agent.id;
|
|
const unsubscribe = agent.session.subscribe((event: AgentStreamEvent) => {
|
|
this.enqueueSessionEvent(agentId, event);
|
|
});
|
|
agent.unsubscribeSession = unsubscribe;
|
|
}
|
|
|
|
private enqueueSessionEvent(agentId: string, event: AgentStreamEvent): void {
|
|
const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve();
|
|
const next = previous
|
|
.catch(() => undefined)
|
|
.then(async () => {
|
|
const current = this.agents.get(agentId);
|
|
if (!current) {
|
|
return;
|
|
}
|
|
if (current.terminal || current.session == null) {
|
|
return;
|
|
}
|
|
await this.dispatchSessionEvent(current, event);
|
|
})
|
|
.catch((err) => {
|
|
this.logger.error(
|
|
{ err, agentId, eventType: event.type },
|
|
"Failed to process session event",
|
|
);
|
|
});
|
|
|
|
this.sessionEventTails.set(agentId, next);
|
|
this.trackBackgroundTask(next);
|
|
void next.finally(() => {
|
|
if (this.sessionEventTails.get(agentId) === next) {
|
|
this.sessionEventTails.delete(agentId);
|
|
}
|
|
});
|
|
}
|
|
|
|
private async dispatchSessionEvent(
|
|
agent: ActiveManagedAgent,
|
|
event: AgentStreamEvent,
|
|
): Promise<void> {
|
|
const turnId = (event as { turnId?: string }).turnId;
|
|
const matchingWaiters =
|
|
turnId == null
|
|
? []
|
|
: Array.from(agent.foregroundTurnWaiters).filter(
|
|
(waiter) => waiter.turnId === turnId && !waiter.settled,
|
|
);
|
|
|
|
await this.handleStreamEvent(agent, event);
|
|
|
|
for (const waiter of matchingWaiters) {
|
|
waiter.callback(event);
|
|
if (isTurnTerminalEvent(event)) {
|
|
this.settleForegroundTurnWaiter(waiter);
|
|
}
|
|
}
|
|
}
|
|
|
|
private settleForegroundTurnWaiter(waiter: ForegroundTurnWaiter): void {
|
|
if (waiter.settled) {
|
|
return;
|
|
}
|
|
waiter.settled = true;
|
|
waiter.resolveSettled();
|
|
}
|
|
|
|
private createPendingForegroundRun(): PendingForegroundRun {
|
|
let resolveSettled!: () => void;
|
|
const settledPromise = new Promise<void>((resolve) => {
|
|
resolveSettled = resolve;
|
|
});
|
|
return {
|
|
token: randomUUID(),
|
|
started: false,
|
|
settled: false,
|
|
settledPromise,
|
|
resolveSettled,
|
|
};
|
|
}
|
|
|
|
private getPendingForegroundRun(agentId: string): PendingForegroundRun | null {
|
|
return this.pendingForegroundRuns.get(agentId) ?? null;
|
|
}
|
|
|
|
private hasPendingForegroundRun(agentId: string): boolean {
|
|
return this.pendingForegroundRuns.has(agentId);
|
|
}
|
|
|
|
private settlePendingForegroundRun(agentId: string, token?: string): void {
|
|
const pendingRun = this.pendingForegroundRuns.get(agentId);
|
|
if (!pendingRun) {
|
|
return;
|
|
}
|
|
if (token && pendingRun.token !== token) {
|
|
return;
|
|
}
|
|
|
|
this.pendingForegroundRuns.delete(agentId);
|
|
if (pendingRun.settled) {
|
|
return;
|
|
}
|
|
pendingRun.settled = true;
|
|
pendingRun.resolveSettled();
|
|
}
|
|
|
|
private async resolveInitialPersistedTitle(
|
|
agentId: string,
|
|
config: AgentSessionConfig,
|
|
): Promise<string | null> {
|
|
const existing = await this.registry?.get(agentId);
|
|
if (existing) {
|
|
return existing.title ?? null;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(config, "title")) {
|
|
return config.title ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async persistSnapshot(
|
|
agent: ManagedAgent,
|
|
options?: { workspaceId?: number; title?: string | null; internal?: boolean },
|
|
): Promise<void> {
|
|
if (!this.registry) {
|
|
return;
|
|
}
|
|
// Don't persist internal agents - they're ephemeral system tasks
|
|
if (agent.internal) {
|
|
return;
|
|
}
|
|
if (options?.workspaceId !== undefined) {
|
|
await this.registry.applySnapshot(agent, options.workspaceId, options);
|
|
return;
|
|
}
|
|
await this.registry.applySnapshot(agent, options);
|
|
}
|
|
|
|
private requireRegistry(): AgentSnapshotStore {
|
|
if (!this.registry) {
|
|
throw new Error("Agent storage unavailable");
|
|
}
|
|
return this.registry;
|
|
}
|
|
|
|
private async refreshSessionState(agent: ActiveManagedAgent): Promise<void> {
|
|
try {
|
|
const modes = await agent.session.getAvailableModes();
|
|
agent.availableModes = modes;
|
|
} catch {
|
|
agent.availableModes = [];
|
|
}
|
|
|
|
try {
|
|
agent.currentModeId = await agent.session.getCurrentMode();
|
|
} catch {
|
|
agent.currentModeId = null;
|
|
}
|
|
|
|
try {
|
|
const pending = agent.session.getPendingPermissions();
|
|
agent.pendingPermissions = new Map(pending.map((request) => [request.id, request]));
|
|
} catch {
|
|
agent.pendingPermissions.clear();
|
|
}
|
|
|
|
await this.refreshRuntimeInfo(agent);
|
|
}
|
|
|
|
private async refreshRuntimeInfo(agent: ActiveManagedAgent): Promise<void> {
|
|
try {
|
|
const newInfo = await agent.session.getRuntimeInfo();
|
|
const changed =
|
|
newInfo.model !== agent.runtimeInfo?.model ||
|
|
newInfo.thinkingOptionId !== agent.runtimeInfo?.thinkingOptionId ||
|
|
newInfo.sessionId !== agent.runtimeInfo?.sessionId ||
|
|
newInfo.modeId !== agent.runtimeInfo?.modeId;
|
|
agent.runtimeInfo = newInfo;
|
|
if (!agent.persistence && newInfo.sessionId) {
|
|
agent.persistence = attachPersistenceCwd(
|
|
{ provider: agent.provider, sessionId: newInfo.sessionId },
|
|
agent.cwd,
|
|
);
|
|
}
|
|
// Emit state if runtimeInfo changed so clients get the updated model
|
|
if (changed) {
|
|
this.emitState(agent);
|
|
}
|
|
} catch {
|
|
// Keep existing runtimeInfo if refresh fails.
|
|
}
|
|
}
|
|
|
|
private async hydrateTimelineFromLegacyProviderHistory(
|
|
agent: ActiveManagedAgent,
|
|
): Promise<void> {
|
|
if (agent.historyPrimed) {
|
|
return;
|
|
}
|
|
agent.historyPrimed = true;
|
|
const canonicalUserMessagesById = this.timelineStore.getCanonicalUserMessagesById(agent.id);
|
|
const pendingTurnItems: AgentTimelineItem[] = [];
|
|
let bufferedAssistantText = "";
|
|
const flushPendingTurn = () => {
|
|
for (const item of pendingTurnItems) {
|
|
this.recordTimeline(agent.id, item);
|
|
}
|
|
pendingTurnItems.length = 0;
|
|
if (bufferedAssistantText) {
|
|
this.recordTimeline(agent.id, {
|
|
type: "assistant_message",
|
|
text: bufferedAssistantText,
|
|
});
|
|
bufferedAssistantText = "";
|
|
}
|
|
};
|
|
try {
|
|
for await (const event of agent.session.streamHistory()) {
|
|
if (event.type !== "timeline") {
|
|
if (
|
|
event.type === "turn_completed" ||
|
|
event.type === "turn_failed" ||
|
|
event.type === "turn_canceled"
|
|
) {
|
|
flushPendingTurn();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (event.item.type === "user_message") {
|
|
flushPendingTurn();
|
|
const eventMessageId = normalizeMessageId(event.item.messageId);
|
|
if (eventMessageId) {
|
|
const canonicalText = canonicalUserMessagesById.get(eventMessageId);
|
|
if (canonicalText === event.item.text) {
|
|
continue;
|
|
}
|
|
}
|
|
this.recordTimeline(agent.id, event.item);
|
|
continue;
|
|
}
|
|
|
|
if (event.item.type === "assistant_message") {
|
|
bufferedAssistantText += event.item.text;
|
|
continue;
|
|
}
|
|
|
|
if (event.item.type === "reasoning") {
|
|
continue;
|
|
}
|
|
|
|
if (event.item.type === "tool_call" && event.item.status === "running") {
|
|
continue;
|
|
}
|
|
|
|
pendingTurnItems.push(event.item);
|
|
}
|
|
flushPendingTurn();
|
|
} catch {
|
|
// ignore history failures
|
|
}
|
|
}
|
|
|
|
private async handleStreamEvent(
|
|
agent: ActiveManagedAgent,
|
|
event: AgentStreamEvent,
|
|
options?: {
|
|
fromHistory?: boolean;
|
|
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
|
},
|
|
): Promise<void> {
|
|
const eventTurnId = (event as { turnId?: string }).turnId;
|
|
const isForegroundEvent = Boolean(
|
|
eventTurnId && agent.activeForegroundTurnId === eventTurnId,
|
|
);
|
|
let suppressLiveDispatch = false;
|
|
|
|
// Only update timestamp for live events, not history replay
|
|
if (!options?.fromHistory) {
|
|
this.touchUpdatedAt(agent);
|
|
}
|
|
|
|
let timelineRow: AgentTimelineRow | null = null;
|
|
|
|
switch (event.type) {
|
|
case "thread_started":
|
|
{
|
|
const previousSessionId = agent.persistence?.sessionId ?? null;
|
|
const handle = agent.session.describePersistence();
|
|
if (handle) {
|
|
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
|
|
if (agent.persistence?.sessionId !== previousSessionId) {
|
|
this.emitState(agent);
|
|
}
|
|
}
|
|
void this.refreshRuntimeInfo(agent);
|
|
}
|
|
break;
|
|
case "timeline":
|
|
// Skip provider-replayed user_message items during history hydration.
|
|
if (options?.fromHistory && event.item.type === "user_message") {
|
|
const eventMessageId = normalizeMessageId(event.item.messageId);
|
|
if (eventMessageId) {
|
|
const canonicalText = options?.canonicalUserMessagesById?.get(eventMessageId);
|
|
if (canonicalText === event.item.text) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
// Suppress user_message echoes for the active foreground turn —
|
|
// these are already recorded by recordUserMessage().
|
|
if (
|
|
!options?.fromHistory &&
|
|
event.item.type === "user_message" &&
|
|
isForegroundEvent
|
|
) {
|
|
const eventMessageId = normalizeMessageId(event.item.messageId);
|
|
const eventText = event.item.text;
|
|
if (eventMessageId) {
|
|
if (
|
|
await this.hasCommittedUserMessageFromStores(agent.id, {
|
|
messageId: eventMessageId,
|
|
text: eventText,
|
|
})
|
|
) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (event.item.type === "assistant_message") {
|
|
agent.provisionalAssistantText = `${agent.provisionalAssistantText ?? ""}${event.item.text}`;
|
|
suppressLiveDispatch = true;
|
|
break;
|
|
}
|
|
if (event.item.type === "reasoning") {
|
|
break;
|
|
}
|
|
if (event.item.type === "tool_call" && event.item.status === "running") {
|
|
break;
|
|
}
|
|
timelineRow = this.recordTimeline(agent.id, event.item);
|
|
if (!options?.fromHistory && event.item.type === "user_message") {
|
|
agent.lastUserMessageAt = new Date();
|
|
this.emitState(agent);
|
|
}
|
|
break;
|
|
case "turn_completed":
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
eventTurnId,
|
|
},
|
|
"handleStreamEvent: turn_completed",
|
|
);
|
|
if (agent.provisionalAssistantText) {
|
|
const item: AgentTimelineItem = {
|
|
type: "assistant_message",
|
|
text: agent.provisionalAssistantText,
|
|
};
|
|
timelineRow = this.recordTimeline(agent.id, item);
|
|
if (!options?.fromHistory) {
|
|
this.dispatchStream(
|
|
agent.id,
|
|
{
|
|
type: "timeline",
|
|
item,
|
|
provider: event.provider,
|
|
},
|
|
{
|
|
seq: timelineRow.seq,
|
|
},
|
|
);
|
|
}
|
|
agent.provisionalAssistantText = null;
|
|
}
|
|
agent.lastUsage = event.usage;
|
|
agent.lastError = undefined;
|
|
// For autonomous turns (not foreground), transition to idle
|
|
// unless a replacement is pending (avoid idle flash during replace)
|
|
if (!isForegroundEvent && agent.lifecycle !== "idle" && !agent.pendingReplacement) {
|
|
(agent as ActiveManagedAgent).lifecycle = "idle";
|
|
this.emitState(agent);
|
|
}
|
|
void this.refreshRuntimeInfo(agent);
|
|
break;
|
|
case "turn_failed":
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
eventTurnId,
|
|
error: event.error,
|
|
code: event.code,
|
|
diagnostic: event.diagnostic,
|
|
},
|
|
"handleStreamEvent: turn_failed",
|
|
);
|
|
agent.provisionalAssistantText = null;
|
|
// For autonomous turns, set error state directly
|
|
if (!isForegroundEvent) {
|
|
agent.lifecycle = "error";
|
|
}
|
|
agent.lastError = event.error;
|
|
await this.appendSystemErrorTimelineMessage(
|
|
agent,
|
|
event.provider,
|
|
this.formatTurnFailedMessage(event),
|
|
options,
|
|
);
|
|
for (const [requestId] of agent.pendingPermissions) {
|
|
agent.pendingPermissions.delete(requestId);
|
|
if (!options?.fromHistory) {
|
|
this.dispatchStream(agent.id, {
|
|
type: "permission_resolved",
|
|
provider: event.provider,
|
|
requestId,
|
|
resolution: { behavior: "deny", message: "Turn failed" },
|
|
});
|
|
}
|
|
}
|
|
if (!isForegroundEvent) {
|
|
this.emitState(agent);
|
|
}
|
|
break;
|
|
case "turn_canceled":
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
eventTurnId,
|
|
},
|
|
"handleStreamEvent: turn_canceled",
|
|
);
|
|
agent.provisionalAssistantText = null;
|
|
// For autonomous turns, transition to idle
|
|
// unless a replacement is pending (avoid idle flash during replace)
|
|
if (!isForegroundEvent && !agent.pendingReplacement) {
|
|
(agent as ActiveManagedAgent).lifecycle = "idle";
|
|
}
|
|
agent.lastError = undefined;
|
|
for (const [requestId] of agent.pendingPermissions) {
|
|
agent.pendingPermissions.delete(requestId);
|
|
if (!options?.fromHistory) {
|
|
this.dispatchStream(agent.id, {
|
|
type: "permission_resolved",
|
|
provider: event.provider,
|
|
requestId,
|
|
resolution: { behavior: "deny", message: "Interrupted" },
|
|
});
|
|
}
|
|
}
|
|
if (!isForegroundEvent) {
|
|
this.emitState(agent);
|
|
}
|
|
break;
|
|
case "turn_started":
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
eventTurnId,
|
|
},
|
|
"handleStreamEvent: turn_started",
|
|
);
|
|
agent.provisionalAssistantText = null;
|
|
// For autonomous turn_started (no foreground match), set running
|
|
if (!isForegroundEvent) {
|
|
(agent as ActiveManagedAgent).lifecycle = "running";
|
|
this.emitState(agent);
|
|
}
|
|
break;
|
|
case "permission_requested":
|
|
{
|
|
const hadPendingPermissions = agent.pendingPermissions.size > 0;
|
|
agent.pendingPermissions.set(event.request.id, event.request);
|
|
if (!hadPendingPermissions && !agent.internal) {
|
|
this.broadcastAgentAttention(agent, "permission");
|
|
}
|
|
}
|
|
this.emitState(agent);
|
|
break;
|
|
case "permission_resolved":
|
|
agent.pendingPermissions.delete(event.requestId);
|
|
this.emitState(agent);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
|
|
if (!options?.fromHistory && isForegroundEvent && isTurnTerminalEvent(event)) {
|
|
this.finalizeForegroundTurn(agent);
|
|
}
|
|
|
|
// Skip dispatching individual stream events during history replay.
|
|
if (!options?.fromHistory && !suppressLiveDispatch) {
|
|
this.dispatchStream(
|
|
agent.id,
|
|
event,
|
|
timelineRow
|
|
? {
|
|
seq: timelineRow.seq,
|
|
}
|
|
: undefined,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async appendSystemErrorTimelineMessage(
|
|
agent: ActiveManagedAgent,
|
|
provider: AgentProvider,
|
|
message: string,
|
|
options?: {
|
|
fromHistory?: boolean;
|
|
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
|
},
|
|
): Promise<void> {
|
|
if (options?.fromHistory) {
|
|
return;
|
|
}
|
|
|
|
const normalized = message.trim();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
|
|
const text = `${SYSTEM_ERROR_PREFIX} ${normalized}`;
|
|
const lastItem = await this.getLastItemFromStores(agent.id);
|
|
if (lastItem?.type === "assistant_message" && lastItem.text === text) {
|
|
return;
|
|
}
|
|
|
|
const item: AgentTimelineItem = { type: "assistant_message", text };
|
|
const row = this.recordTimeline(agent.id, item);
|
|
this.dispatchStream(
|
|
agent.id,
|
|
{
|
|
type: "timeline",
|
|
item,
|
|
provider,
|
|
},
|
|
{
|
|
seq: row.seq,
|
|
},
|
|
);
|
|
}
|
|
|
|
private formatTurnFailedMessage(
|
|
event: Extract<AgentStreamEvent, { type: "turn_failed" }>,
|
|
): string {
|
|
const base = event.error.trim();
|
|
const parts = [base.length > 0 ? base : "Provider run failed"];
|
|
const code = event.code?.trim();
|
|
if (code) {
|
|
parts.push(`code: ${code}`);
|
|
}
|
|
const diagnostic = event.diagnostic?.trim();
|
|
if (diagnostic && diagnostic !== base) {
|
|
parts.push(diagnostic);
|
|
}
|
|
return parts.join("\n\n");
|
|
}
|
|
|
|
private recordTimeline(agentId: string, item: AgentTimelineItem): AgentTimelineRow {
|
|
const row = this.timelineStore.append(agentId, item);
|
|
this.enqueueDurableTimelineAppend(agentId, row);
|
|
return row;
|
|
}
|
|
|
|
private emitState(agent: ManagedAgent, options?: { persist?: boolean }): void {
|
|
// Keep attention as an edge-triggered unread signal, not a level signal.
|
|
this.checkAndSetAttention(agent);
|
|
if (options?.persist !== false) {
|
|
this.enqueueBackgroundPersist(agent);
|
|
}
|
|
|
|
this.dispatch({
|
|
type: "agent_state",
|
|
agent: { ...agent },
|
|
});
|
|
}
|
|
|
|
private checkAndSetAttention(agent: ManagedAgent): void {
|
|
const previousStatus = this.previousStatuses.get(agent.id);
|
|
const currentStatus = agent.lifecycle;
|
|
|
|
// Track the new status
|
|
this.previousStatuses.set(agent.id, currentStatus);
|
|
|
|
// Skip attention tracking for internal agents
|
|
if (agent.internal) {
|
|
return;
|
|
}
|
|
|
|
// Skip if already requires attention
|
|
if (agent.attention.requiresAttention) {
|
|
return;
|
|
}
|
|
|
|
// Check if agent transitioned from running to idle (finished)
|
|
if (previousStatus === "running" && currentStatus === "idle") {
|
|
agent.attention = {
|
|
requiresAttention: true,
|
|
attentionReason: "finished",
|
|
attentionTimestamp: new Date(),
|
|
};
|
|
this.broadcastAgentAttention(agent, "finished");
|
|
return;
|
|
}
|
|
|
|
// Check if agent entered error state
|
|
if (previousStatus !== "error" && currentStatus === "error") {
|
|
agent.attention = {
|
|
requiresAttention: true,
|
|
attentionReason: "error",
|
|
attentionTimestamp: new Date(),
|
|
};
|
|
this.broadcastAgentAttention(agent, "error");
|
|
return;
|
|
}
|
|
}
|
|
|
|
private enqueueBackgroundPersist(agent: ManagedAgent): void {
|
|
const task = this.persistSnapshot(agent).catch((err) => {
|
|
this.logger.error({ err, agentId: agent.id }, "Failed to persist agent snapshot");
|
|
});
|
|
this.trackBackgroundTask(task);
|
|
}
|
|
|
|
private enqueueDurableTimelineAppend(agentId: string, row: AgentTimelineRow): void {
|
|
if (!this.durableTimelineStore) {
|
|
return;
|
|
}
|
|
const task = this.durableTimelineStore
|
|
.appendCommitted(agentId, row.item, { timestamp: row.timestamp })
|
|
.then(() => undefined)
|
|
.catch((err) => {
|
|
this.logger.error(
|
|
{ err, agentId, seq: row.seq, itemType: row.item.type },
|
|
"Failed to append timeline row to durable store",
|
|
);
|
|
});
|
|
this.trackBackgroundTask(task);
|
|
}
|
|
|
|
private enqueueDurableTimelineBulkInsert(
|
|
agentId: string,
|
|
rows: readonly AgentTimelineRow[],
|
|
): void {
|
|
if (!this.durableTimelineStore || rows.length === 0) {
|
|
return;
|
|
}
|
|
const task = this.durableTimelineStore.bulkInsert(agentId, rows).catch((err) => {
|
|
this.logger.error(
|
|
{ err, agentId, rowCount: rows.length },
|
|
"Failed to seed durable timeline store",
|
|
);
|
|
});
|
|
this.trackBackgroundTask(task);
|
|
}
|
|
|
|
private trackBackgroundTask(task: Promise<void>): void {
|
|
this.backgroundTasks.add(task);
|
|
void task.finally(() => {
|
|
this.backgroundTasks.delete(task);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Flush any background persistence work (best-effort).
|
|
* Used by daemon shutdown paths to avoid unhandled rejections after cleanup.
|
|
*/
|
|
async flush(): Promise<void> {
|
|
// Drain tasks, including tasks spawned while awaiting.
|
|
while (this.backgroundTasks.size > 0) {
|
|
const pending = Array.from(this.backgroundTasks);
|
|
await Promise.allSettled(pending);
|
|
}
|
|
}
|
|
|
|
private broadcastAgentAttention(
|
|
agent: ManagedAgent,
|
|
reason: "finished" | "error" | "permission",
|
|
): void {
|
|
this.onAgentAttention?.({
|
|
agentId: agent.id,
|
|
provider: agent.provider,
|
|
reason,
|
|
});
|
|
}
|
|
|
|
private dispatchStream(
|
|
agentId: string,
|
|
event: AgentStreamEvent,
|
|
metadata?: { seq?: number },
|
|
): void {
|
|
this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
|
|
}
|
|
|
|
private dispatch(event: AgentManagerEvent): void {
|
|
for (const subscriber of this.subscribers) {
|
|
if (
|
|
subscriber.agentId &&
|
|
event.type === "agent_stream" &&
|
|
subscriber.agentId !== event.agentId
|
|
) {
|
|
continue;
|
|
}
|
|
if (
|
|
subscriber.agentId &&
|
|
event.type === "agent_state" &&
|
|
subscriber.agentId !== event.agent.id
|
|
) {
|
|
continue;
|
|
}
|
|
// Skip internal agents for global subscribers (those without a specific agentId)
|
|
if (!subscriber.agentId) {
|
|
if (event.type === "agent_state" && event.agent.internal) {
|
|
continue;
|
|
}
|
|
if (event.type === "agent_stream") {
|
|
const agent = this.agents.get(event.agentId);
|
|
if (agent?.internal) {
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
subscriber.callback(event);
|
|
}
|
|
}
|
|
|
|
private async normalizeConfig(
|
|
config: AgentSessionConfig,
|
|
): Promise<AgentSessionConfig> {
|
|
const normalized: AgentSessionConfig = { ...config };
|
|
|
|
// Always resolve cwd to absolute path for consistent history file lookup
|
|
if (normalized.cwd) {
|
|
normalized.cwd = resolve(normalized.cwd);
|
|
try {
|
|
const cwdStats = await stat(normalized.cwd);
|
|
if (!cwdStats.isDirectory()) {
|
|
throw new Error(`Working directory is not a directory: ${normalized.cwd}`);
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
error instanceof Error &&
|
|
"code" in error &&
|
|
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
) {
|
|
throw new Error(`Working directory does not exist: ${normalized.cwd}`);
|
|
}
|
|
if (error instanceof Error) {
|
|
throw error;
|
|
}
|
|
throw new Error(`Failed to access working directory: ${normalized.cwd}`);
|
|
}
|
|
}
|
|
|
|
if (typeof normalized.model === "string") {
|
|
const trimmed = normalized.model.trim();
|
|
normalized.model = trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
private buildLaunchContext(agentId: string): AgentLaunchContext {
|
|
return {
|
|
env: {
|
|
PASEO_AGENT_ID: agentId,
|
|
},
|
|
};
|
|
}
|
|
|
|
private requireClient(provider: AgentProvider): AgentClient {
|
|
const client = this.clients.get(provider);
|
|
if (!client) {
|
|
throw new Error(`No client registered for provider '${provider}'`);
|
|
}
|
|
return client;
|
|
}
|
|
|
|
private requireAgent(id: string): LiveManagedAgent {
|
|
const normalizedId = validateAgentId(id, "requireAgent");
|
|
const agent = this.agents.get(normalizedId);
|
|
if (!agent) {
|
|
throw new Error(`Unknown agent '${normalizedId}'`);
|
|
}
|
|
return agent;
|
|
}
|
|
|
|
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`);
|
|
}
|
|
return agent;
|
|
}
|
|
|
|
}
|