Files
paseo/packages/server/src/server/agent/agent-manager.ts
T
0009a7e717 feat(workspaces): add background visibility (#5039)
* feat(server): let clients create internal agents

The daemon's own branch-name and commit-message generators run as
internal agents: never persisted, listed, flagged, announced, or seen by
plugin lifecycle hooks. Nothing outside the daemon could ask for one,
because the wire config schema strips `internal` and the agent.create
transform re-pins it. A plugin that runs a one-turn helper per event
therefore left an archived session in History every time.

Add an optional `internal` flag to `create_agent_request` (inherited by
`agent.create.request`), copy it onto the config in the session's create
path, and skip provider session persistence for it like the generators
do. The client SDK forwards the flag on both creation paths and gates it
once on `server_info.features.internalAgents`.

Two daemon paths assumed every agent is public and had to follow:
`resolveAgentIdentifier` now accepts a live agent by exact id, since the
id set it matched against came from listings that exclude internal
agents and left `wait_for_finish` answering "Agent not found"; and
archiving an internal agent now closes it without writing the record
that `registry.applySnapshot` used to leave behind, so `autoArchive`
does not accumulate hidden files on disk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(server): keep internal agents whole through failure, archive, and wait

Review follow-ups for internal agents:

- A create that fails after the runtime is registered now closes the
  internal agent before rethrowing, so a hidden runtime cannot outlive
  the failed request. Public agents keep today's behaviour.
- Archiving an internal agent cascades to attached children like any
  parent. cascadeArchiveChildren takes the parent record instead of
  reading it back from storage, where an internal agent never is.
- The archived snapshot and last message of an internal agent stay
  readable by exact id for ten minutes, so create-then-waitForFinish
  returns the finished turn whether the wait lands before or after an
  auto-archive. Storage plays that role for public agents.
- The internal-agent e2e scenarios live in the existing
  create_agent_request lifecycle suite rather than a new file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(server): cascade to live internal children and keep a failed internal create's worktree until it closes

Internal children never reach storage, so the parent archive cascade scans
live agents for them as well. A failed internal create now clears the
created id only after the runtime is archived, so worktree cleanup cannot
delete the directory under a runtime that would not close. Stored agent
payloads carry lastError, so a retained failed helper reports its error
through wait_for_finish instead of the generic message.

* feat(cli,mcp): expose internal agents and document --no-wait

paseo run gains --internal and create_agent gains internal, both forwarded
to the create request. --no-wait becomes the documented spelling for
returning immediately, matching paseo send; --background and --detach stay
as hidden aliases under COMPAT tags.

* test: follow the --no-wait rename and stub getRetiredInternalAgent

* feat: list internal agents on request and add internal workspaces

Internal agents stay hidden by default, but a caller can now ask for
them: `fetch_agents_request` takes `filter.includeInternal`, snapshots
carry `internal: true`, and `paseo ls --internal` shows them in an
INTERNAL column. The app never passes the flag.

Workspaces get the same flag. `workspace.create.request` with
`internal: true` persists a hidden workspace: listings, update
subscriptions, sync reads, empty-project counting, open-by-path, MCP
`list_workspaces`, and plugin workspace hooks all skip it unless the
caller opts in with `filter.includeInternal`. Every agent created inside
an internal workspace is internal and unpersisted; AgentManager applies
that from the workspace id before the plugin hook so creators cannot
opt out. Archive and shutdown enumerate internal agents so nothing
inside an internal workspace outlives it.

Surfaces: `paseo workspace create --internal`, `paseo workspace ls
--internal`, `paseo run --internal --new-workspace`, MCP
`create_workspace({ internal })`, SDK `workspaces.create({ internal })`
and `list({ filter: { includeInternal } })`. Clients gate on
`features.internalWorkspaces`.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(workspaces): add background visibility with inherited creation defaults

* fix(cli): resolve workspace callers on the selected daemon

* test(workspaces): keep caller integration coverage in server suite

* test(cli): exercise workspace callers through CLI integration harness

* test(mcp): remove obsolete execution flags from browser fixtures

---------

Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-09 09:31:50 +01:00

5510 lines
178 KiB
TypeScript

import { projectTimelineRows } from "./timeline-projection.js";
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
import { randomUUID } from "node:crypto";
import { basename, resolve } from "node:path";
import { stat } from "node:fs/promises";
import {
AGENT_LIFECYCLE_STATUSES,
type AgentLifecycleStatus,
} from "@getpaseo/protocol/agent-lifecycle";
import {
getParentAgentIdFromLabels,
hasOpenAgentTab,
isDelegatedAgent,
isOpenAgentTabLabel,
PARENT_AGENT_ID_LABEL,
} from "@getpaseo/protocol/agent-labels";
import type { Logger } from "pino";
import type { ToolPolicy } from "@getpaseo/protocol/agent-types";
import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config";
import { z } from "zod";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import {
getAgentStreamEventTurnId,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentResumePurpose,
type AgentResumeSessionOptions,
type AgentFeature,
type AgentLaunchContext,
type AgentSlashCommand,
type AgentMode,
type AgentPermissionRequest,
type AgentPermissionResponse,
type AgentPermissionResult,
type AgentPersistenceHandle,
type AgentProviderNotice,
type AgentPromptInput,
type AgentProvider,
type AgentRunOptions,
type AgentSteerOptions,
type AgentRunResult,
type AgentSession,
type AgentSessionConfig,
type SteerResult,
type AgentStreamEvent,
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type ImportedTimelineEntry,
type ImportableProviderSession,
type ListImportableSessionsOptions,
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import { toStoredAgentRecord } from "./agent-projections.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import type { AgentOwner } from "./agent-owner.js";
import {
InMemoryAgentTimelineStore,
type SeedAgentTimelineOptions,
} from "./agent-timeline-store.js";
import type {
AgentTimelineFetchOptions,
AgentTimelineFetchResult,
AgentTimelineRow,
AgentTimelineStore,
} from "./agent-timeline-store-types.js";
import {
AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
AgentStreamCoalescer,
} from "./agent-stream-coalescer.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import {
AgentRunState,
type ForegroundTurnWaiter,
type PendingForegroundRun,
} from "./agent-run-state.js";
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { projectAgentMessage } from "./agent-messages/index.js";
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js";
import type { PaseoToolCatalogFactory } from "./tools/types.js";
import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js";
import {
ProviderSubagentStore,
type ProviderSubagentDescriptor,
type ProviderSubagentStoreEvent,
} from "./provider-subagents/store.js";
import { withTimeout } from "../../utils/promise-timeout.js";
import { extractAttention } from "../persistence-hooks.js";
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
const IMPORTABLE_SESSION_LIST_TIMEOUT_MS = 90_000;
const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: false,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
};
type TimeoutResult = "completed" | "timed_out";
function submittedPromptText(prompt: AgentPromptInput): string {
if (typeof prompt === "string") {
return prompt;
}
return prompt
.flatMap((block) => (block.type === "text" && !("mimeType" in block) ? [block.text] : []))
.join("\n")
.trim();
}
export class AgentManagerShuttingDownError extends Error {
constructor() {
super("Agent manager is shutting down");
this.name = "AgentManagerShuttingDownError";
}
}
export class AgentRunCancellationError extends Error {
constructor(agentId: string, action: "reload" | "replace" | "rewind" | "stop") {
super(
`Cannot ${action} agent ${agentId} because its active run cancellation was not acknowledged`,
);
this.name = "AgentRunCancellationError";
}
}
export type AgentRunCancellationResult =
| { status: "not_running" }
| { status: "settled" }
| { status: "refused" };
/** A session that will run in a directory needs that directory to be there. */
async function assertUsableWorkingDirectory(cwd: string): Promise<void> {
try {
const stats = await stat(cwd);
if (!stats.isDirectory()) {
throw new Error(`Working directory is not a directory: ${cwd}`);
}
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
(error as NodeJS.ErrnoException).code === "ENOENT"
) {
throw new Error(`Working directory does not exist: ${cwd}`, { cause: error });
}
if (error instanceof Error) {
throw error;
}
throw new Error(`Failed to access working directory: ${cwd}`, { cause: error });
}
}
interface PreparedSessionConfig {
storedConfig: AgentSessionConfig;
launchConfig: AgentSessionConfig;
paseoToolPolicy: ProviderPaseoToolsPolicy | undefined;
}
interface NormalizeConfigOptions {
resolveDefaultModel?: boolean;
env?: Record<string, string>;
/** Defaults to interactive. A history load reads persisted state and runs nothing. */
purpose?: AgentResumePurpose;
}
interface TimeoutOptions {
operation: Promise<void>;
timeoutMs: number;
onLateError?: (error: unknown) => void;
}
function formatProviderList(providers: readonly string[]): string {
return providers.length > 0 ? providers.join(", ") : "none";
}
function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
const config: AgentSessionConfig = {
provider: record.provider,
cwd: record.cwd,
};
// lastModeId is the last live mode — it also covers provider-side switches
// that never reach record.config.modeId.
const modeId = record.lastModeId ?? record.config?.modeId;
if (modeId != null) config.modeId = modeId;
if (!record.config) {
return config;
}
if (record.config.model != null) config.model = record.config.model;
if (record.config.thinkingOptionId != null) {
config.thinkingOptionId = record.config.thinkingOptionId;
}
if (record.config.featureValues != null) {
config.featureValues = record.config.featureValues;
}
if (record.config.providerOptions != null) {
config.providerOptions = record.config.providerOptions;
}
if (record.config.toolPolicy != null) config.toolPolicy = record.config.toolPolicy;
if (record.config.systemPrompt != null) {
config.systemPrompt = record.config.systemPrompt;
}
if (record.config.mcpServers != null) config.mcpServers = record.config.mcpServers;
return stripInternalPaseoMcpServer(config);
}
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: "provider_subagent"; event: ProviderSubagentStoreEvent }
| { type: "timeline_replacement"; agentId: string; epoch: string }
| {
type: "agent_stream";
agentId: string;
event: AgentStreamEvent;
seq?: number;
epoch?: string;
timestamp?: string;
};
export type AgentSubscriber = (event: AgentManagerEvent) => void;
export interface SubscribeOptions {
agentId?: string;
replayState?: boolean;
/** Receive events for internal agents too. Off by default for global subscribers. */
includeInternal?: boolean;
}
interface HydrateTimelineOptions {
force?: boolean;
broadcast?: boolean | (() => boolean);
broadcastTimeline?: boolean;
}
export type ImportablePersistedAgentQueryOptions = ListImportableSessionsOptions & {
/**
* When set, only providers in this set are scanned, in addition to the
* built-in importable allowlist + enabled + non-derived rules.
*/
providerFilter?: Set<string>;
};
export interface ManagedImportableProviderSession extends ImportableProviderSession {
provider: AgentProvider;
}
export interface ImportableSessionProviderError {
provider: AgentProvider;
message: string;
}
export interface ManagedImportableSessionsResult {
sessions: ManagedImportableProviderSession[];
providerErrors: ImportableSessionProviderError[];
}
export type AgentAttentionCallback = (params: {
agentId: string;
provider: AgentProvider;
reason: "finished" | "error" | "permission";
}) => void;
export type AgentArchivedCallback = (agentId: string) => Promise<void> | void;
export interface ProviderAvailability {
provider: AgentProvider;
available: boolean;
error: string | null;
}
interface AgentManagerRescueTimeouts {
reloadSessionCloseMs?: number;
interruptSessionMs?: number;
}
interface ProviderEnabledFlag {
enabled: boolean;
derivedFromProviderId?: string | null;
applyToolPolicy?: (
config: AgentSessionConfig,
toolPolicy: ToolPolicy | undefined,
) => AgentSessionConfig;
}
type ProviderEnabledMap = Partial<Record<AgentProvider, ProviderEnabledFlag>>;
type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
export interface CreateAgentOptions {
labels?: Record<string, string>;
initialPrompt?: string;
env?: Record<string, string>;
persistSession?: boolean;
initialTitle?: string | null;
// undefined is an explicit decision: the agent never appears in the sidebar.
workspaceId: string | undefined;
owner?: AgentOwner;
}
export interface AgentManagerOptions {
pluginLifecycle?: PluginLifecycle;
/** An internal workspace makes every agent created inside it internal. */
clients?: ProviderClientMap;
providerDefinitions?: ProviderEnabledMap;
idFactory?: () => string;
registry?: AgentStorage;
onAgentAttention?: AgentAttentionCallback;
onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
durableTimelineStore?: AgentTimelineStore;
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
mcpAuthToken?: string;
paseoToolsEnabled?: boolean;
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined;
appendSystemPrompt?: string;
agentStreamCoalesceWindowMs?: number;
rescueTimeouts?: AgentManagerRescueTimeouts;
beforeSteerUnavailableFallback?: (input: {
agentId: string;
expectedTurnId: string;
}) => Promise<void>;
logger: Logger;
}
export type ActiveTurnSteerDispatchResult =
| { status: "inactive" | "steered" }
| { status: "replaced"; iterator: AsyncGenerator<AgentStreamEvent> };
function stripSteerOptions(options?: AgentSteerOptions): AgentRunOptions | undefined {
if (!options) return undefined;
const { clearPendingPermissions: _, ...runOptions } = options;
return runOptions;
}
export interface WaitForAgentOptions {
signal?: AbortSignal;
waitForActive?: boolean;
}
export interface WaitForAgentResult {
status: AgentLifecycleStatus;
permission: AgentPermissionRequest | null;
lastMessage: string | null;
}
export interface WaitForAgentStartOptions {
signal?: AbortSignal;
}
export type AttentionState =
| { requiresAttention: false }
| {
requiresAttention: true;
attentionReason: "finished" | "error" | "permission";
attentionTimestamp: Date;
};
function resolveInitialAttention(input: AttentionState | undefined): AttentionState {
if (input == null || !input.requiresAttention) {
return { requiresAttention: false };
}
return {
requiresAttention: true,
attentionReason: input.attentionReason,
attentionTimestamp: new Date(input.attentionTimestamp),
};
}
interface StreamEventFlags {
shouldDispatchEvent: boolean;
shouldNotifyWaiters: boolean;
}
type ActiveTurnTerminalDisposition = "closed_current" | "stale" | "untracked";
interface HandleStreamEventOptions {
fromHistory?: boolean;
}
interface ManagedAgentBase {
id: string;
provider: AgentProvider;
cwd: string;
/**
* Workspace this agent belongs to, stamped at creation. Independent of cwd:
* cwd answers "where does it run", workspaceId answers "which workspace owns it".
* Null/undefined for legacy agents created before ownership stamping.
*/
workspaceId?: string;
owner?: AgentOwner;
capabilities: AgentCapabilityFlags;
config: AgentSessionConfig;
runtimeInfo?: AgentRuntimeInfo;
createdAt: Date;
updatedAt: Date;
availableModes: AgentMode[];
features?: AgentFeature[];
currentModeId: string | null;
pendingPermissions: Map<string, AgentPermissionRequest>;
bufferedPermissionResolutions: Map<
string,
Extract<AgentStreamEvent, { type: "permission_resolved" }>
>;
inFlightPermissionResponses: Set<string>;
pendingReplacement: boolean;
persistence: AgentPersistenceHandle | null;
historyPrimed: boolean;
lastUserMessageAt: Date | null;
activeTurnId: string | null;
activeTurnStartedAt: Date | null;
lastUsage?: AgentUsage;
lastError?: string;
attention: AttentionState;
foregroundTurnWaiters: Set<ForegroundTurnWaiter>;
finalizedForegroundTurnIds: Set<string>;
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;
};
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;
};
export type ManagedAgent =
| ManagedAgentInitializing
| ManagedAgentIdle
| ManagedAgentRunning
| ManagedAgentError
| ManagedAgentClosed;
export interface AgentMetricsSnapshot {
total: number;
subscriptionCount: number;
byLifecycle: Record<string, number>;
withActiveForegroundTurn: number;
timelineStats: {
totalItems: number;
maxItemsPerAgent: number;
};
}
type ActiveManagedAgent =
| ManagedAgentInitializing
| ManagedAgentIdle
| ManagedAgentRunning
| ManagedAgentError;
type LiveManagedAgent = ActiveManagedAgent;
type AgentLabelPatch = Record<string, string | null>;
function attachManagedTurnIdentity(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
fromHistory: boolean,
): { event: AgentStreamEvent; turnId: string | undefined } {
const existingTurnId = getAgentStreamEventTurnId(event);
if (fromHistory || existingTurnId !== undefined) {
return { event, turnId: existingTurnId };
}
switch (event.type) {
case "turn_started": {
const turnId =
agent.activeForegroundTurnId ?? agent.activeTurnId ?? `autonomous-${randomUUID()}`;
return { event: { ...event, turnId }, turnId };
}
case "turn_completed":
case "turn_failed":
case "turn_canceled": {
const turnId = agent.activeForegroundTurnId ?? agent.activeTurnId ?? undefined;
return turnId ? { event: { ...event, turnId }, turnId } : { event, turnId };
}
case "timeline": {
// Live provider items belong to the foreground turn that owns their dispatch.
// Provider history deliberately keeps absent IDs because it has no daemon turn identity.
const turnId = agent.activeForegroundTurnId ?? agent.activeTurnId ?? undefined;
return turnId ? { event: { ...event, turnId }, turnId } : { event, turnId };
}
default:
return { event, turnId: undefined };
}
}
function limitAgentStreamEventContent(event: AgentStreamEvent): AgentStreamEvent {
return event.type === "timeline"
? { ...event, item: limitAgentTimelineItemContent(event.item) }
: event;
}
interface WriteLabelsResult {
record: StoredAgentRecord | null;
live: boolean;
}
interface AgentMetadataPatch {
title?: string;
labels?: AgentLabelPatch;
}
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,
},
};
}
interface SubscriptionRecord {
callback: AgentSubscriber;
agentId: string | null;
includeInternal: boolean;
}
interface SteerEventBarrier {
events: AgentStreamEvent[];
}
const BUSY_STATUSES: Set<AgentLifecycleStatus> = new Set(["initializing", "running"]);
const AgentIdSchema = z.guid();
function isAgentBusy(status: AgentLifecycleStatus): boolean {
return BUSY_STATUSES.has(status);
}
function isTurnTerminalEvent(event: AgentStreamEvent): boolean {
return (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
);
}
function abortMessage(reason: unknown, fallbackMessage: string): string {
if (typeof reason === "string") return reason;
if (reason instanceof Error) return reason.message;
return fallbackMessage;
}
function createAbortError(signal: AbortSignal | undefined, fallbackMessage: string): Error {
const message = abortMessage(signal?.reason, fallbackMessage);
return Object.assign(new Error(message), { name: "AbortError" });
}
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 applyLabelPatch(
labels: Record<string, string>,
patch: AgentLabelPatch,
): Record<string, string> {
const nextLabels = { ...labels };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete nextLabels[key];
} else {
nextLabels[key] = value;
}
}
return nextLabels;
}
function buildExplicitTimelineSeedForRegister(
now: Date,
options:
| {
timeline?: AgentTimelineItem[];
timelineRows?: AgentTimelineRow[];
timelineNextSeq?: number;
createdAt?: Date;
updatedAt?: Date;
}
| undefined,
): SeedAgentTimelineOptions | null {
const hasTimeline = Boolean(options?.timeline?.length);
const hasTimelineRows = Boolean(options?.timelineRows?.length);
const hasTimelineNextSeq = options?.timelineNextSeq !== undefined;
if (!hasTimeline && !hasTimelineRows && !hasTimelineNextSeq) {
return null;
}
return {
items: options?.timeline,
rows: options?.timelineRows,
nextSeq: options?.timelineNextSeq,
timestamp: (options?.updatedAt ?? options?.createdAt ?? now).toISOString(),
};
}
function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): AgentTimelineRow[] {
const rows: AgentTimelineRow[] = [];
for (const entry of entries) {
const item = projectAgentMessage(entry.item);
if (!item) continue;
rows.push({
seq: rows.length + 1,
timestamp: entry.timestamp ?? new Date().toISOString(),
item: limitAgentTimelineItemContent(item),
});
}
return rows;
}
function resolveImportedAgentTitle(
config: AgentSessionConfig,
timelineRows: readonly AgentTimelineRow[],
): string | null {
const initialPrompt = getFirstUserMessageTextFromRows(timelineRows);
if (!initialPrompt) {
return null;
}
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
configTitle: config.title,
initialPrompt,
});
return explicitTitle ?? provisionalTitle ?? null;
}
function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): string | null {
for (const row of rows) {
const item = row.item;
if (item.type !== "user_message") {
continue;
}
const text = item.text.trim();
if (text) {
return text;
}
}
return null;
}
function shouldDetachFromArchivedParent(
parent: Pick<StoredAgentRecord, "workspaceId">,
child: StoredAgentRecord,
): boolean {
const isCrossWorkspace =
parent.workspaceId !== undefined &&
child.workspaceId !== undefined &&
parent.workspaceId !== child.workspaceId;
return isCrossWorkspace || hasOpenAgentTab(child.labels);
}
function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatch {
const patch: AgentLabelPatch = { [PARENT_AGENT_ID_LABEL]: null };
for (const label of Object.keys(labels)) {
if (isOpenAgentTabLabel(label)) {
patch[label] = null;
}
}
return patch;
}
/**
* How long an archived internal agent stays readable by id. Storage plays this
* role for public agents; internal agents never reach it, and a one-shot helper
* can finish and auto-archive before its creator's `waitForFinish` arrives.
*/
const RETIRED_INTERNAL_AGENT_TTL_MS = 10 * 60 * 1000;
const RETIRED_INTERNAL_AGENT_LIMIT = 500;
export interface RetiredInternalAgent {
record: ArchivedStoredAgentRecord;
lastMessage: string | null;
}
export class AgentManager {
private readonly pluginLifecycle: PluginLifecycle | undefined;
private readonly retiredInternalAgents = new Map<
string,
RetiredInternalAgent & { expiresAt: number }
>();
private readonly clients = new Map<AgentProvider, AgentClient>();
private readonly providerEnabled = new Map<AgentProvider, boolean>();
private readonly providerDefinitions = new Map<AgentProvider, ProviderEnabledFlag>();
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly providerSubagents = new ProviderSubagentStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
private readonly foregroundMutationTails = new Map<string, Promise<void>>();
private readonly runs = new AgentRunState();
private readonly subscribers = new Set<SubscriptionRecord>();
private readonly idFactory: () => string;
private readonly registry?: AgentStorage;
private readonly durableTimelineStore?: AgentTimelineStore;
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly agentRegistrationTasks = new Set<Promise<void>>();
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
private readonly reloadedSessionCloses = new WeakMap<AgentSession, Promise<void>>();
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private readonly mcpAuthToken: string | null;
private paseoToolsEnabled = true;
private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null;
private readonly paseoToolPolicies = new Map<string, ProviderPaseoToolsPolicy | undefined>();
private readonly resolvePaseoToolPolicy: (
provider: AgentProvider,
) => ProviderPaseoToolsPolicy | undefined;
private appendSystemPrompt: string;
private onAgentAttention?: AgentAttentionCallback;
private onAgentArchived?: AgentArchivedCallback;
private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
private logger: Logger;
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
private readonly beforeSteerUnavailableFallback?: AgentManagerOptions["beforeSteerUnavailableFallback"];
private acceptingAgentRegistrations = true;
constructor(options: AgentManagerOptions) {
this.pluginLifecycle = options.pluginLifecycle;
this.idFactory = options?.idFactory ?? (() => randomUUID());
this.registry = options?.registry;
this.durableTimelineStore = options?.durableTimelineStore;
this.onAgentAttention = options?.onAgentAttention;
this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged;
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
this.mcpAuthToken = options?.mcpAuthToken ?? null;
this.configurePaseoTools(options);
this.resolvePaseoToolPolicy = options.resolvePaseoToolPolicy ?? (() => undefined);
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
this.rescueTimeouts = {
reloadSessionCloseMs:
options.rescueTimeouts?.reloadSessionCloseMs ?? RELOAD_SESSION_CLOSE_TIMEOUT_MS,
interruptSessionMs:
options.rescueTimeouts?.interruptSessionMs ?? INTERRUPT_SESSION_TIMEOUT_MS,
};
this.beforeSteerUnavailableFallback = options.beforeSteerUnavailableFallback;
this.agentStreamCoalescer = new AgentStreamCoalescer({
windowMs: options.agentStreamCoalesceWindowMs ?? AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
timers: { setTimeout, clearTimeout },
onFlush: ({ agentId, item, provider, turnId }) => {
const event = this.recordAndDispatchTimelineItem(agentId, item, provider, turnId);
this.notifyForegroundTurnWaiters(agentId, event);
},
});
this.updateProviderRegistry({
providerDefinitions: options.providerDefinitions ?? {},
clients: options.clients ?? {},
});
}
private configurePaseoTools(options: AgentManagerOptions): void {
this.paseoToolsEnabled = options.paseoToolsEnabled ?? true;
this.paseoToolCatalogFactory = options.paseoToolCatalogFactory ?? null;
}
registerClient(provider: AgentProvider, client: AgentClient): void {
this.clients.set(provider, client);
}
updateProviderRegistry(input: {
providerDefinitions: ProviderEnabledMap;
clients: ProviderClientMap;
retiredProviders?: readonly AgentProvider[];
}): void {
this.providerEnabled.clear();
this.providerDefinitions.clear();
for (const [provider, definition] of Object.entries(input.providerDefinitions)) {
if (definition) {
this.providerEnabled.set(provider, definition.enabled);
this.providerDefinitions.set(provider, definition);
}
}
this.clients.clear();
for (const [provider, client] of Object.entries(input.clients)) {
if (client) {
this.clients.set(provider, client);
}
}
for (const provider of input.retiredProviders ?? []) {
for (const agent of this.agents.values()) {
if (agent.provider !== provider) continue;
void this.closeAgent(agent.id).catch((error) => {
this.logger.warn(
{ err: error, agentId: agent.id, provider },
"Failed to close agent after provider retirement",
);
});
}
}
}
getRegisteredProviderIds(): AgentProvider[] {
return Array.from(this.clients.keys());
}
setAgentAttentionCallback(callback: AgentAttentionCallback): void {
this.onAgentAttention = callback;
}
setAgentArchivedCallback(callback: AgentArchivedCallback): void {
this.onAgentArchived = callback;
}
setMcpBaseUrl(url: string | null): void {
this.mcpBaseUrl = url;
}
prepareForShutdown(): void {
this.acceptingAgentRegistrations = false;
}
setPaseoToolsEnabled(enabled: boolean): void {
this.paseoToolsEnabled = enabled;
}
setPaseoToolCatalogFactory(factory: PaseoToolCatalogFactory | null): void {
this.paseoToolCatalogFactory = factory;
}
getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined {
return this.paseoToolPolicies.get(agentId);
}
/**
* Capability token the daemon's own MCP clients must present to the Agent MCP
* endpoint when a daemon password is configured. Read by the per-client
* session to authenticate its own MCP connection. Stays in the daemon — never
* sent to remote clients.
*/
getMcpAuthToken(): string | null {
return this.mcpAuthToken;
}
setAppendSystemPrompt(prompt: string | null | undefined): void {
this.appendSystemPrompt = prompt ?? "";
}
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,
subscriptionCount: this.subscribers.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;
}
private nextStoredUpdatedAt(record: StoredAgentRecord): string {
const previousMs = Date.parse(record.updatedAt);
const nowMs = Date.now();
const nextMs = nowMs > previousMs ? nowMs : previousMs + 1;
return new Date(nextMs).toISOString();
}
hasInFlightRun(agentId: string): boolean {
const agent = this.agents.get(agentId);
if (!agent) {
return false;
}
return (
agent.lifecycle === "running" ||
Boolean(agent.activeForegroundTurnId) ||
this.runs.hasRun(agentId)
);
}
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void {
const targetAgentId =
options?.agentId == null ? null : validateAgentId(options.agentId, "subscribe");
const record: SubscriptionRecord = {
callback,
agentId: targetAgentId,
includeInternal: options?.includeInternal === true,
};
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 {
// Global subscribers skip internal agents unless they opted in.
for (const agent of this.agents.values()) {
if (agent.internal && !record.includeInternal) {
continue;
}
callback({
type: "agent_state",
agent: { ...agent },
});
}
}
}
return () => {
this.subscribers.delete(record);
};
}
subscriptionCount(): number {
return this.subscribers.size;
}
listAgents(options?: { includeInternal?: boolean }): ManagedAgent[] {
return Array.from(this.agents.values())
.filter((agent) => options?.includeInternal === true || !agent.internal)
.map((agent) => Object.assign({}, agent));
}
/** Archived internal agents still readable by id; see getRetiredInternalAgent. */
listRetiredInternalAgents(): RetiredInternalAgent[] {
const now = Date.now();
const retired: RetiredInternalAgent[] = [];
for (const [agentId, entry] of this.retiredInternalAgents) {
if (entry.expiresAt <= now) {
this.retiredInternalAgents.delete(agentId);
continue;
}
retired.push({ record: entry.record, lastMessage: entry.lastMessage });
}
return retired;
}
async listImportableSessions(
options?: ImportablePersistedAgentQueryOptions,
): Promise<ManagedImportableSessionsResult> {
const providerEntries = Array.from(this.clients.entries()).filter(
([provider, client]) =>
client.capabilities.supportsSessionListing &&
!!client.listImportableSessions &&
this.isProviderImportable(provider, options?.providerFilter),
);
const providerResults = await Promise.all(
providerEntries.map(async ([provider, client]) => {
try {
const sessions = await withTimeout(
client.listImportableSessions!({
limit: options?.limit,
query: options?.query,
scanLimit: options?.scanLimit,
cwd: options?.cwd,
}),
IMPORTABLE_SESSION_LIST_TIMEOUT_MS,
`Timed out listing importable sessions for provider '${provider}' after ${IMPORTABLE_SESSION_LIST_TIMEOUT_MS}ms`,
);
return {
sessions: sessions
.filter((session) => matchesImportableSessionQuery(session, options?.query))
.map((session) => Object.assign(session, { provider })),
error: null,
};
} catch (error) {
this.logger.warn(
{ err: error, provider },
"Failed to list importable sessions for provider",
);
return {
sessions: [],
error: {
provider,
message: error instanceof Error ? error.message : String(error),
},
};
}
}),
);
const sessions = providerResults.flatMap((result) => result.sessions);
const limit = options?.limit ?? 20;
return {
sessions: sessions
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime())
.slice(0, limit),
providerErrors: providerResults.flatMap((result) => (result.error ? [result.error] : [])),
};
}
private isProviderImportable(
provider: AgentProvider,
providerFilter: Set<string> | undefined,
): boolean {
if (this.providerEnabled.get(provider) === false) {
return false;
}
if (providerFilter && !providerFilter.has(provider)) {
return false;
}
return true;
}
async listProviderAvailability(): Promise<ProviderAvailability[]> {
return Promise.all(
Array.from(this.clients.keys()).map((provider) => this.getProviderAvailability(provider)),
);
}
async getProviderAvailability(provider: AgentProvider): Promise<ProviderAvailability> {
const client = this.clients.get(provider);
if (!client) {
return {
provider,
available: false,
error: `No client registered for provider '${provider}'`,
};
}
try {
const available = await client.isAvailable();
return {
provider,
available,
error: null,
};
} 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,
};
}
}
async listDraftCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
const client = this.requireClient(normalizedConfig.provider);
if (!normalizedConfig.model) {
return [];
}
const available = await client.isAvailable();
if (!available) {
throw new Error(
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
);
}
if (client.listCommands) {
return await client.listCommands(normalizedConfig);
}
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",
);
}
}
}
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
const client = this.requireClient(normalizedConfig.provider);
if (!normalizedConfig.model && !client.listFeatures) {
return [];
}
const available = await client.isAvailable();
if (!available) {
throw new Error(
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
);
}
if (client.listFeatures) {
return await client.listFeatures(normalizedConfig);
}
const session = await client.createSession(normalizedConfig);
try {
return session.features ?? [];
} finally {
try {
await session.close();
} catch (error) {
this.logger.warn(
{ err: error, provider: normalizedConfig.provider },
"Failed to close draft feature listing session",
);
}
}
}
usageSession(id: string) {
const agent = this.agents.get(id);
return agent?.session?.usageSession?.() ?? null;
}
getAgent(id: string): ManagedAgent | null {
const agent = this.agents.get(id);
return agent ? { ...agent } : null;
}
async waitForAgentClose(agentId: string): Promise<void> {
// Loading during reload must wait for the replacement, not resume another writer.
await this.lifecycleMutationTails.get(agentId);
await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined);
}
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 projectTimelineRows({
rows: await this.durableTimelineStore.getCommittedRows(id),
mode: "projected",
}).map((entry) => Object.assign({ seq: entry.seqEnd }, entry));
}
return this.timelineStore.getRows(id);
}
fetchTimeline(id: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
this.requireAgent(id);
return this.timelineStore.fetch(id, options);
}
listProviderSubagents(parentAgentId: string): ProviderSubagentDescriptor[] {
this.requirePublicAgent(parentAgentId);
return this.providerSubagents.list(parentAgentId);
}
listProviderSubagentActivity(): ProviderSubagentDescriptor[] {
const publicParentIds = new Set(
Array.from(this.agents.values())
.filter((agent) => !agent.internal)
.map((agent) => agent.id),
);
return this.providerSubagents
.listAll()
.filter((subagent) => publicParentIds.has(subagent.parentAgentId));
}
getProviderSubagent(
parentAgentId: string,
subagentId: string,
): ProviderSubagentDescriptor | null {
this.requirePublicAgent(parentAgentId);
return this.providerSubagents.get(parentAgentId, subagentId);
}
fetchProviderSubagentTimeline(
parentAgentId: string,
subagentId: string,
options?: AgentTimelineFetchOptions,
): AgentTimelineFetchResult {
this.requirePublicAgent(parentAgentId);
return this.providerSubagents.fetchTimeline(parentAgentId, subagentId, options);
}
createAgent(
config: AgentSessionConfig,
agentId: string | undefined,
options: CreateAgentOptions,
): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(this.createAgentInternal(config, agentId, options));
}
private async createAgentInternal(
config: AgentSessionConfig,
agentId: string | undefined,
options: CreateAgentOptions,
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
if (this.pluginLifecycle && !config.internal) {
const request = await this.pluginLifecycle.before("agent.create", {
config,
env: options.env,
});
config = { ...request.config, internal: config.internal };
options = { ...options, env: request.env };
}
await this.deleteAgentState(resolvedAgentId);
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
config,
resolvedAgentId,
{ env: options?.env },
);
this.requireEnabledProvider(storedConfig.provider);
const client = await this.requireAvailableClient({
provider: storedConfig.provider,
});
this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy);
const launchContext = await this.buildLaunchContext(
resolvedAgentId,
client,
storedConfig.cwd,
paseoToolPolicy,
options?.env,
{ reason: "create", purpose: "interactive", workspaceId: options.workspaceId ?? null },
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options);
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
await this.requireExternalMcpSupport(session, storedConfig);
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
labels: options.labels,
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
owner: options.owner,
historyPrimed: true,
});
if (!agent.internal) {
this.pluginLifecycle?.emit("agent.created", {
agent: describeHookAgent({ ...agent, title: agent.config.title }),
});
}
return agent;
}
private buildCreateSessionOptions(options?: {
persistSession?: boolean;
}): AgentCreateSessionOptions | undefined {
return options?.persistSession === undefined
? undefined
: { persistSession: options.persistSession };
}
// Reconstruct an agent from provider persistence. Callers should explicitly
// hydrate timeline history after resume.
resumeAgentFromPersistence(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
agentId?: string,
options?: {
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
workspaceId?: string;
owner?: AgentOwner;
attention?: AttentionState;
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(
agentId ?? this.idFactory(),
"resumeAgentFromPersistence",
);
return this.trackAgentRegistrationOperation(
this.runLifecycleMutation(resolvedAgentId, () =>
this.resumeAgentFromPersistenceInternal(
handle,
overrides,
resolvedAgentId,
options,
resumeOptions,
),
),
);
}
private async resumeAgentFromPersistenceInternal(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
agentId?: string,
options?: {
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
workspaceId?: string;
owner?: AgentOwner;
attention?: AttentionState;
},
resumeOptions?: AgentResumeSessionOptions,
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(
agentId ?? this.idFactory(),
"resumeAgentFromPersistence",
);
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const mergedConfig = {
...metadata,
...overrides,
provider: handle.provider,
} as AgentSessionConfig;
// Decide residency from durable state inside the lifecycle lane. A loader may
// have read the record before a queued archive or restore completed. Residency is
// settled before the config is prepared, because a history load reads an archived
// agent whose working directory may be gone.
const record = this.registry ? await this.registry.get(resolvedAgentId) : null;
const currentResumeOptions = record
? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) }
: resumeOptions;
const purpose = currentResumeOptions?.purpose ?? "interactive";
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
mergedConfig,
resolvedAgentId,
{ purpose },
);
const client = this.requireClient(handle.provider);
const available = await client.isAvailable();
if (!available) {
throw new Error(
`Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`,
);
}
this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy);
const launchContext = await this.buildLaunchContext(
resolvedAgentId,
client,
storedConfig.cwd,
paseoToolPolicy,
undefined,
{
reason: "resume",
purpose,
workspaceId: options?.workspaceId ?? null,
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const session = await client.resumeSession(
handle,
providerLaunchConfig,
launchContext,
currentResumeOptions,
);
await this.requireExternalMcpSupport(session, storedConfig);
return this.registerSession(session, storedConfig, resolvedAgentId, {
...options,
persistence: handle,
restoring: true,
});
}
importProviderSession(input: {
provider: AgentProvider;
providerHandleId: string;
cwd: string;
workspaceId: string;
labels?: Record<string, string>;
}): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(this.importProviderSessionInternal(input));
}
private async importProviderSessionInternal(input: {
provider: AgentProvider;
providerHandleId: string;
cwd: string;
workspaceId: string;
labels?: Record<string, string>;
}): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession");
this.requireEnabledProvider(input.provider);
const client = await this.requireAvailableClient({ provider: input.provider });
if (!client.importSession) {
throw new Error(`Provider '${input.provider}' does not support importing sessions`);
}
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
{
provider: input.provider,
cwd: input.cwd,
},
resolvedAgentId,
);
this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy);
const launchContext = await this.buildLaunchContext(
resolvedAgentId,
client,
storedConfig.cwd,
paseoToolPolicy,
undefined,
{ reason: "import", purpose: "interactive", workspaceId: input.workspaceId },
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const imported = await client.importSession(
{
providerHandleId: input.providerHandleId,
cwd: input.cwd,
},
{ config: providerLaunchConfig, storedConfig, launchContext },
);
let handedToRegistration = false;
try {
const importedConfig = await this.normalizeConfig(
stripInternalPaseoMcpServer(imported.config),
);
const timelineRows = buildImportedTimelineRows(imported.timeline);
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
handedToRegistration = true;
const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
labels: input.labels,
workspaceId: input.workspaceId,
timelineRows,
timelineNextSeq: timelineRows.length + 1,
persistence: imported.persistence,
historyPrimed: true,
initialTitle,
publishWhenReady: true,
});
for (const event of imported.providerSubagentEvents ?? []) {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
this.dispatch({ type: "provider_subagent", event: update });
}
return agent;
} finally {
if (!handedToRegistration) {
await this.closeUnregisteredSession(imported.session);
}
}
}
// Hot-reload an active agent session with config overrides. By default the
// in-memory timeline is preserved (used for voice-mode toggles and similar
// config swaps). When `rehydrateFromDisk` is set, the timeline is wiped so a
// new epoch is minted and provider history is re-streamed — this is what the
// user-facing "Reload agent" action wants when the on-disk session was
// mutated outside Paseo.
reloadAgentSession(
agentId: string,
overrides?: Partial<AgentSessionConfig>,
options?: { rehydrateFromDisk?: boolean },
): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(
this.runLifecycleMutation(agentId, () =>
this.reloadAgentSessionInternal(agentId, overrides, options),
),
);
}
private async reloadAgentSessionInternal(
agentId: string,
overrides?: Partial<AgentSessionConfig>,
options?: { rehydrateFromDisk?: boolean },
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
let existing = this.requireSessionAgent(agentId);
if (this.hasInFlightRun(agentId)) {
await this.cancelAgentRunBefore(agentId, "reload");
existing = this.requireSessionAgent(agentId);
}
const rehydrateFromDisk = options?.rehydrateFromDisk ?? false;
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 { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
refreshConfig,
agentId,
);
const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId);
const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId);
const launchContext = await this.buildLaunchContext(
agentId,
client,
storedConfig.cwd,
paseoToolPolicy,
undefined,
{ reason: "refresh", purpose: "interactive", workspaceId: existing.workspaceId },
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
if (
Object.keys(storedConfig.mcpServers ?? {}).length > 0 &&
existing.session.capabilities.supportsMcpServers !== true
) {
throw new Error(`Provider '${provider}' does not support MCP servers`);
}
let session: AgentSession | undefined;
let closedExisting: ManagedAgentClosed | undefined;
let handedToRegistration = false;
try {
// A persisted thread can have only one writer, even when its turn is idle.
await this.closeReloadedSession(existing.session, agentId);
await this.drainSessionEvents(agentId);
this.cancelRunningProviderSubagents(agentId);
closedExisting = this.prepareAgentForClosure(existing, "agent reloaded");
await this.persistSnapshot(closedExisting);
this.assertAcceptingAgentRegistrations();
this.paseoToolPolicies.set(agentId, paseoToolPolicy);
session = handle
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
: await client.createSession(providerLaunchConfig, launchContext);
await this.requireExternalMcpSupport(session, storedConfig);
this.assertAcceptingAgentRegistrations();
if (rehydrateFromDisk) {
// Wipe the in-memory timeline so registerSession mints a new epoch and
// hydrateTimelineFromProvider re-streams the freshly read provider history.
this.timelineStore.delete(agentId);
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
}
}
// Preserve existing labels and timeline during reload.
handedToRegistration = true;
return this.registerSession(session, storedConfig, agentId, {
labels: existing.labels,
workspaceId: existing.workspaceId,
owner: existing.owner,
createdAt: existing.createdAt,
updatedAt: existing.updatedAt,
lastUserMessageAt: existing.lastUserMessageAt,
historyPrimed: rehydrateFromDisk ? false : preservedHistoryPrimed,
lastUsage: preservedLastUsage,
lastError: preservedLastError,
attention: preservedAttention,
restoring: true,
});
} catch (error) {
if (closedExisting) {
this.emitClosedAgent(closedExisting, { persist: false });
} else if (this.agents.get(agentId) === existing) {
existing.lifecycle = "error";
existing.lastError = error instanceof Error ? error.message : String(error);
this.emitState(existing);
}
throw error;
} finally {
if (!handedToRegistration) {
if (hadPreviousPaseoToolPolicy) {
this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy);
} else {
this.paseoToolPolicies.delete(agentId);
}
if (session) {
await this.closeUnregisteredSession(session);
}
}
}
}
private async closeReloadedSession(session: AgentSession, agentId: string): Promise<void> {
let operation = this.reloadedSessionCloses.get(session);
if (!operation) {
operation = session.close();
this.reloadedSessionCloses.set(session, operation);
// Keep pending closes across request timeouts; a retry must await the same release.
void operation.catch(() => this.reloadedSessionCloses.delete(session));
}
const result = await this.waitWithTimeout({
operation,
timeoutMs: this.rescueTimeouts.reloadSessionCloseMs,
onLateError: (error) => {
this.logger.warn(
{ err: error, agentId },
"Previous session close failed after refresh timeout",
);
},
});
if (result === "timed_out") {
throw new Error("Timed out closing previous session during refresh");
}
}
private async waitWithTimeout(options: TimeoutOptions): Promise<TimeoutResult> {
let didTimeOut = false;
let timer: NodeJS.Timeout | null = null;
const operation = options.operation
.then((): TimeoutResult => "completed")
.catch((error) => {
if (didTimeOut) {
options.onLateError?.(error);
return "timed_out" as const;
}
throw error;
});
try {
return await Promise.race([
operation,
new Promise<TimeoutResult>((resolvePromise) => {
timer = setTimeout(() => {
didTimeOut = true;
resolvePromise("timed_out");
}, options.timeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
/**
* The archived snapshot of an internal agent, for a while after archive.
* Null once it has expired or was never an internal agent.
*/
getRetiredInternalAgent(agentId: string): RetiredInternalAgent | null {
const entry = this.retiredInternalAgents.get(agentId);
if (!entry) {
return null;
}
if (entry.expiresAt <= Date.now()) {
this.retiredInternalAgents.delete(agentId);
return null;
}
return { record: entry.record, lastMessage: entry.lastMessage };
}
private retireInternalAgent(entry: RetiredInternalAgent): void {
const now = Date.now();
for (const [agentId, retired] of this.retiredInternalAgents) {
if (retired.expiresAt <= now) {
this.retiredInternalAgents.delete(agentId);
}
}
while (this.retiredInternalAgents.size >= RETIRED_INTERNAL_AGENT_LIMIT) {
const oldest = this.retiredInternalAgents.keys().next().value;
if (oldest === undefined) {
break;
}
this.retiredInternalAgents.delete(oldest);
}
this.retiredInternalAgents.set(entry.record.id, {
...entry,
expiresAt: now + RETIRED_INTERNAL_AGENT_TTL_MS,
});
}
closeAgent(agentId: string): Promise<void> {
const existing = this.inFlightAgentCloses.get(agentId);
if (existing) {
return existing;
}
const close = this.runLifecycleMutation(agentId, async () => {
// A preceding reload or archive may already have closed the durable agent.
if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId);
});
this.inFlightAgentCloses.set(agentId, close);
const clearClose = () => {
if (this.inFlightAgentCloses.get(agentId) === close) {
this.inFlightAgentCloses.delete(agentId);
}
};
void close.then(clearClose, clearClose);
return close;
}
private async closeAgentRuntime(agentId: string): Promise<void> {
const agent = this.requireAgent(agentId);
this.logger.trace(
{
agentId,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: agent.activeForegroundTurnId ?? undefined,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
pendingPermissions: agent.pendingPermissions.size,
},
"agent.manager.close.start",
);
await this.drainSessionEvents(agentId);
// Retain ownership until shutdown succeeds. A failed close may still own a
// native writer, so publishing a resumable closed snapshot would orphan it.
await agent.session.close();
this.cancelRunningProviderSubagents(agentId);
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
let persistError: unknown;
try {
await this.persistSnapshot(closedAgent);
} catch (error) {
persistError = error;
}
this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace(
{
agentId,
provider: closedAgent.provider,
sessionId: closedAgent.persistence?.sessionId ?? undefined,
},
"agent.manager.close.complete",
);
if (persistError !== undefined) {
throw persistError;
}
}
private cancelRunningProviderSubagents(parentAgentId: string): void {
for (const subagent of this.providerSubagents.list(parentAgentId)) {
if (subagent.status !== "running") {
continue;
}
const event = this.providerSubagents.apply(parentAgentId, subagent.provider, {
type: "upsert",
id: subagent.id,
status: "canceled",
});
this.dispatch({ type: "provider_subagent", event });
}
}
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId));
}
private async archiveAgentUnlocked(
agentId: string,
requestedArchivedAt?: string,
): Promise<{ archivedAt: string }> {
const agent = this.requireAgent(agentId);
if (agent.internal) {
// Nothing about an internal agent is on disk, and archiving must not put
// it there. Its final snapshot is kept in memory for a while (see
// getRetiredInternalAgent); the runtime is closed and the committed
// timeline dropped, so `getAgent` returns null from here on.
const archivedAt = requestedArchivedAt ?? new Date().toISOString();
const lastMessage = await this.getLastAssistantMessage(agentId);
const record = buildArchivedAgentRecord(
toStoredAgentRecord(agent, { title: agent.config.title ?? null, internal: true }),
{ archivedAt, updatedAt: archivedAt },
);
await this.closeAgentRuntime(agentId);
await this.deleteAgentState(agentId);
this.retireInternalAgent({ record, lastMessage });
await this.cascadeArchiveChildren(record);
return { archivedAt };
}
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 } = await this.markRecordArchived(stored, requestedArchivedAt);
agent.updatedAt = new Date(archivedAt);
await this.closeAgentRuntime(agentId);
await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive");
this.discardRetainedAgentState(agentId);
await this.cascadeArchiveChildren(stored);
return { archivedAt };
}
// Children created via the MCP `create_agent` tool carry the parent-agent-id
// label pointing back at the caller. Archiving the parent cascades to those
// children so subagent fleets don't outlive their orchestrator. Detached
// handoff agents omit this label, so they stand outside the cascade.
private async cascadeArchiveChildren(
parent: Pick<StoredAgentRecord, "id" | "workspaceId">,
): Promise<void> {
const registry = this.registry;
if (!registry) {
return;
}
const parentAgentId = parent.id;
const records = await registry.list();
for (const record of records) {
if (record.archivedAt) {
continue;
}
if (record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
continue;
}
const child = await registry.get(record.id);
if (!child || child.archivedAt || child.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
continue;
}
await this.runLifecycleMutation(child.id, async () => {
const currentChild = await registry.get(child.id);
if (
!currentChild ||
currentChild.archivedAt ||
currentChild.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId
) {
return;
}
if (shouldDetachFromArchivedParent(parent, currentChild)) {
await this.detachAgentUnlocked(currentChild.id);
} else if (this.agents.has(currentChild.id)) {
await this.archiveAgentUnlocked(currentChild.id);
} else {
await this.archiveSnapshotUnlocked(currentChild.id, new Date().toISOString());
}
});
}
// Internal children never reach storage, so the scan above cannot see them.
// They have no tab to keep open and no listing to detach into, so they
// always archive with the parent.
for (const child of Array.from(this.agents.values())) {
if (!child.internal || getParentAgentIdFromLabels(child.labels) !== parentAgentId) {
continue;
}
await this.runLifecycleMutation(child.id, async () => {
const current = this.agents.get(child.id);
if (!current || getParentAgentIdFromLabels(current.labels) !== parentAgentId) {
return;
}
await this.archiveAgentUnlocked(current.id);
});
}
}
private async markRecordArchived(
record: StoredAgentRecord,
archivedAt = new Date().toISOString(),
): Promise<ArchivedStoredAgentRecord> {
const archivedRecord = await this.persistArchivedRecord(record, {
archivedAt,
updatedAt: archivedAt,
});
if (this.agents.has(record.id)) {
this.notifyAgentState(record.id);
} else if (!archivedRecord.internal) {
this.dispatchStoredAgentState(archivedRecord);
}
await this.fireAgentArchived(record.id);
return archivedRecord;
}
private async persistArchivedRecord(
record: StoredAgentRecord,
options: { archivedAt: string; updatedAt?: string },
): Promise<ArchivedStoredAgentRecord> {
const archivedRecord = buildArchivedAgentRecord(record, options);
await this.requireRegistry().upsert(archivedRecord);
if (!record.archivedAt && !record.internal) {
this.pluginLifecycle?.emit("agent.archived", {
agent: describeHookAgent(archivedRecord),
archivedAt: archivedRecord.archivedAt,
});
}
return archivedRecord;
}
private async fireAgentArchived(agentId: string): Promise<void> {
const callback = this.onAgentArchived;
if (!callback) {
return;
}
try {
await callback(agentId);
} catch (error) {
this.logger.warn({ err: error, agentId }, "onAgentArchived callback failed");
}
}
private dispatchStoredAgentState(record: StoredAgentRecord): void {
const updatedAt = new Date(record.updatedAt);
const attention = extractAttention(record);
this.dispatch({
type: "agent_state",
agent: {
id: record.id,
provider: record.provider,
cwd: record.cwd,
workspaceId: record.workspaceId,
owner: record.owner,
session: null,
capabilities: STORED_AGENT_CAPABILITIES,
config: buildStoredAgentConfig(record),
runtimeInfo: undefined,
lifecycle: "closed",
createdAt: new Date(record.createdAt),
updatedAt,
availableModes: [],
features: record.features,
currentModeId: record.lastModeId ?? null,
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
activeForegroundTurnId: null,
activeTurnId: null,
activeTurnStartedAt: null,
foregroundTurnWaiters: new Set(),
finalizedForegroundTurnIds: new Set(),
unsubscribeSession: null,
persistence: record.persistence ?? null,
historyPrimed: true,
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
lastUsage: undefined,
lastError: record.lastError ?? undefined,
attention,
internal: record.internal,
labels: record.labels,
},
});
}
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
const agent = this.requireSessionAgent(agentId);
const notice = (await agent.session.setMode(modeId)) ?? null;
await this.drainSessionEvents(agentId);
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
agent.config.modeId = currentMode ?? undefined;
agent.currentModeId = currentMode;
// Update runtimeInfo to reflect the new mode
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: currentMode };
}
this.touchUpdatedAt(agent);
this.emitState(agent);
return notice;
}
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);
}
await this.drainSessionEvents(agentId);
agent.config.model = normalizedModelId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
this.emitState(agent);
}
async setAgentThinkingOption(
agentId: string,
thinkingOptionId: string | null,
): Promise<AgentProviderNotice | null> {
const agent = this.requireSessionAgent(agentId);
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
let notice: AgentProviderNotice | null = null;
if (agent.session.setThinkingOption) {
notice = (await agent.session.setThinkingOption(normalizedThinkingOptionId)) ?? null;
}
await this.drainSessionEvents(agentId);
let effectiveThinkingOptionId = normalizedThinkingOptionId;
const runtimeInfo = await agent.session.getRuntimeInfo();
if (runtimeInfo.thinkingOptionId !== undefined) {
effectiveThinkingOptionId = runtimeInfo.thinkingOptionId;
}
agent.config.thinkingOptionId = effectiveThinkingOptionId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = {
...agent.runtimeInfo,
thinkingOptionId: effectiveThinkingOptionId,
};
}
this.touchUpdatedAt(agent);
this.emitState(agent);
return notice;
}
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
const agent = this.requireAgent(agentId);
if (!agent.session.setFeature) {
throw new Error("Agent session does not support setting features");
}
await agent.session.setFeature(featureId, value);
await this.drainSessionEvents(agentId);
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
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> {
await this.runLifecycleMutation(agentId, async () => {
const agent = this.requireAgent(agentId);
await this.writeLabels(agent.id, labels);
});
}
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
liveAgent.labels = applyLabelPatch(liveAgent.labels, patch);
this.touchUpdatedAt(liveAgent);
await this.persistSnapshot(liveAgent);
this.emitState(liveAgent, { persist: false });
const record = this.registry ? await this.registry.get(agentId) : null;
return { record, live: true };
}
const nextRecord = await this.writeStoredMetadata(agentId, { labels: patch });
return { record: nextRecord, live: false };
}
private async writeStoredMetadata(
agentId: string,
patch: AgentMetadataPatch,
): Promise<StoredAgentRecord> {
const registry = this.requireRegistry();
const record = await registry.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const nextRecord = {
...record,
...(patch.title ? { title: patch.title } : {}),
...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}),
updatedAt: this.nextStoredUpdatedAt(record),
};
await registry.upsert(nextRecord);
return nextRecord;
}
async detachAgent(agentId: string): Promise<{
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}> {
return this.runLifecycleMutation(agentId, () => this.detachAgentUnlocked(agentId));
}
private async detachAgentUnlocked(agentId: string): Promise<{
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}> {
const registry = this.requireRegistry();
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
const previousParentAgentId = getParentAgentIdFromLabels(liveAgent.labels);
if (!previousParentAgentId) {
await this.persistSnapshot(liveAgent);
const record = await registry.get(agentId);
if (!record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
return { record, live: true, previousParentAgentId: null };
}
const { record } = await this.writeLabels(agentId, detachedAgentLabelPatch(liveAgent.labels));
if (!record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
return { record, live: true, previousParentAgentId };
}
const record = await registry.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const previousParentAgentId = getParentAgentIdFromLabels(record.labels);
if (!previousParentAgentId) {
return { record, live: false, previousParentAgentId: null };
}
const result = await this.writeLabels(agentId, detachedAgentLabelPatch(record.labels));
if (!result.record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
return { record: result.record, live: false, previousParentAgentId };
}
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 markAgentUnread(agentId: string): Promise<void> {
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
const isFinished = liveAgent.lifecycle === "idle";
const hasPendingPermissions = liveAgent.pendingPermissions.size > 0;
const canMarkUnread =
isFinished && !liveAgent.attention.requiresAttention && !hasPendingPermissions;
if (!canMarkUnread) {
throw new Error(`Agent is no longer finished and read: ${agentId}`);
}
liveAgent.attention = {
requiresAttention: true,
attentionReason: "finished",
attentionTimestamp: new Date(),
};
await this.persistSnapshot(liveAgent);
this.emitState(liveAgent, { persist: false });
return;
}
const registry = this.requireRegistry();
const record = await registry.get(agentId);
const hasFinishedStatus = record?.lastStatus === "idle" || record?.lastStatus === "closed";
const canMarkUnread =
record && !record.internal && !record.archivedAt && !record.requiresAttention;
if (!canMarkUnread || !hasFinishedStatus) {
throw new Error(`Agent is no longer finished and read: ${agentId}`);
}
const updatedAt = this.nextStoredUpdatedAt(record);
const nextRecord: StoredAgentRecord = {
...record,
updatedAt,
requiresAttention: true,
attentionReason: "finished",
attentionTimestamp: updatedAt,
};
await registry.upsert(nextRecord);
this.dispatchStoredAgentState(nextRecord);
}
async archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord> {
return this.runLifecycleMutation(agentId, () =>
this.archiveSnapshotUnlocked(agentId, archivedAt),
);
}
private async archiveSnapshotUnlocked(
agentId: string,
archivedAt: string,
): Promise<StoredAgentRecord> {
const registry = this.requireRegistry();
// A stored-only archive can have waited behind a persisted resume. Reuse the
// live archive transition so its newly acquired runtime is closed as well.
if (this.agents.has(agentId)) {
await this.archiveAgentUnlocked(agentId, archivedAt);
const archivedRecord = await registry.get(agentId);
if (!archivedRecord) throw new Error(`Agent not found: ${agentId}`);
return archivedRecord;
}
const record = await registry.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const nextRecord = await this.persistArchivedRecord(record, { archivedAt });
await this.syncNativeArchiveState(record.provider, record.persistence, "archive");
this.discardRetainedAgentState(agentId);
if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord);
await this.fireAgentArchived(agentId);
await this.cascadeArchiveChildren(record);
return nextRecord;
}
async unarchiveSnapshot(
agentId: string,
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
): Promise<boolean> {
return this.runLifecycleMutation(agentId, () =>
this.unarchiveSnapshotUnlocked(agentId, updates),
);
}
private async unarchiveSnapshotUnlocked(
agentId: string,
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
): Promise<boolean> {
const registry = this.requireRegistry();
const record = await registry.get(agentId);
if (!record || !record.archivedAt) {
return false;
}
// Close and native restore share the lifecycle lane with persisted resume.
// No new history or interactive runtime can acquire the writer between them.
if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId);
await this.syncNativeArchiveState(record.provider, record.persistence, "restore");
await registry.upsert({
...record,
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
archivedAt: null,
updatedAt: new Date().toISOString(),
});
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> {
await this.runLifecycleMutation(agentId, () =>
this.updateAgentMetadataUnlocked(agentId, updates),
);
}
private async updateAgentMetadataUnlocked(
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.writeLabels(agentId, updates.labels);
}
return;
}
await this.writeStoredMetadata(agentId, updates);
}
private async runLifecycleMutation<T>(agentId: string, mutation: () => Promise<T>): Promise<T> {
// Parent cascade classifies a child inside the same lane used by open-tab
// label writes, so a received ownership update cannot be overtaken.
const previous = this.lifecycleMutationTails.get(agentId) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(mutation);
const tail = result.then(
() => undefined,
() => undefined,
);
this.lifecycleMutationTails.set(agentId, tail);
void tail.finally(() => {
if (this.lifecycleMutationTails.get(agentId) === tail) {
this.lifecycleMutationTails.delete(agentId);
}
});
return result;
}
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,
};
}
/**
* Try to run a prompt out-of-band — i.e. without allocating a foreground turn
* and without canceling any active turn. Returns true when the session
* accepted the prompt as a side-effect command (e.g. /goal pause). Events
* emitted by the handler flow through dispatchStream so they persist and
* broadcast like normal timeline events.
*/
tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean {
const agent = this.requireSessionAgent(agentId);
const handler = agent.session.tryHandleOutOfBand?.(prompt);
if (!handler) {
return false;
}
if (options?.clientMessageId) {
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted");
this.emitState(agent);
}
const dispatch = (event: AgentStreamEvent): void => {
// Persist timeline items so they show up in fetchAgentTimeline; broadcast
// for live subscribers. Other event types are broadcast only.
if (event.type === "timeline") {
this.touchUpdatedAt(agent);
const row = this.recordTimeline(agent.id, event.item);
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
return;
}
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
};
void (async () => {
try {
await handler.run({ emit: dispatch });
} catch (error) {
const text = error instanceof Error ? error.message : "Out-of-band command failed";
dispatch({
type: "timeline",
provider: agent.provider,
item: { type: "assistant_message", text: `[Error] ${text}` },
});
}
})();
return true;
}
async appendTimelineItem(
agentId: string,
item: AgentTimelineItem,
): Promise<{ seq: number; epoch: string }> {
const agent = this.requireAgent(agentId);
item = limitAgentTimelineItemContent(item);
this.touchUpdatedAt(agent);
const row = this.recordTimeline(agentId, item);
this.dispatchStream(
agentId,
{
type: "timeline",
item,
provider: agent.provider,
},
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agentId),
timestamp: row.timestamp,
},
);
await this.persistSnapshot(agent);
return { seq: row.seq, epoch: this.timelineStore.getEpoch(agentId) };
}
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,
});
}
private async startPendingForegroundTurn(params: {
agent: ActiveManagedAgent;
agentId: string;
pendingRun: PendingForegroundRun;
prompt: AgentPromptInput;
options?: AgentRunOptions;
}): Promise<string> {
const { agent, agentId, pendingRun, prompt, options } = params;
try {
const result = await agent.session.startTurn(prompt, options);
if (pendingRun.settled) {
throw new Error(`Agent ${agentId} run was canceled before its turn started`);
}
return result.turnId;
} catch (error) {
if (pendingRun.settled) {
throw error;
}
if (isStaleProviderSessionError(error)) {
pendingRun.start = { status: "failed", error: error.message };
agent.pendingReplacement = false;
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
this.runs.settleForegroundRun(agentId, pendingRun.token);
throw error;
}
agent.pendingReplacement = false;
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
pendingRun.start = { status: "failed", error: errorMsg };
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
if (options?.clientMessageId) {
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
messageId: options.clientMessageId,
});
}
await this.handleStreamEvent(agent, {
type: "turn_failed",
provider: agent.provider,
error: errorMsg,
});
this.finalizeForegroundTurn(agent);
this.runs.settleForegroundRun(agentId, pendingRun.token);
throw error;
}
}
streamAgent(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): AsyncGenerator<AgentStreamEvent> {
const existingAgent = this.requireSessionAgent(agentId);
this.logger.trace(
{
agentId,
provider: existingAgent.provider,
sessionId: existingAgent.persistence?.sessionId ?? undefined,
turnId: existingAgent.activeForegroundTurnId ?? undefined,
lifecycle: existingAgent.lifecycle,
activeForegroundTurnId: existingAgent.activeForegroundTurnId,
hasTrackedRun: this.runs.hasRun(agentId),
promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(options),
},
"agent.manager.stream.request",
);
if (existingAgent.activeForegroundTurnId || this.runs.hasRun(agentId)) {
this.logger.trace(
{
agentId,
provider: existingAgent.provider,
sessionId: existingAgent.persistence?.sessionId ?? undefined,
turnId: existingAgent.activeForegroundTurnId ?? undefined,
lifecycle: existingAgent.lifecycle,
hasTrackedRun: this.runs.hasRun(agentId),
},
"agent.manager.stream.reject",
);
throw new Error(`Agent ${agentId} already has an active run`);
}
const agent = existingAgent;
const isReplacement = agent.pendingReplacement;
agent.lastError = undefined;
const pendingRun = this.runs.createPendingRun(agentId);
const streamForwarder = async function* streamForwarder(this: AgentManager) {
let turnId: string;
let turnStream: ReturnType<AgentRunState["createTurnStream"]> | null = null;
turnId = await this.startPendingForegroundTurn({
agent,
agentId,
pendingRun,
prompt,
options,
});
if (isReplacement) {
agent.pendingReplacement = false;
}
const turnStartedAt = new Date();
pendingRun.start = { status: "started", turnId };
agent.activeForegroundTurnId = turnId;
this.openActiveTurn(agent, turnId, turnStartedAt);
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
// AgentManager owns the accepted-turn boundary. Publish liveness before the canonical
// prompt so clients can retire optimistic activity without painting an idle frame.
// The provider's duplicate start for this turn is suppressed at the ingestion boundary.
this.dispatchStream(
agent.id,
{ type: "turn_started", provider: agent.provider, turnId },
{ timestamp: turnStartedAt.toISOString() },
);
const stagedSubmittedPromptEcho = options?.clientMessageId
? pendingRun.stagedEvents.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" &&
event.item.type === "user_message" &&
event.item.clientMessageId === options.clientMessageId,
)
: undefined;
if (options?.clientMessageId) {
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted", {
messageId: options.clientMessageId,
turnId,
providerMessageId:
stagedSubmittedPromptEcho?.item.type === "user_message"
? stagedSubmittedPromptEcho.item.messageId
: undefined,
});
}
for (const stagedEvent of pendingRun.stagedEvents.splice(0)) {
const isAcceptedTurnStart =
stagedEvent.type === "turn_started" && getAgentStreamEventTurnId(stagedEvent) === turnId;
if (isAcceptedTurnStart || stagedEvent === stagedSubmittedPromptEcho) {
continue;
}
this.enqueueSessionEvent(agent.id, stagedEvent);
}
this.emitState(agent);
this.logger.trace(
{
agentId,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
},
"agent.manager.stream.start",
);
turnStream = this.runs.createTurnStream(turnId);
this.runs.addWaiter(agent, turnStream.waiter);
try {
const acceptedTurnStartedEvent: AgentStreamEvent = {
type: "turn_started",
provider: agent.provider,
turnId,
};
yield acceptedTurnStartedEvent;
for await (const event of turnStream.events(isTurnTerminalEvent)) {
yield event;
}
} finally {
if (turnStream) {
this.runs.deleteWaiter(agent, turnStream.waiter);
}
this.runs.settleForegroundRun(agentId, pendingRun.token);
if (!agent.activeForegroundTurnId) {
await this.refreshRuntimeInfo(agent);
}
}
}.call(this);
return streamForwarder;
}
private finalizeForegroundTurn(agent: ActiveManagedAgent, turnId?: string): void {
const mutableAgent = agent;
if (turnId) {
this.runs.rememberFinalizedTurn(mutableAgent, turnId);
}
mutableAgent.activeForegroundTurnId = null;
this.applyActiveTurnTerminal(mutableAgent, turnId);
const terminalError = mutableAgent.lastError;
const shouldHoldBusyForReplacement = mutableAgent.pendingReplacement && !terminalError;
let nextLifecycle: "running" | "error" | "idle";
if (shouldHoldBusyForReplacement) {
nextLifecycle = "running";
} else if (terminalError) {
nextLifecycle = "error";
} else {
nextLifecycle = "idle";
}
mutableAgent.lifecycle = nextLifecycle;
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,
provider: agent.provider,
sessionId: mutableAgent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: mutableAgent.lifecycle,
terminalError,
pendingReplacement: mutableAgent.pendingReplacement,
},
"agent.manager.finalize",
);
if (!shouldHoldBusyForReplacement) {
this.touchUpdatedAt(mutableAgent);
this.emitState(mutableAgent);
}
}
private openActiveTurn(agent: ActiveManagedAgent, turnId: string, startedAt: Date): void {
agent.activeTurnId = turnId;
agent.activeTurnStartedAt = startedAt;
}
private applyActiveTurnTerminal(
agent: ActiveManagedAgent,
turnId?: string,
fromHistory = false,
): ActiveTurnTerminalDisposition {
if (fromHistory) return "stale";
if (!agent.activeTurnId) return "untracked";
if (turnId && agent.activeTurnId !== turnId) return "stale";
agent.activeTurnId = null;
agent.activeTurnStartedAt = null;
return "closed_current";
}
async replaceAgentRun(
agentId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<AsyncGenerator<AgentStreamEvent>> {
const snapshot = this.requireAgent(agentId);
if (
snapshot.lifecycle !== "running" &&
!snapshot.activeForegroundTurnId &&
!this.runs.hasRun(agentId)
) {
return this.streamAgent(agentId, prompt, options);
}
const agent = this.requireSessionAgent(agentId);
agent.pendingReplacement = true;
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
this.emitState(agent);
try {
await this.cancelAgentRunBefore(agentId, "replace");
return this.streamAgent(agentId, prompt, options);
} catch (error) {
const latest = this.agents.get(agentId);
if (latest) {
latest.pendingReplacement = false;
}
throw error;
}
}
async steerAgentRun(
agentId: string,
prompt: AgentPromptInput,
options?: AgentSteerOptions,
): Promise<SteerResult> {
const agent = this.requireSessionAgent(agentId);
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
if (!expectedTurnId || !agent.session.steerActiveTurn) {
return { status: "unavailable" };
}
const result = await this.runSteerAdmission(agent, expectedTurnId, async () => {
const admission = await agent.session.steerActiveTurn!(prompt, {
...options,
expectedTurnId,
});
if (admission.status === "accepted") {
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
}
return admission;
});
// An unavailable answer is only safe to fall back from while this admission
// still owns the active turn. Never let an A admission replace a later B.
if (result.status === "unavailable" && agent.activeTurnId !== expectedTurnId) {
throw new Error("Active turn changed before steering could be delivered");
}
return result;
}
async steerOrReplaceActiveTurn(
agentId: string,
prompt: AgentPromptInput,
options?: AgentSteerOptions,
): Promise<ActiveTurnSteerDispatchResult> {
const agent = this.requireSessionAgent(agentId);
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
if (!expectedTurnId) {
return { status: "inactive" };
}
const result = agent.session.steerActiveTurn
? await this.runSteerAdmission(agent, expectedTurnId, async () => {
const admission = await agent.session.steerActiveTurn!(prompt, {
...options,
expectedTurnId,
});
if (admission.status === "accepted") {
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
}
return admission;
})
: { status: "unavailable" as const };
if (result.status === "accepted") {
return { status: "steered" };
}
// Providers without autonomous steering keep their existing dispatch behavior. The shared
// admission may recognize the turn, but only an accepted steer can own it without replacement.
if (agent.activeForegroundTurnId === null && agent.activeTurnId === expectedTurnId) {
return { status: "inactive" };
}
await this.beforeSteerUnavailableFallback?.({ agentId, expectedTurnId });
this.assertSteerAdmissionOwnsTurn(agent, expectedTurnId);
return {
status: "replaced",
iterator: await this.replaceAdmittedForegroundTurn(
agent,
expectedTurnId,
prompt,
stripSteerOptions(options),
),
};
}
private assertSteerAdmissionOwnsTurn(agent: ActiveManagedAgent, expectedTurnId: string): void {
if (agent.activeTurnId !== expectedTurnId) {
throw new Error("Active turn changed before steering could be delivered");
}
}
private async runSteerAdmission<T>(
agent: ActiveManagedAgent,
expectedTurnId: string,
operation: () => Promise<T>,
): Promise<T> {
return this.runForegroundMutation(agent.id, async () => {
await this.drainSessionEvents(agent.id);
this.agentStreamCoalescer.flushFor(agent.id);
this.assertSteerAdmissionOwnsTurn(agent, expectedTurnId);
const barrier: SteerEventBarrier = { events: [] };
this.steerEventBarriers.set(agent.id, barrier);
try {
return await operation();
} finally {
if (this.steerEventBarriers.get(agent.id) === barrier) {
this.steerEventBarriers.delete(agent.id);
}
for (const event of barrier.events) {
this.enqueueSessionEvent(agent.id, event);
}
await this.drainSessionEvents(agent.id);
}
});
}
private async runForegroundMutation<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
const previous = this.foregroundMutationTails.get(agentId) ?? Promise.resolve();
const run = previous.catch(() => undefined).then(operation);
const tail = run.then(
() => undefined,
() => undefined,
);
this.foregroundMutationTails.set(agentId, tail);
try {
return await run;
} finally {
if (this.foregroundMutationTails.get(agentId) === tail) {
this.foregroundMutationTails.delete(agentId);
}
}
}
private async replaceAdmittedForegroundTurn(
agent: ActiveManagedAgent,
expectedTurnId: string,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<AsyncGenerator<AgentStreamEvent>> {
this.assertSteerAdmissionOwnsTurn(agent, expectedTurnId);
agent.pendingReplacement = true;
agent.lifecycle = "running";
this.touchUpdatedAt(agent);
this.emitState(agent);
try {
await this.cancelAgentRunBefore(agent.id, "replace");
return this.streamAgent(agent.id, prompt, options);
} catch (error) {
const latest = this.agents.get(agent.id);
if (latest) {
latest.pendingReplacement = false;
}
throw error;
}
}
private async recordAcceptedSteer(
agent: ActiveManagedAgent,
prompt: AgentPromptInput,
clientMessageId: string | undefined,
expectedTurnId: string,
): Promise<void> {
if (!clientMessageId) {
return;
}
this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", {
messageId: clientMessageId,
turnId: expectedTurnId,
});
this.emitState(agent);
}
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.runs.getPendingRun(agentId);
if (
(snapshot.lifecycle === "running" || pendingRun?.start.status === "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>((resolvePromise, 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();
resolvePromise();
};
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.runs.getPendingRun(agentId);
if (
(current.lifecycle === "running" || currentPendingRun?.start.status === "started") &&
!current.pendingReplacement
) {
finishOk();
return true;
}
if (currentPendingRun?.start.status === "failed") {
finishErr(new Error(currentPendingRun.start.error));
return true;
}
if (current.lifecycle === "error" && !currentPendingRun) {
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<AgentPermissionResult | void> {
const agent = this.requireAgent(agentId);
if (agent.inFlightPermissionResponses.has(requestId)) {
throw new Error("A response to this permission request is already being submitted");
}
agent.inFlightPermissionResponses.add(requestId);
try {
const result = await agent.session.respondToPermission(requestId, response);
agent.pendingPermissions.delete(requestId);
try {
await this.refreshSessionState(agent);
} catch {
// Ignore refresh errors - state sync after permission approval is best effort.
}
this.touchUpdatedAt(agent);
await this.persistSnapshot(agent);
this.emitState(agent);
const bufferedResolution = agent.bufferedPermissionResolutions.get(requestId);
if (bufferedResolution) {
agent.bufferedPermissionResolutions.delete(requestId);
this.dispatchStream(agent.id, bufferedResolution, { timestamp: new Date().toISOString() });
}
return result;
} finally {
agent.inFlightPermissionResponses.delete(requestId);
agent.bufferedPermissionResolutions.delete(requestId);
}
}
async cancelAgentRun(agentId: string): Promise<AgentRunCancellationResult> {
return this.runForegroundMutation(agentId, () => this.cancelAgentRunNow(agentId));
}
private async cancelAgentRunNow(agentId: string): Promise<AgentRunCancellationResult> {
const agent = this.requireSessionAgent(agentId);
const run =
this.runs.getRun(agentId) ??
(agent.lifecycle === "running" ? this.runs.trackAutonomousRun(agentId, null) : null);
if (!run) {
return { status: "not_running" };
}
const interruptAcknowledged = await this.interruptSession(agent.session, agentId);
const settlement = await this.waitWithTimeout({
operation: run.settledPromise,
timeoutMs: interruptAcknowledged
? INTERRUPT_SESSION_TIMEOUT_MS
: this.rescueTimeouts.interruptSessionMs,
});
if (!interruptAcknowledged) {
return { status: settlement === "completed" ? "settled" : "refused" };
}
const runTurnId = this.runs.getTurnId(agentId);
if (settlement === "timed_out" && runTurnId) {
this.logger.warn(
{ agentId, turnId: runTurnId, kind: run.kind },
"cancelAgentRun: acknowledged turn still active after timeout, force-canceling",
);
await this.dispatchSessionEvent(agent, {
type: "turn_canceled",
provider: agent.provider,
reason: "interrupted",
turnId: runTurnId,
});
await run.settledPromise;
} else if (settlement === "timed_out" && run.kind === "foreground") {
this.logger.warn(
{ agentId, kind: run.kind },
"cancelAgentRun: acknowledged pending turn still active after timeout, clearing it",
);
this.runs.settleForegroundRun(agentId, run.token);
if (!agent.pendingReplacement) {
agent.lifecycle = "idle";
this.touchUpdatedAt(agent);
this.emitState(agent);
}
} else if (settlement === "timed_out" && run.kind === "autonomous") {
this.logger.warn(
{ agentId, kind: run.kind },
"cancelAgentRun: acknowledged turn still active after timeout, force-canceling",
);
await this.dispatchSessionEvent(agent, {
type: "turn_canceled",
provider: agent.provider,
reason: "interrupted",
});
}
if (agent.pendingPermissions.size > 0) {
this.resolvePendingPermissionsForAgent(agent, agent.provider, undefined, "Interrupted");
this.touchUpdatedAt(agent);
this.emitState(agent);
}
return { status: "settled" };
}
private async cancelAgentRunBefore(
agentId: string,
action: "reload" | "replace" | "rewind",
): Promise<void> {
const result = await this.cancelAgentRun(agentId);
if (result.status === "refused") {
throw new AgentRunCancellationError(agentId, action);
}
}
private async interruptSession(session: AgentSession, agentId: string): Promise<boolean> {
try {
const result = await this.waitWithTimeout({
operation: session.interrupt(),
timeoutMs: this.rescueTimeouts.interruptSessionMs,
onLateError: (error) => {
this.logger.warn(
{ err: error, agentId },
"Session interrupt failed after timeout during cancel",
);
},
});
if (result === "timed_out") {
this.logger.warn(
{ agentId, timeoutMs: this.rescueTimeouts.interruptSessionMs },
"Timed out interrupting session during cancel",
);
return false;
}
return true;
} catch (error) {
this.logger.error({ err: error, agentId }, "Failed to interrupt session");
return false;
}
}
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;
}
/**
* Hydrates the runtime timeline from provider history. No-ops if already hydrated.
*/
async hydrateTimelineFromProvider(
agentId: string,
options?: HydrateTimelineOptions,
): Promise<void> {
const agent = this.requireSessionAgent(agentId);
await this.hydrateTimelineFromLegacyProviderHistory(agent, options);
}
async rewind(agentId: string, messageId: string, mode: RewindMode): Promise<void> {
const agent = this.requireSessionAgent(agentId);
const submittedRow = this.timelineStore
.getRows(agentId)
.find(
(row) =>
row.item.type === "user_message" &&
row.item.messageId === messageId &&
row.item.clientMessageId === messageId,
);
if (submittedRow && !submittedRow.providerMessageId) {
throw new Error("Cannot rewind before the provider acknowledges the submitted prompt");
}
const providerMessageId = submittedRow?.providerMessageId ?? messageId;
if (this.hasInFlightRun(agentId)) {
await this.cancelAgentRunBefore(agentId, "rewind");
}
const lock = this.runs.createPendingRun(agentId);
try {
this.logger.info(
{ agentId, provider: agent.provider, messageId, mode },
"agent.rewind.start",
);
await invokeRewindCapability(agent.session, { messageId: providerMessageId, mode });
if (mode !== "files") {
await this.hydrateTimelineFromProvider(agentId, {
force: true,
broadcast: true,
broadcastTimeline: false,
});
this.dispatch({
type: "timeline_replacement",
agentId,
epoch: this.timelineStore.getEpoch(agentId),
});
}
// Rewind stages provider events under the run lock; publish its final state directly.
this.refreshSessionPersistence(agent);
await this.refreshSessionState(agent, { emit: false });
await this.persistSnapshot(agent);
this.emitState(agent, { persist: false });
this.logger.info(
{ agentId, provider: agent.provider, messageId, mode },
"agent.rewind.complete",
);
} catch (error) {
this.logger.warn(
{ err: error, agentId, provider: agent.provider, messageId, mode },
"agent.rewind.failed",
);
throw error;
} finally {
this.runs.settleForegroundRun(agentId, lock.token);
}
}
async deleteAgentState(agentId: string): Promise<void> {
this.discardRetainedAgentState(agentId);
await this.deleteCommittedTimeline(agentId);
}
async deleteCommittedTimeline(agentId: string): Promise<void> {
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.toReversed().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);
return lastLiveItem ?? (await this.durableTimelineStore?.getLastItem(agentId)) ?? null;
}
async waitForAgentEvent(
agentId: string,
options?: WaitForAgentOptions,
): Promise<WaitForAgentResult> {
const snapshot = this.getAgent(agentId);
if (!snapshot) {
throw new Error(`Agent ${agentId} not found`);
}
const pendingForegroundRun = this.runs.getPendingRun(agentId);
const hasForegroundTurn =
Boolean(snapshot.activeForegroundTurnId) || Boolean(pendingForegroundRun);
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>((resolvePromise, 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 =
isAgentBusy(initialStatus) ||
Boolean(snapshot.activeForegroundTurnId) ||
pendingForegroundRun?.start.status === "started";
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) => {
resolvePromise({
status: currentStatus,
permission,
lastMessage,
});
return;
})
.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?: {
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
timeline?: AgentTimelineItem[];
timelineRows?: AgentTimelineRow[];
timelineNextSeq?: number;
persistence?: AgentPersistenceHandle;
historyPrimed?: boolean;
lastUsage?: AgentUsage;
lastError?: string;
attention?: AttentionState;
/**
* Bringing a known agent back, rather than starting a new one. Its timestamps and
* attention come from what was already recorded, and installing the session is not
* activity in it.
*/
restoring?: boolean;
initialTitle?: string | null;
publishWhenReady?: boolean;
workspaceId?: string;
owner?: AgentOwner;
},
): Promise<ManagedAgent> {
let registered = false;
try {
this.assertAcceptingAgentRegistrations();
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,
options?.initialTitle ?? null,
);
const now = new Date();
const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({
agentId: resolvedAgentId,
now,
options,
});
const managed = this.buildManagedAgentForRegister({
resolvedAgentId,
session,
config,
now,
durableTimelineHasRows,
options,
});
// Read history before publishing the agent: a provider failure must leave the
// session unregistered so the registration catch closes it.
const startupHistory: AgentStreamEvent[] = [];
if (session.initialTimeline?.length && !managed.historyPrimed) {
for await (const event of session.streamHistory()) {
startupHistory.push(limitAgentStreamEventContent(event));
}
}
this.assertAcceptingAgentRegistrations();
this.agents.set(resolvedAgentId, managed);
registered = true;
// Initialize previousStatus to track transitions
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
if (session.initialTimeline?.length) {
if (!managed.historyPrimed) {
// Legacy/imported chats need their existing history before startup rows.
await this.primeTimelineFromLegacyProviderHistory(managed, false, startupHistory);
} else {
for (const entry of buildImportedTimelineRows(session.initialTimeline)) {
this.recordTimeline(managed.id, entry.item, { timestamp: entry.timestamp });
}
}
this.refreshSessionPersistence(managed);
}
await this.refreshRuntimeInfo(managed, { emit: false });
this.assertAgentRegistrationActive(managed);
await this.persistSnapshot(managed, {
title: initialPersistedTitle,
});
this.assertAgentRegistrationActive(managed);
if (!options?.publishWhenReady) {
this.emitState(managed, { persist: false });
}
await this.refreshSessionState(managed, { emit: false });
this.assertAgentRegistrationActive(managed);
managed.lifecycle = "idle";
// Stamping now over a restored timestamp rewrote the workspace's "last used" in the
// sidebar every time a chat was reopened, because workspace `statusEnteredAt` is
// re-derived from persisted agent `updatedAt` on every daemon start.
if (!options?.restoring) {
this.touchUpdatedAt(managed);
}
await this.persistSnapshot(managed);
this.assertAgentRegistrationActive(managed);
this.emitState(managed, { persist: false });
this.subscribeToSession(managed);
return { ...managed };
} catch (error) {
if (!registered) {
await this.closeUnregisteredSession(session);
}
throw error;
}
}
private assertAcceptingAgentRegistrations(): void {
if (!this.acceptingAgentRegistrations) {
throw new AgentManagerShuttingDownError();
}
}
private assertAgentRegistrationActive(agent: ActiveManagedAgent): void {
if (!this.acceptingAgentRegistrations || this.agents.get(agent.id) !== agent) {
throw new AgentManagerShuttingDownError();
}
}
private async closeUnregisteredSession(session: AgentSession): Promise<void> {
try {
await session.close();
} catch (error) {
this.logger.warn({ err: error }, "Failed to close unregistered agent session");
}
}
private async requireExternalMcpSupport(
session: AgentSession,
storedConfig: AgentSessionConfig,
): Promise<void> {
if (
Object.keys(storedConfig.mcpServers ?? {}).length === 0 ||
session.capabilities.supportsMcpServers === true
) {
return;
}
await this.closeUnregisteredSession(session);
throw new Error(`Provider '${storedConfig.provider}' does not support MCP servers`);
}
private async initializeAgentTimelineForRegister(params: {
agentId: string;
now: Date;
options:
| {
timeline?: AgentTimelineItem[];
timelineRows?: AgentTimelineRow[];
timelineNextSeq?: number;
persistence?: AgentPersistenceHandle;
createdAt?: Date;
updatedAt?: Date;
}
| undefined;
}): Promise<{ durableTimelineHasRows: boolean }> {
const { agentId, now, options } = params;
const timelineAlreadyPrimed = this.timelineStore.has(agentId);
const explicitTimelineSeed = buildExplicitTimelineSeedForRegister(now, options);
const shouldSeedFromDurable =
!explicitTimelineSeed && !this.timelineStore.has(agentId) && this.durableTimelineStore;
const durableTimelineSeed = shouldSeedFromDurable
? await this.loadCommittedTimelineSeed(agentId, now)
: null;
const durableTimelineHasRows =
timelineAlreadyPrimed ||
(durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1);
const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed;
if (timelineSeed || !this.timelineStore.has(agentId)) {
this.timelineStore.initialize(agentId, timelineSeed ?? { timestamp: now.toISOString() });
}
if (options?.timelineRows?.length) {
this.enqueueDurableTimelineBulkInsert(agentId, options.timelineRows);
}
return { durableTimelineHasRows };
}
private buildManagedAgentForRegister(params: {
resolvedAgentId: string;
session: AgentSession;
config: AgentSessionConfig;
now: Date;
durableTimelineHasRows: boolean;
options:
| {
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
historyPrimed?: boolean;
lastUsage?: AgentUsage;
lastError?: string;
attention?: AttentionState;
persistence?: AgentPersistenceHandle;
workspaceId?: string;
owner?: AgentOwner;
}
| undefined;
}): ActiveManagedAgent {
const { resolvedAgentId, session, config, now, durableTimelineHasRows, options } = params;
return {
id: resolvedAgentId,
provider: config.provider,
cwd: config.cwd,
workspaceId: options?.workspaceId,
owner: options?.owner,
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>(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
activeForegroundTurnId: null,
activeTurnId: null,
activeTurnStartedAt: null,
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
finalizedForegroundTurnIds: new Set<string>(),
unsubscribeSession: null,
persistence: attachPersistenceCwd(
options?.persistence ?? session.describePersistence(),
config.cwd,
),
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
lastUserMessageAt: options?.lastUserMessageAt ?? null,
lastUsage: options?.lastUsage,
lastError: options?.lastError,
attention: resolveInitialAttention(options?.attention),
internal: config.internal ?? false,
labels: options?.labels ?? {},
} as ActiveManagedAgent;
}
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 prepareAgentForClosure(
agent: LiveManagedAgent,
cancelReason: string,
): ManagedAgentClosed {
this.agentStreamCoalescer.flushAndDiscard(agent.id);
this.agents.delete(agent.id);
this.previousStatuses.delete(agent.id);
if (agent.unsubscribeSession) {
agent.unsubscribeSession();
agent.unsubscribeSession = null;
}
this.runs.cancelWaiters(agent, (turnId) => ({
type: "turn_canceled",
provider: agent.provider,
reason: cancelReason,
turnId,
}));
this.runs.clearAgentRun(agent.id);
return {
...agent,
lifecycle: "closed",
session: null,
activeForegroundTurnId: null,
activeTurnId: null,
activeTurnStartedAt: null,
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
foregroundTurnWaiters: new Set(),
finalizedForegroundTurnIds: new Set(),
unsubscribeSession: null,
};
}
private discardRetainedAgentState(agentId: string): void {
this.timelineStore.delete(agentId);
this.paseoToolPolicies.delete(agentId);
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
}
}
private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void {
this.emitState(agent, options);
if (!agent.internal) {
this.pluginLifecycle?.emit("agent.closed", {
agent: describeHookAgent({ ...agent, title: agent.config.title }),
});
}
}
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 {
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: this.agents.get(agentId)?.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
event,
},
"agent.manager.enqueue",
);
const steerBarrier = this.steerEventBarriers.get(agentId);
if (steerBarrier) {
steerBarrier.events.push(event);
return;
}
const pendingRun = this.runs.getPendingRun(agentId);
if (pendingRun?.start.status === "pending") {
pendingRun.stagedEvents.push(event);
return;
}
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.session == null) {
return;
}
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: current.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
event,
},
"agent.manager.dequeue",
);
await this.dispatchSessionEvent(current, event);
return;
})
.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);
}
});
}
/**
* Provider mutations may synchronously emit config events that are processed through the
* asynchronous session queue. Apply those events before committing the mutation's explicit
* manager state so call order remains authoritative.
*/
private async drainSessionEvents(agentId: string): Promise<void> {
while (true) {
const tail = this.sessionEventTails.get(agentId);
if (!tail) {
return;
}
await tail;
if (this.sessionEventTails.get(agentId) === tail) {
return;
}
}
}
private async dispatchSessionEvent(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
): Promise<void> {
if (event.type === "provider_subagent") {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
this.dispatch({ type: "provider_subagent", event: update });
return;
}
const turnId = getAgentStreamEventTurnId(event);
const matchingWaiters = this.runs.getMatchingWaiters(agent, turnId);
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
matchingWaiterCount: matchingWaiters.length,
event,
},
"agent.manager.dispatch_session_event",
);
const shouldNotifyWaiters = await this.handleStreamEvent(agent, event);
if (!shouldNotifyWaiters) {
return;
}
this.runs.notifyWaiters(matchingWaiters, event, {
terminal: isTurnTerminalEvent(event),
});
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
notifiedWaiterCount: matchingWaiters.length,
terminal: isTurnTerminalEvent(event),
event,
},
"agent.manager.notify_waiters",
);
}
private async resolveInitialPersistedTitle(
agentId: string,
config: AgentSessionConfig,
fallbackTitle: string | null,
): Promise<string | null> {
const existing = await this.registry?.get(agentId);
if (existing) {
return existing.title ?? null;
}
const explicitTitle =
typeof config.title === "string" && config.title.trim().length > 0
? config.title.trim()
: null;
return explicitTitle ?? fallbackTitle;
}
private async persistSnapshot(
agent: ManagedAgent,
options?: { 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;
}
await this.registry.applySnapshot(agent, options);
}
private requireRegistry(): AgentStorage {
if (!this.registry) {
throw new Error("Agent storage unavailable");
}
return this.registry;
}
/**
* Provider-side mode switches (ACP current_mode_update, in-session
* commands, permission-driven transitions) must land in config.modeId too —
* reloadAgentSession and the persisted record derive the resumed session's
* mode from it, so leaving it stale silently downgrades the mode on resume.
*/
private applyObservedMode(agent: ActiveManagedAgent, modeId: string | null): void {
agent.currentModeId = modeId;
if (modeId != null) {
agent.config.modeId = modeId;
}
}
private async refreshSessionState(
agent: ActiveManagedAgent,
options?: { emit?: boolean },
): Promise<void> {
try {
const modes = await agent.session.getAvailableModes();
agent.availableModes = modes;
} catch {
agent.availableModes = [];
}
try {
this.applyObservedMode(agent, 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();
}
this.syncFeaturesFromSession(agent);
await this.refreshRuntimeInfo(agent, options);
}
private async refreshRuntimeInfo(
agent: ActiveManagedAgent,
options?: { emit?: boolean },
): 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 && options?.emit !== false) {
this.emitState(agent);
}
} catch {
// Keep existing runtimeInfo if refresh fails.
}
}
private async hydrateTimelineFromLegacyProviderHistory(
agent: ActiveManagedAgent,
options?: HydrateTimelineOptions,
): Promise<void> {
if (agent.historyPrimed && !options?.force) {
return;
}
const broadcast = options?.broadcast ?? false;
const broadcastTimeline = options?.broadcastTimeline ?? broadcast;
if (options?.force) {
await this.forceHydrateTimelineFromLegacyProviderHistory(
agent,
typeof broadcast === "function" ? broadcast() : broadcast,
typeof broadcastTimeline === "function" ? broadcastTimeline() : broadcastTimeline,
);
return;
}
await this.primeTimelineFromLegacyProviderHistory(agent, broadcast);
}
private async forceHydrateTimelineFromLegacyProviderHistory(
agent: ActiveManagedAgent,
broadcast: boolean,
broadcastTimeline: boolean,
): Promise<void> {
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
const providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
for await (const rawEvent of agent.session.streamHistory()) {
const event = limitAgentStreamEventContent(rawEvent);
if (event.type === "timeline") {
const item = projectAgentMessage(event.item);
if (!item) continue;
historyEvents.push({ ...event, item });
} else if (event.type === "provider_subagent") {
providerSubagentEvents.push(event);
}
}
this.agentStreamCoalescer.flushAndDiscard(agent.id);
await this.deleteCommittedTimeline(agent.id);
this.timelineStore.delete(agent.id);
this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() });
agent.historyPrimed = true;
for (const event of this.providerSubagents.deleteParent(agent.id)) {
if (broadcast) {
this.dispatch({ type: "provider_subagent", event });
}
}
for (const event of providerSubagentEvents) {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
if (broadcast) {
this.dispatch({ type: "provider_subagent", event: update });
}
}
for (const event of historyEvents) {
const row = this.recordTimeline(
agent.id,
event.item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (broadcastTimeline) {
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
this.touchUpdatedAt(agent);
this.emitState(agent);
}
private async primeTimelineFromLegacyProviderHistory(
agent: ActiveManagedAgent,
broadcast: boolean | (() => boolean),
history:
| AsyncIterable<AgentStreamEvent>
| Iterable<AgentStreamEvent> = agent.session.streamHistory(),
): Promise<void> {
const deferredBroadcast = typeof broadcast === "function";
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
const historySubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
agent.historyPrimed = false;
try {
// Collect the whole replay before touching either store. A stream that fails
// halfway then leaves the committed timeline as it was, instead of a partial
// copy the next attempt would append to.
for await (const rawEvent of history) {
const event = limitAgentStreamEventContent(rawEvent);
if (event.type === "provider_subagent") {
historySubagentEvents.push(event);
continue;
}
if (event.type !== "timeline") {
continue;
}
const item = projectAgentMessage(event.item);
if (!item) continue;
historyEvents.push({ ...event, item });
}
} catch (error) {
this.logger.warn({ err: error, agentId: agent.id }, "Failed to hydrate provider history");
throw error;
}
// The replay is the timeline, so drop the rows a previous hydration committed.
// Keeping them would leave getTimelineRows reading one copy per hydration.
await this.deleteCommittedTimeline(agent.id);
const timelineEvents: Array<{
event: Extract<AgentStreamEvent, { type: "timeline" }>;
row: AgentTimelineRow;
}> = [];
const providerSubagentEvents: AgentManagerEvent[] = [];
for (const event of historySubagentEvents) {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update };
if (deferredBroadcast) {
providerSubagentEvents.push(managerEvent);
} else if (broadcast) {
this.dispatch(managerEvent);
}
}
for (const event of historyEvents) {
const row = this.recordTimeline(
agent.id,
event.item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (deferredBroadcast) {
timelineEvents.push({ event, row });
} else if (broadcast) {
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
agent.historyPrimed = true;
if (typeof broadcast !== "function" || !broadcast()) {
return;
}
for (const event of providerSubagentEvents) {
this.dispatch(event);
}
for (const { event, row } of timelineEvents) {
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void {
const turnId = getAgentStreamEventTurnId(event);
if (turnId == null) {
return;
}
const agent = this.agents.get(agentId);
if (!agent) {
return;
}
this.runs.notifyAgentWaiters(agent, event);
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
event,
},
"agent.manager.notify_waiters.coalesced",
);
}
private async handleStreamEvent(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
options?: HandleStreamEventOptions,
): Promise<boolean> {
event = limitAgentStreamEventContent(event);
const identified = attachManagedTurnIdentity(agent, event, options?.fromHistory === true);
event = identified.event;
const eventTurnId = identified.turnId;
const isForegroundEvent = agent.activeForegroundTurnId === eventTurnId;
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
if (
eventTurnId &&
isTurnTerminalEvent(event) &&
this.runs.hasFinalizedTurn(agent, eventTurnId)
) {
return false;
}
// Only update timestamp for live events, not history replay
if (!options?.fromHistory) {
this.touchUpdatedAt(agent);
if (this.agentStreamCoalescer.handle(agent.id, event)) {
this.traceCoalescerBuffered(agent, event, eventTurnId);
return false;
}
this.agentStreamCoalescer.flushFor(agent.id);
}
let terminalDisposition: ActiveTurnTerminalDisposition = "untracked";
if (isTurnTerminalEvent(event)) {
terminalDisposition = this.applyActiveTurnTerminal(
agent,
eventTurnId,
options?.fromHistory === true,
);
}
const flags: StreamEventFlags = { shouldDispatchEvent: true, shouldNotifyWaiters: true };
const dispatchPromise = this.dispatchStreamEventByType({
agent,
event,
options,
isForegroundEvent,
eventTurnId,
terminalDisposition,
flags,
});
if (dispatchPromise) {
await dispatchPromise;
}
if (!options?.fromHistory) {
if (isTurnTerminalEvent(event)) {
this.runs.settleTerminalRun(agent.id, eventTurnId);
if (isForegroundEvent) {
this.finalizeForegroundTurn(agent, eventTurnId);
}
}
if (flags.shouldDispatchEvent) {
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
}
}
this.traceHandleStreamEventEnd(agent, event, eventTurnId, flags);
return flags.shouldNotifyWaiters;
}
private traceHandleStreamEventStart(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
isForegroundEvent: boolean,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
isForegroundEvent,
event,
},
"agent.manager.handle_stream_event.start",
);
}
private traceCoalescerBuffered(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
event,
},
"agent.manager.coalescer.buffer",
);
}
private traceHandleStreamEventEnd(
agent: ActiveManagedAgent,
event: AgentStreamEvent,
turnId: string | undefined,
flags: StreamEventFlags,
): void {
this.logger.trace(
{
agentId: agent.id,
provider: event.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
shouldDispatchEvent: flags.shouldDispatchEvent,
shouldNotifyWaiters: flags.shouldNotifyWaiters,
event,
},
"agent.manager.handle_stream_event.end",
);
}
private dispatchStreamEventByType(params: {
agent: ActiveManagedAgent;
event: AgentStreamEvent;
options: HandleStreamEventOptions | undefined;
isForegroundEvent: boolean;
eventTurnId: string | undefined;
terminalDisposition: ActiveTurnTerminalDisposition;
flags: StreamEventFlags;
}): Promise<void> | undefined {
const { agent, event, options, isForegroundEvent, eventTurnId, terminalDisposition, flags } =
params;
switch (event.type) {
case "thread_started":
this.onStreamThreadStarted(agent);
return undefined;
case "usage_updated":
agent.lastUsage = event.usage;
this.emitState(agent);
return undefined;
case "mode_changed":
this.applyObservedMode(agent, event.currentModeId);
agent.availableModes = event.availableModes;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: event.currentModeId };
}
flags.shouldDispatchEvent = false;
this.emitState(agent);
return undefined;
case "model_changed":
agent.runtimeInfo = event.runtimeInfo;
if (!agent.persistence && event.runtimeInfo.sessionId) {
agent.persistence = attachPersistenceCwd(
{ provider: agent.provider, sessionId: event.runtimeInfo.sessionId },
agent.cwd,
);
}
this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId);
flags.shouldDispatchEvent = false;
this.emitState(agent);
return undefined;
case "thinking_option_changed":
agent.config.thinkingOptionId = event.thinkingOptionId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = {
...agent.runtimeInfo,
thinkingOptionId: event.thinkingOptionId,
};
}
flags.shouldDispatchEvent = false;
this.emitState(agent);
return undefined;
case "timeline":
return this.onStreamTimelineEvent({ agent, event, options, flags });
case "turn_completed":
this.onStreamTurnCompleted({
agent,
event,
eventTurnId,
isForegroundEvent,
terminalDisposition,
});
return undefined;
case "turn_failed":
return this.onStreamTurnFailed({
agent,
event,
eventTurnId,
isForegroundEvent,
terminalDisposition,
options,
});
case "turn_canceled":
this.onStreamTurnCanceled({
agent,
event,
eventTurnId,
isForegroundEvent,
terminalDisposition,
options,
});
return undefined;
case "turn_started":
this.onStreamTurnStarted({ agent, eventTurnId, isForegroundEvent, flags });
return undefined;
case "permission_requested":
this.onStreamPermissionRequested(agent, event);
return undefined;
case "permission_resolved":
this.onStreamPermissionResolved({ agent, event, options, flags });
return undefined;
default:
return undefined;
}
}
private onStreamThreadStarted(agent: ActiveManagedAgent): void {
const previousSessionId = agent.persistence?.sessionId ?? null;
this.refreshSessionPersistence(agent);
if (agent.persistence?.sessionId !== previousSessionId) {
this.emitState(agent);
}
void this.refreshRuntimeInfo(agent);
}
private refreshSessionPersistence(agent: ActiveManagedAgent): void {
const handle = agent.session.describePersistence();
if (handle) {
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
}
}
private async onStreamTimelineEvent(params: {
agent: ActiveManagedAgent;
event: Extract<AgentStreamEvent, { type: "timeline" }>;
options: { fromHistory?: boolean } | undefined;
flags: StreamEventFlags;
}): Promise<void> {
const { agent, event, options, flags } = params;
const item = projectAgentMessage(event.item);
if (!item) {
flags.shouldDispatchEvent = false;
flags.shouldNotifyWaiters = false;
return;
}
event.item = item;
if (
event.item.type === "user_message" &&
event.item.clientMessageId &&
this.reconcileSubmittedPromptEcho(agent, event.item, event.turnId)
) {
flags.shouldDispatchEvent = false;
flags.shouldNotifyWaiters = false;
return;
}
if (options?.fromHistory) {
this.recordTimeline(
agent.id,
event.item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
flags.shouldDispatchEvent = false;
flags.shouldNotifyWaiters = false;
return;
}
this.recordAndDispatchTimelineItem(agent.id, event.item, event.provider, event.turnId);
if (event.item.type === "user_message") {
agent.lastUserMessageAt = new Date();
this.emitState(agent);
}
flags.shouldDispatchEvent = false;
flags.shouldNotifyWaiters = true;
}
private onStreamTurnCompleted(params: {
agent: ActiveManagedAgent;
event: Extract<AgentStreamEvent, { type: "turn_completed" }>;
eventTurnId: string | undefined;
isForegroundEvent: boolean;
terminalDisposition: ActiveTurnTerminalDisposition;
}): void {
const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition } = params;
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
},
"agent.manager.turn.completed",
);
if (terminalDisposition === "stale") return;
if (event.usage) {
agent.lastUsage = { ...agent.lastUsage, ...event.usage };
}
// If no usage on turn_completed, keep lastUsage as-is so context window
// data accumulated during streaming isn't lost when the provider omits
// it from the completion event.
agent.lastError = undefined;
if (
!isForegroundEvent &&
!agent.activeForegroundTurnId &&
agent.lifecycle !== "idle" &&
!agent.pendingReplacement
) {
(agent as ActiveManagedAgent).lifecycle = "idle";
this.emitState(agent);
}
void this.refreshRuntimeInfo(agent);
}
private async onStreamTurnFailed(params: {
agent: ActiveManagedAgent;
event: Extract<AgentStreamEvent, { type: "turn_failed" }>;
eventTurnId: string | undefined;
isForegroundEvent: boolean;
terminalDisposition: ActiveTurnTerminalDisposition;
options: { fromHistory?: boolean } | undefined;
}): Promise<void> {
const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition, options } = params;
this.logger.warn(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
error: event.error,
code: event.code,
diagnostic: event.diagnostic,
},
"handleStreamEvent: turn_failed",
);
if (terminalDisposition === "stale") return;
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
agent.lifecycle = "error";
}
agent.lastError = event.error;
await this.appendSystemErrorTimelineMessage(
agent,
event.provider,
this.formatTurnFailedMessage(event),
options,
);
this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed");
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
this.emitState(agent);
}
}
private onStreamTurnCanceled(params: {
agent: ActiveManagedAgent;
event: Extract<AgentStreamEvent, { type: "turn_canceled" }>;
eventTurnId: string | undefined;
isForegroundEvent: boolean;
terminalDisposition: ActiveTurnTerminalDisposition;
options:
| {
fromHistory?: boolean;
}
| undefined;
}): void {
const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition, options } = params;
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
eventTurnId,
},
"agent.manager.turn.canceled",
);
if (terminalDisposition === "stale") return;
if (!isForegroundEvent && !agent.activeForegroundTurnId && !agent.pendingReplacement) {
agent.lifecycle = "idle";
}
agent.lastError = undefined;
this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Interrupted");
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
this.emitState(agent);
}
}
private onStreamTurnStarted(params: {
agent: ActiveManagedAgent;
eventTurnId: string | undefined;
isForegroundEvent: boolean;
flags: StreamEventFlags;
}): void {
const { agent, eventTurnId, isForegroundEvent, flags } = params;
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: eventTurnId,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
},
"agent.manager.turn.started",
);
if (isForegroundEvent) {
flags.shouldDispatchEvent = false;
flags.shouldNotifyWaiters = false;
return;
}
if (agent.activeForegroundTurnId) {
flags.shouldDispatchEvent = false;
flags.shouldNotifyWaiters = false;
return;
}
this.runs.trackAutonomousRun(agent.id, eventTurnId ?? null);
if (eventTurnId) {
this.openActiveTurn(agent, eventTurnId, new Date());
}
agent.lifecycle = "running";
this.emitState(agent);
}
private onStreamPermissionRequested(
agent: ActiveManagedAgent,
event: Extract<AgentStreamEvent, { type: "permission_requested" }>,
): void {
const hadPendingPermissions = agent.pendingPermissions.size > 0;
agent.pendingPermissions.set(event.request.id, event.request);
this.refreshSessionPersistence(agent);
if (!hadPendingPermissions && !agent.internal) {
this.broadcastAgentAttention(agent, "permission");
}
this.emitState(agent);
}
private onStreamPermissionResolved(params: {
agent: ActiveManagedAgent;
event: Extract<AgentStreamEvent, { type: "permission_resolved" }>;
options: { fromHistory?: boolean } | undefined;
flags: StreamEventFlags;
}): void {
const { agent, event, options, flags } = params;
agent.pendingPermissions.delete(event.requestId);
this.refreshSessionPersistence(agent);
if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) {
agent.bufferedPermissionResolutions.set(event.requestId, event);
flags.shouldDispatchEvent = false;
return;
}
this.emitState(agent);
}
private resolvePendingPermissionsForAgent(
agent: ActiveManagedAgent,
provider: AgentProvider,
options: { fromHistory?: boolean } | undefined,
message: string,
): void {
for (const [requestId] of agent.pendingPermissions) {
agent.pendingPermissions.delete(requestId);
if (!options?.fromHistory) {
this.dispatchStream(agent.id, {
type: "permission_resolved",
provider,
requestId,
resolution: { behavior: "deny", message },
});
}
}
}
private recordAndDispatchTimelineItem(
agentId: string,
item: AgentTimelineItem,
provider: AgentProvider,
turnId?: string,
options?: { providerMessageId?: string },
): AgentStreamEvent {
const row = this.recordTimeline(agentId, item, { ...options, turnId });
const event: AgentStreamEvent = {
type: "timeline",
item,
provider,
...(turnId !== undefined ? { turnId } : {}),
};
this.dispatchStream(agentId, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agentId),
timestamp: row.timestamp,
});
if (
item.type === "tool_call" &&
item.status === "completed" &&
item.detail?.type === "shell" &&
commandMayHaveChangedExternalState(item.detail.command)
) {
const agent = this.agents.get(agentId);
if (agent) {
this.onWorkspaceStateMayHaveChanged?.({ cwd: agent.cwd });
}
}
return event;
}
private recordSubmittedPrompt(
agent: ActiveManagedAgent,
prompt: AgentPromptInput,
clientMessageId: string,
outcome: "accepted" | "rejected",
options?: { messageId?: string; providerMessageId?: string; turnId?: string },
): void {
const item = projectAgentMessage({
type: "user_message",
text: submittedPromptText(prompt),
clientMessageId,
...(options?.messageId ? { messageId: options.messageId } : {}),
});
if (!item) return;
// Human attempts stay in history on rejection; delivery notifications require acceptance.
if (outcome === "rejected" && item.type !== "user_message") return;
this.recordSubmittedPromptItem(agent, item, options);
}
private recordSubmittedPromptItem(
agent: ActiveManagedAgent,
item: AgentTimelineItem,
options?: { providerMessageId?: string; turnId?: string },
): void {
if (
item.type === "user_message" &&
item.clientMessageId &&
this.timelineStore.getSubmittedUserMessage(agent.id, item.clientMessageId)
)
return;
this.touchUpdatedAt(agent);
if (item.type === "user_message") agent.lastUserMessageAt = new Date();
this.recordAndDispatchTimelineItem(agent.id, item, agent.provider, options?.turnId, options);
}
private reconcileSubmittedPromptEcho(
agent: ActiveManagedAgent,
item: Extract<AgentTimelineItem, { type: "user_message" }>,
turnId?: string,
): AgentTimelineRow | null {
const { clientMessageId, messageId } = item;
if (!clientMessageId) return null;
let existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId);
if (!existing) {
this.recordSubmittedPromptItem(
agent,
{ ...item, messageId: clientMessageId },
{
...(messageId ? { providerMessageId: messageId } : {}),
...(turnId ? { turnId } : {}),
},
);
existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId);
}
if (!existing || existing.item.type !== "user_message") return null;
if (messageId) {
const enriched = this.timelineStore.enrichSubmittedUserMessage(
agent.id,
clientMessageId,
messageId,
);
if (enriched) this.enqueueDurableTimelineUpdate(agent.id, enriched);
}
return existing;
}
private async appendSystemErrorTimelineMessage(
agent: ActiveManagedAgent,
provider: AgentProvider,
message: string,
options?: { fromHistory?: boolean },
): 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,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
}
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,
options?: {
timestamp?: string;
providerMessageId?: string;
turnId?: string;
},
): AgentTimelineRow {
item = limitAgentTimelineItemContent(item);
const row = this.timelineStore.append(agentId, item, options);
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.syncFeaturesFromSession(agent);
this.logger.trace(
{
agentId: agent.id,
provider: agent.provider,
sessionId: agent.persistence?.sessionId ?? undefined,
turnId: agent.activeForegroundTurnId ?? undefined,
lifecycle: agent.lifecycle,
activeForegroundTurnId: agent.activeForegroundTurnId,
pendingPermissions: agent.pendingPermissions.size,
persist: options?.persist !== false,
},
"agent.manager.emit_state",
);
this.dispatch({
type: "agent_state",
agent: { ...agent },
});
}
private syncFeaturesFromSession(agent: ManagedAgent): void {
if ("session" in agent && agent.session?.features) {
agent.features = agent.session.features;
}
}
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.bulkInsert(agentId, [row]).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 enqueueDurableTimelineUpdate(agentId: string, row: AgentTimelineRow): void {
if (!this.durableTimelineStore) return;
const task = this.durableTimelineStore.updateCommittedRow(agentId, row).catch((err) => {
this.logger.error(
{ err, agentId, seq: row.seq, itemType: row.item.type },
"Failed to enrich durable timeline row",
);
});
this.trackBackgroundTask(task);
}
private trackBackgroundTask(task: Promise<void>): void {
this.backgroundTasks.add(task);
void task.finally(() => {
this.backgroundTasks.delete(task);
});
}
private trackAgentRegistrationOperation<T>(result: Promise<T>): Promise<T> {
const settled = result.then(
() => undefined,
() => undefined,
);
this.agentRegistrationTasks.add(settled);
void settled.then(() => {
this.agentRegistrationTasks.delete(settled);
return undefined;
});
return result;
}
/**
* Flush any background persistence work (best-effort).
*/
async flush(): Promise<void> {
await this.flushTasks({ includeAgentRegistrations: false });
}
/**
* Flush persistence and agent registrations that crossed the synchronous
* shutdown barrier. Those registrations own provider sessions until they
* either install them or close them.
*/
async flushForShutdown(): Promise<void> {
await this.flushTasks({ includeAgentRegistrations: true });
}
private async flushTasks(options: { includeAgentRegistrations: boolean }): Promise<void> {
this.agentStreamCoalescer.flushAll();
// Drain tasks, including tasks spawned while awaiting.
while (
this.backgroundTasks.size > 0 ||
(options.includeAgentRegistrations && this.agentRegistrationTasks.size > 0)
) {
const pending = options.includeAgentRegistrations
? [...this.backgroundTasks, ...this.agentRegistrationTasks]
: [...this.backgroundTasks];
await Promise.allSettled(pending);
}
}
private broadcastAgentAttention(
agent: ManagedAgent,
reason: "finished" | "error" | "permission",
): void {
if (isDelegatedAgent(agent)) {
return;
}
this.onAgentAttention?.({
agentId: agent.id,
provider: agent.provider,
reason,
});
}
private dispatchStream(
agentId: string,
event: AgentStreamEvent,
metadata?: {
seq?: number;
epoch?: string;
timestamp?: string;
},
): void {
if (event.type === "timeline") {
event = {
...event,
item: limitAgentTimelineItemContent(event.item),
};
}
const agent = this.agents.get(agentId);
this.logger.trace(
{
agentId,
provider: event.provider,
sessionId: agent?.persistence?.sessionId ?? undefined,
turnId: getAgentStreamEventTurnId(event),
metadata,
event,
},
"agent.manager.dispatch_stream",
);
this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
if (this.pluginLifecycle && agent && !agent.internal && event.type !== "timeline") {
publishAgentStream(
this.pluginLifecycle,
describeHookAgent({ ...agent, title: agent.config.title }),
event,
this.timelineStore.getItems(agentId),
);
}
}
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;
}
if (
subscriber.agentId &&
event.type === "provider_subagent" &&
subscriber.agentId !==
(event.event.type === "upsert"
? event.event.subagent.parentAgentId
: event.event.parentAgentId)
) {
continue;
}
// Global subscribers (no agentId) skip internal agents unless they opted in.
if (
!subscriber.agentId &&
!subscriber.includeInternal &&
this.eventBelongsToInternalAgent(event)
) {
continue;
}
subscriber.callback(event);
}
}
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
if (event.type === "agent_state") return event.agent.internal === true;
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
if (event.type !== "provider_subagent") return false;
const parentAgentId =
event.event.type === "upsert"
? event.event.subagent.parentAgentId
: event.event.parentAgentId;
return this.agents.get(parentAgentId)?.internal === true;
}
private async normalizeConfig(
config: AgentSessionConfig,
options: NormalizeConfigOptions = {},
): 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);
// Only a session that will run in the directory needs it to still be there. Reading
// an archived agent's history runs nothing, and must survive the worktree it ran in
// being removed when its workspace was archived.
if (options.purpose !== "history") {
await assertUsableWorkingDirectory(normalized.cwd);
}
}
if (typeof normalized.model === "string") {
const trimmed = normalized.model.trim();
normalized.model = trimmed.length > 0 && trimmed !== "default" ? trimmed : undefined;
}
const shouldResolveDefaultModel = options.resolveDefaultModel ?? true;
if (shouldResolveDefaultModel && !normalized.model) {
const defaultModelId = await this.resolveDefaultModelId(normalized);
if (defaultModelId) {
normalized.model = defaultModelId;
}
}
return this.applyProviderConfiguration(normalized);
}
private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig {
const definition = this.providerDefinitions.get(config.provider);
this.validateToolPolicyServers(config);
if (config.toolPolicy && !definition?.applyToolPolicy) {
throw new Error(
`Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`,
);
}
return definition?.applyToolPolicy
? definition.applyToolPolicy(config, config.toolPolicy)
: config;
}
private validateToolPolicyServers(config: AgentSessionConfig): void {
if (!config.toolPolicy) return;
const serverNames = new Set(Object.keys(config.mcpServers ?? {}));
for (const grant of config.toolPolicy.preapproved) {
if (!serverNames.has(grant.server)) {
throw new Error(
`toolPolicy preapproval '${grant.server}.${grant.tool}' requires MCP server '${grant.server}' in the same agent request`,
);
}
}
}
private async resolveDefaultModelId(config: AgentSessionConfig): Promise<string | undefined> {
const client = this.clients.get(config.provider);
if (!client) {
return undefined;
}
try {
const catalog = await client.fetchCatalog({
scope: "workspace",
cwd: config.cwd,
force: false,
});
return (catalog.models.find((model) => model.isDefault) ?? catalog.models[0])?.id;
} catch {
// Provider may not support model listing — leave model undefined.
return undefined;
}
}
private async prepareSessionConfig(
config: AgentSessionConfig,
agentId: string,
options: { env?: Record<string, string>; purpose?: AgentResumePurpose } = {},
): Promise<PreparedSessionConfig> {
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), {
env: options.env,
purpose: options.purpose,
});
const paseoToolPolicy = this.paseoToolsEnabled
? this.resolvePaseoToolPolicy(storedConfig.provider)
: { enabled: false };
const launchConfig = this.applyDaemonAppendSystemPrompt(
withRuntimePaseoMcpServer({
config: storedConfig,
agentId,
mcpBaseUrl:
this.paseoToolsEnabled && isPaseoToolPolicyEnabled(paseoToolPolicy)
? this.mcpBaseUrl
: null,
mcpAuthToken: this.mcpAuthToken,
}),
);
return { storedConfig, launchConfig, paseoToolPolicy };
}
private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig {
const daemonAppendSystemPrompt = this.appendSystemPrompt.trim();
const next = { ...config };
delete next.daemonAppendSystemPrompt;
return daemonAppendSystemPrompt
? {
...next,
daemonAppendSystemPrompt,
}
: next;
}
private async buildLaunchContext(
agentId: string,
client: AgentClient,
cwd: string,
paseoToolPolicy: ProviderPaseoToolsPolicy | undefined,
env?: Record<string, string>,
opening?: {
reason: PluginSessionOpenRequest["reason"];
purpose: PluginSessionOpenRequest["purpose"];
workspaceId?: string | null;
},
): Promise<AgentLaunchContext> {
if (this.pluginLifecycle) {
const request: PluginSessionOpenRequest = {
agentId,
provider: client.provider,
cwd,
workspaceId: opening?.workspaceId ?? null,
reason: opening?.reason ?? "resume",
purpose: opening?.purpose ?? "interactive",
env: { ...env },
};
const transformed = await this.pluginLifecycle.before("agent.session_open", request);
env = transformed.env;
}
const context: AgentLaunchContext = {
agentId,
env: {
...env,
PASEO_AGENT_ID: agentId,
PASEO_AGENT_CWD: cwd,
},
};
if (
this.paseoToolsEnabled &&
isPaseoToolPolicyEnabled(paseoToolPolicy) &&
client.capabilities.supportsNativePaseoTools &&
this.paseoToolCatalogFactory
) {
context.paseoTools = await this.paseoToolCatalogFactory({
callerAgentId: agentId,
paseoToolPolicy,
});
}
return context;
}
private resolveProviderLaunchConfig(
launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext,
): AgentSessionConfig {
return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig;
}
private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> {
const client = this.clients.get(options.provider);
if (!client) {
const configuredProviders = this.getConfiguredProviderIds();
throw new Error(
`Unknown provider '${options.provider}'. Configured providers: ${formatProviderList(
configuredProviders,
)}.`,
);
}
let unavailableReason: string | null = null;
try {
const available = await client.isAvailable();
if (available) {
return client;
}
} catch (error) {
unavailableReason = error instanceof Error ? error.message : String(error);
}
const availableProviders = (await this.listProviderAvailability())
.filter((entry) => entry.available)
.map((entry) => entry.provider);
const providerList = formatProviderList(availableProviders);
const reason = unavailableReason ? ` Reason: ${unavailableReason}.` : "";
throw new Error(
`Provider '${options.provider}' is not available.${reason} Available providers: ${providerList}. Use one of those providers, or install/configure '${options.provider}'.`,
);
}
private requireEnabledProvider(provider: AgentProvider): void {
if (this.providerEnabled.get(provider) === false) {
throw new Error(`Provider '${provider}' is disabled`);
}
}
private getConfiguredProviderIds(): AgentProvider[] {
return Array.from(new Set([...this.providerEnabled.keys(), ...this.clients.keys()]));
}
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 async syncNativeArchiveState(
provider: AgentProvider,
persistence: AgentPersistenceHandle | null | undefined,
state: "archive" | "restore",
): Promise<void> {
if (!persistence) return;
const client = this.clients.get(provider);
const sync =
state === "archive" ? client?.archiveNativeSession : client?.unarchiveNativeSession;
if (!sync) return;
if (state === "restore") {
await sync.call(client, persistence);
return;
}
try {
await sync.call(client, persistence);
} catch (error) {
this.logger.warn(
{ error, provider, sessionId: persistence.sessionId },
"Failed to archive native session (best-effort)",
);
}
}
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.session === null) {
throw new Error(`Agent '${agent.id}' has no managed session`);
}
return agent;
}
private requirePublicAgent(id: string): LiveManagedAgent {
const agent = this.requireAgent(id);
if (agent.internal) {
throw new Error(`Unknown agent '${agent.id}'`);
}
return agent;
}
}
function matchesImportableSessionQuery(
session: ImportableProviderSession,
rawQuery: string | undefined,
): boolean {
const query = rawQuery?.trim().toLowerCase();
if (!query) return true;
const cwdBasename = basename(session.cwd.replaceAll("\\", "/"));
return [session.title, session.firstPromptPreview, session.lastPromptPreview, cwdBasename].some(
(value) => value?.toLowerCase().includes(query),
);
}
export function commandMayHaveChangedExternalState(command: string): boolean {
const normalized = command.toLowerCase();
// Commands that operate on remote state and do NOT trigger local file
// watchers. Local git mutations (commit, checkout, merge, rebase, reset,
// pull) are already caught by watchers on .git/HEAD and refs/heads/.
return (
// GitHub PR operations (merge, close, create, edit, comment, review)
/\bgh\s+pr\s+(merge|close|create|edit|comment|review)\b/.test(normalized) ||
// Pushes to remote — local refs unchanged, but remote state (PR checks,
// mergeable status) may shift immediately after.
/\bgit\s+push\b/.test(normalized) ||
// Fetches update refs/remotes/ which our watchers do not watch, so
// ahead/behind counts can drift stale until the next refresh.
/\bgit\s+fetch\b/.test(normalized)
);
}