Files
paseo/packages/server/src/server/session.ts
T
Mohamed Boudra 3d96ea6354 Fix chat history paging around tool updates (#1247)
* Fix projected timeline paging

* Avoid full timeline fetches for plain pages
2026-05-31 05:38:29 +00:00

9054 lines
281 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import equal from "fast-deep-equal";
import { v4 as uuidv4 } from "uuid";
import { TTLCache } from "@isaacs/ttlcache";
import pMemoize from "p-memoize";
import { realpathSync } from "node:fs";
import type { FSWatcher } from "node:fs";
import { basename, resolve, sep } from "path";
import { homedir } from "node:os";
import { z } from "zod";
import type { ToolSet } from "ai";
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
import {
isLegacyEditorTargetId,
serializeAgentStreamEvent,
type AgentSnapshotPayload,
type AgentAttachment,
type FirstAgentContext,
type SessionInboundMessage,
type SessionOutboundMessage,
type FileExplorerRequest,
type FileDownloadTokenRequest,
type GitSetupOptions,
type CheckoutRenameBranchRequest,
type StartWorkspaceScriptRequest,
type CloseItemsRequest,
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
type EditorTargetDescriptorPayload,
type EditorTargetId,
type ProjectPlacementPayload,
type WorkspaceSetupSnapshot,
type WorkspaceDescriptorPayload,
} from "./messages.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import { TerminalSessionController } from "../terminal/terminal-session-controller.js";
import {
encodeFileTransferFrame,
FileTransferOpcode,
type TerminalStreamFrame,
} from "@getpaseo/protocol/binary-frames/index";
import { CursorError } from "./pagination/cursor.js";
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
import { TTSManager } from "./agent/tts-manager.js";
import { STTManager } from "./agent/stt-manager.js";
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
import { getPidLockInfo } from "./pid-lock.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import {
DictationStreamManager,
type DictationStreamOutboundMessage,
} from "./dictation/dictation-stream-manager.js";
import {
createVoiceTurnController,
type VoiceTurnController,
} from "./voice/voice-turn-controller.js";
import {
buildConfigOverrides,
extractTimestamps,
isStoredAgentProviderAvailable,
toAgentPersistenceHandle,
} from "./persistence-hooks.js";
import { ensureAgentLoaded } from "./agent/agent-loading.js";
import {
formatSystemNotificationPrompt,
sendPromptToAgent,
waitForAgentRunStartWithTimeout,
unarchiveAgentState,
} from "./agent/agent-prompt.js";
import { resolveCreateAgentTitles } from "./agent/create-agent-title.js";
import { respondToAgentPermission } from "./agent/permission-response.js";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
import {
buildWorkspaceScriptPayloads,
readPaseoConfigForProjection,
} from "./script-status-projection.js";
import { deriveProjectSlug } from "./workspace-git-metadata.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { DaemonConfigStore } from "./daemon-config-store.js";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
import type {
WorkspaceGitRuntimeSnapshot,
WorkspaceGitService,
WorkspaceGitSnapshotOptions,
} from "./workspace-git-service.js";
import { AgentManager } from "./agent/agent-manager.js";
import { ProviderSnapshotManager, resolveSnapshotCwd } from "./agent/provider-snapshot-manager.js";
import type {
AgentManagerEvent,
AgentTimelineCursor,
AgentTimelineFetchDirection,
AgentTimelineFetchResult,
ManagedAgent,
} from "./agent/agent-manager.js";
import { createAgentCommand } from "./agent/create-agent/create.js";
import {
archiveAgentCommand,
cancelAgentRunCommand,
closeAgentCommand,
setAgentModeCommand,
updateAgentCommand,
} from "./agent/lifecycle-command.js";
import {
buildStoredAgentPayload,
resolveEffectiveThinkingOptionId,
resolveStoredAgentPayloadUpdatedAt,
toAgentPayload,
} from "./agent/agent-projections.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./agent/timeline-append.js";
import {
selectProjectedTimelinePage,
type TimelineProjectionMode,
} from "./agent/timeline-projection.js";
import {
StructuredAgentFallbackError,
StructuredAgentResponseError,
generateStructuredAgentResponseWithFallback,
} from "./agent/agent-response-loop.js";
import {
resolveStructuredGenerationProviders,
type StructuredGenerationDaemonConfig,
} from "./agent/structured-generation-providers.js";
import {
getAgentStreamEventTurnId,
type AgentPersistenceHandle,
type AgentPermissionResponse,
type AgentProvider,
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentRunOptions,
type AgentSessionConfig,
type ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import {
ImportSessionsRequestError,
importProviderSession,
listImportableProviderSessions,
normalizeImportAgentRequest,
} from "./agent/import-sessions.js";
import {
checkoutLiteFromGitSnapshot,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
deriveProjectGroupingName,
classifyDirectoryForProjectMembership,
deriveWorkspaceDisplayName,
} from "./workspace-registry-model.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
resolveProjectDisplayName,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
} from "./workspace-registry.js";
import {
buildVoiceModeSystemPrompt,
stripVoiceModeSystemPrompt,
wrapSpokenInput,
} from "./voice-config.js";
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
import {
listDirectoryEntries,
readExplorerFile,
readExplorerFileBytes,
getDownloadableFileInfo,
} from "./file-explorer/service.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import {
readPaseoConfigForEdit,
writePaseoConfigForEdit,
type ProjectConfigRpcError,
} from "../utils/paseo-config-file.js";
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import {
checkoutResolvedBranch,
type CheckoutExistingBranchResult,
commitChanges,
mergeToBase,
mergeFromBase,
pullCurrentBranch,
pushCurrentBranch,
createPullRequest,
renameCurrentBranch,
} from "../utils/checkout-git.js";
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import { getProjectIcon } from "../utils/project-icon.js";
import { expandTilde } from "../utils/path.js";
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
import { toCheckoutError } from "./checkout-git-utils.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import {
buildCheckoutPrStatusPayloadFromSnapshot,
buildCheckoutStatusPayloadFromSnapshot,
} from "./checkout/status-projection.js";
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js";
import type pino from "pino";
import {
ChatServiceError,
FileBackedChatService,
parseMentionAgentIds,
} from "./chat/chat-service.js";
import { notifyChatMentions, prepareChatMentionFanout } from "./chat/chat-mentions.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { execCommand } from "../utils/spawn.js";
import {
assertPullRequestAutoMergeDisableReady,
assertPullRequestAutoMergeEnableReady,
createGitHubService,
type GitHubService,
type PullRequestTimelineItem,
} from "../services/github-service.js";
import {
summarizeFetchWorkspacesEntries,
WorkspaceDirectory,
type WorkspaceUpdatesFilter,
} from "./workspace-directory.js";
import {
attemptFirstAgentBranchAutoName,
createPaseoWorktree,
type CreatePaseoWorktreeInput,
type CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js";
import { generateBranchNameFromFirstAgentContext } from "./worktree-branch-name-generator.js";
import {
assertSafeGitRef as assertWorktreeSafeGitRef,
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
createPaseoWorktreeWorkflow as createWorktreeWorkflow,
type CreatePaseoWorktreeSetupContinuationInput,
type CreatePaseoWorktreeWorkflowResult,
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
} from "./worktree-session.js";
import { toWorktreeWireError } from "./worktree-errors.js";
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
type CurrentWorkspacePullRequest = NonNullable<
WorkspaceGitRuntimeSnapshot["github"]["pullRequest"]
> & {
number: number;
};
interface ResolveKnownProjectRootForConfigInput {
repoRoot: string;
projectRegistry: Pick<ProjectRegistry, "list">;
}
async function resolveKnownProjectRootForConfig(
input: ResolveKnownProjectRootForConfigInput,
): Promise<string | null> {
const requestedRoot = canonicalizeConfigRoot(input.repoRoot);
const projects = await input.projectRegistry.list();
for (const project of projects) {
if (project.archivedAt !== null) {
continue;
}
const projectRoot = canonicalizeConfigRoot(project.rootPath);
if (requestedRoot === projectRoot) {
return projectRoot;
}
}
return null;
}
function canonicalizeConfigRoot(repoRoot: string): string {
const resolved = resolve(repoRoot);
try {
return stripTrailingPathSeparators(realpathSync(resolved));
} catch {
return stripTrailingPathSeparators(resolved);
}
}
function stripTrailingPathSeparators(path: string): string {
let normalized = path;
while (normalized.length > 1 && normalized.endsWith(sep)) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
type GitMutationRefreshReason =
| "commit-changes"
| "pull"
| "push"
| "merge-to-base"
| "merge-from-base"
| "merge-pr"
| "enable-pr-auto-merge"
| "disable-pr-auto-merge"
| "create-pr"
| "switch-branch"
| "rename-branch"
| "create-branch"
| "stash-push"
| "stash-pop"
| "create-worktree";
// TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings.
// Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject
// the entire session message if they encounter an unknown provider.
const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]);
// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any
// other icon name is downgraded to "ShieldCheck" for those clients.
const LEGACY_MODE_ICONS = new Set<string>([
"ShieldCheck",
"ShieldAlert",
"ShieldOff",
"ShieldQuestionMark",
]);
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50";
function errorToFriendlyMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return "Unknown error";
}
function resolveSubscriptionId(
subscribe: unknown,
requestedSubscriptionId: string | undefined,
): string | null {
if (!subscribe) return null;
if (requestedSubscriptionId && requestedSubscriptionId.length > 0) {
return requestedSubscriptionId;
}
return uuidv4();
}
function diffChangeTypeFor(file: { isNew?: boolean; isDeleted?: boolean }): "A" | "D" | "M" {
if (file.isNew) return "A";
if (file.isDeleted) return "D";
return "M";
}
function buildWorkspaceCheckout(
workspace: PersistedWorkspaceRecord,
project: PersistedProjectRecord,
): ProjectPlacementPayload["checkout"] {
if (project.kind !== "git") {
return {
cwd: workspace.cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
}
if (workspace.kind === "worktree") {
return {
cwd: workspace.cwd,
isGit: true,
currentBranch: workspace.displayName,
remoteUrl: null,
worktreeRoot: workspace.cwd,
isPaseoOwnedWorktree: true,
mainRepoRoot: project.rootPath,
};
}
return {
cwd: workspace.cwd,
isGit: true,
currentBranch: workspace.displayName,
remoteUrl: null,
worktreeRoot: workspace.cwd,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
}
function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean {
if (!appVersion) return false;
// Strip prerelease suffix: "0.1.45-beta.4" -> "0.1.45"
const base = appVersion.replace(/-.*$/, "");
const parts = base.split(".").map(Number);
const minParts = minVersion.split(".").map(Number);
for (let i = 0; i < minParts.length; i++) {
const a = parts[i] ?? 0;
const b = minParts[i] ?? 0;
if (a > b) return true;
if (a < b) return false;
}
return true;
}
function clientSupportsAllProviders(appVersion: string | null): boolean {
return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS);
}
function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean {
return isAppVersionAtLeast(appVersion, MIN_VERSION_FLEXIBLE_EDITOR_IDS);
}
type DeleteFencedAgentStorage = AgentStorage & {
beginDelete(agentId: string): void;
};
function beginAgentDeleteIfSupported(agentStorage: AgentStorage, agentId: string): void {
if ("beginDelete" in agentStorage && typeof agentStorage.beginDelete === "function") {
(agentStorage as DeleteFencedAgentStorage).beginDelete(agentId);
}
}
const FETCH_AGENTS_SORT_KEYS = ["status_priority", "created_at", "updated_at", "title"] as const;
export function resolveWaitForFinishError(options: {
status: "permission" | "error" | "idle";
final: AgentSnapshotPayload | null;
}): string | null {
if (options.status !== "error") {
return null;
}
const message = options.final?.lastError;
return typeof message === "string" && message.trim().length > 0 ? message : "Agent failed";
}
type ProcessingPhase = "idle" | "transcribing";
interface WorkspaceGitWatchTarget {
cwd: string;
workspaceId: string;
watchers: FSWatcher[];
debounceTimer: ReturnType<typeof setTimeout> | null;
refreshPromise: Promise<void> | null;
refreshQueued: boolean;
latestDescriptorStateKey: string | null;
lastBranchName: string | null;
}
export interface SessionRuntimeMetrics {
terminalDirectorySubscriptionCount: number;
terminalSubscriptionCount: number;
inflightRequests: number;
peakInflightRequests: number;
}
type FetchAgentsRequestMessage = Extract<SessionInboundMessage, { type: "fetch_agents_request" }>;
type FetchAgentHistoryRequestMessage = Extract<
SessionInboundMessage,
{ type: "fetch_agent_history_request" }
>;
type AgentDirectoryRequestMessage = FetchAgentsRequestMessage | FetchAgentHistoryRequestMessage;
type FetchAgentsRequestFilter = NonNullable<FetchAgentsRequestMessage["filter"]>;
type FetchAgentsRequestSort = NonNullable<FetchAgentsRequestMessage["sort"]>[number];
type FetchAgentsResponsePayload = Extract<
SessionOutboundMessage,
{ type: "fetch_agents_response" }
>["payload"];
type FetchAgentsResponseEntry = FetchAgentsResponsePayload["entries"][number];
type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload["pageInfo"];
type AgentUpdatePayload = Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
type AgentUpdatesFilter = FetchAgentsRequestFilter;
interface AgentUpdatesSubscriptionState {
subscriptionId: string;
filter?: AgentUpdatesFilter;
isBootstrapping: boolean;
pendingUpdatesByAgentId: Map<string, AgentUpdatePayload>;
}
type FetchWorkspacesRequestMessage = Extract<
SessionInboundMessage,
{ type: "fetch_workspaces_request" }
>;
type FetchWorkspacesRequestFilter = NonNullable<FetchWorkspacesRequestMessage["filter"]>;
type FetchWorkspacesResponsePayload = Extract<
SessionOutboundMessage,
{ type: "fetch_workspaces_response" }
>["payload"];
type FetchWorkspacesResponseEntry = FetchWorkspacesResponsePayload["entries"][number];
type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo"];
type WorkspaceUpdatePayload = Extract<
SessionOutboundMessage,
{ type: "workspace_update" }
>["payload"];
interface WorkspaceUpdatesSubscriptionState {
subscriptionId: string;
filter?: WorkspaceUpdatesFilter;
isBootstrapping: boolean;
pendingUpdatesByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
lastEmittedByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
}
class SessionRequestError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
this.name = "SessionRequestError";
}
}
const PCM_SAMPLE_RATE = 16000;
const PCM_CHANNELS = 1;
const PCM_BITS_PER_SAMPLE = 16;
const PCM_BYTES_PER_MS = (PCM_SAMPLE_RATE * PCM_CHANNELS * (PCM_BITS_PER_SAMPLE / 8)) / 1000;
const MIN_STREAMING_SEGMENT_DURATION_MS = 1000;
const MIN_STREAMING_SEGMENT_BYTES = Math.round(
PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS,
);
const AgentIdSchema = z.string().uuid();
const AVAILABLE_EDITOR_TARGETS_CACHE_TTL_MS = 60_000;
const AVAILABLE_EDITOR_TARGETS_CACHE_KEY = "available";
interface VoiceModeBaseConfig {
systemPrompt?: string;
}
interface AudioBufferState {
chunks: Buffer[];
format: string;
isPCM: boolean;
totalPCMBytes: number;
}
// Stub types for features under development (modules not yet available)
type AgentMcpTransportFactory = () => Promise<unknown>;
interface VoiceTranscriptionResultPayload {
text: string;
requestId: string;
language?: string;
duration?: number;
avgLogprob?: number;
isLowConfidence?: boolean;
byteLength?: number;
format?: string;
debugRecordingPath?: string;
}
export interface SessionOptions {
clientId: string;
appVersion?: string | null;
clientCapabilities?: Record<string, unknown> | null;
onMessage: (msg: SessionOutboundMessage) => void;
onBinaryMessage?: (frame: Uint8Array) => void;
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
logger: pino.Logger;
downloadTokenStore: DownloadTokenStore;
pushTokenStore: PushTokenStore;
paseoHome: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
chatService: FileBackedChatService;
scheduleService: ScheduleService;
loopService: LoopService;
checkoutDiffManager: CheckoutDiffManager;
github?: GitHubService;
createAgentMcpTransport?: AgentMcpTransportFactory;
workspaceGitService: WorkspaceGitService;
daemonConfigStore: DaemonConfigStore;
mcpBaseUrl?: string | null;
stt: Resolvable<SpeechToTextProvider | null>;
sttLanguage?: string;
tts: Resolvable<TextToSpeechProvider | null>;
terminalManager: TerminalManager | null;
providerSnapshotManager: ProviderSnapshotManager;
scriptRouteStore?: ScriptRouteStore;
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
workspaceSetupSnapshots?: Map<string, WorkspaceSetupSnapshot>;
onBranchChanged?: (
workspaceId: string,
oldBranch: string | null,
newBranch: string | null,
) => void;
getDaemonTcpPort?: () => number | null;
getDaemonTcpHost?: () => string | null;
resolveScriptHealth?: (hostname: string) => ScriptHealthState | null;
voice?: {
turnDetection?: Resolvable<TurnDetectionProvider | null>;
};
voiceBridge?: {
registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void;
unregisterVoiceSpeakHandler?: (agentId: string) => void;
registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void;
unregisterVoiceCallerContext?: (agentId: string) => void;
};
dictation?: {
finalTimeoutMs?: number;
stt?: Resolvable<SpeechToTextProvider | null>;
sttLanguage?: string;
getSpeechReadiness?: () => SpeechReadinessSnapshot;
};
serverId?: string;
daemonVersion?: string;
daemonRuntimeConfig?: {
listen: string | null;
relay: {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
} | null;
};
}
export type SessionLifecycleIntent =
| {
type: "shutdown";
clientId: string;
requestId: string;
}
| {
type: "restart";
clientId: string;
requestId: string;
reason?: string;
};
type PullRequestTimelinePayload = Extract<
SessionOutboundMessage,
{ type: "pull_request_timeline_response" }
>["payload"];
type PullRequestTimelinePayloadItem = PullRequestTimelinePayload["items"][number];
interface VoiceFeatureUnavailableContext {
reasonCode: SpeechReadinessSnapshot["voiceFeature"]["reasonCode"];
message: string;
retryable: boolean;
missingModelIds: LocalSpeechModelId[];
}
interface VoiceFeatureUnavailableResponseMetadata {
reasonCode?: SpeechReadinessSnapshot["voiceFeature"]["reasonCode"];
retryable?: boolean;
missingModelIds?: LocalSpeechModelId[];
}
class VoiceFeatureUnavailableError extends Error {
readonly reasonCode: SpeechReadinessSnapshot["voiceFeature"]["reasonCode"];
readonly retryable: boolean;
readonly missingModelIds: LocalSpeechModelId[];
constructor(context: VoiceFeatureUnavailableContext) {
super(context.message);
this.name = "VoiceFeatureUnavailableError";
this.reasonCode = context.reasonCode;
this.retryable = context.retryable;
this.missingModelIds = [...context.missingModelIds];
}
}
function convertPCMToWavBuffer(
pcmBuffer: Buffer,
sampleRate: number,
channels: number,
bitsPerSample: number,
): Buffer {
const headerSize = 44;
const wavBuffer = Buffer.alloc(headerSize + pcmBuffer.length);
const byteRate = (sampleRate * channels * bitsPerSample) / 8;
const blockAlign = (channels * bitsPerSample) / 8;
wavBuffer.write("RIFF", 0);
wavBuffer.writeUInt32LE(36 + pcmBuffer.length, 4);
wavBuffer.write("WAVE", 8);
wavBuffer.write("fmt ", 12);
wavBuffer.writeUInt32LE(16, 16);
wavBuffer.writeUInt16LE(1, 20);
wavBuffer.writeUInt16LE(channels, 22);
wavBuffer.writeUInt32LE(sampleRate, 24);
wavBuffer.writeUInt32LE(byteRate, 28);
wavBuffer.writeUInt16LE(blockAlign, 32);
wavBuffer.writeUInt16LE(bitsPerSample, 34);
wavBuffer.write("data", 36);
wavBuffer.writeUInt32LE(pcmBuffer.length, 40);
pcmBuffer.copy(wavBuffer, 44);
return wavBuffer;
}
function parseClientCapabilities(
capabilities: Record<string, unknown> | null | undefined,
): ReadonlySet<ClientCapability> {
if (!capabilities) {
return new Set();
}
const known = new Set<ClientCapability>(Object.values(CLIENT_CAPS));
const result: ClientCapability[] = [];
for (const [key, value] of Object.entries(capabilities)) {
if (value === true && known.has(key as ClientCapability)) {
result.push(key as ClientCapability);
}
}
return new Set(result);
}
/**
* Session represents a single connected client session.
* It owns all state management, orchestration logic, and message processing.
* Session has no knowledge of WebSockets - it only emits and receives messages.
*/
export class Session {
private readonly clientId: string;
private appVersion: string | null;
private clientCapabilities: ReadonlySet<ClientCapability>;
private readonly sessionId: string;
private readonly onMessage: (msg: SessionOutboundMessage) => void;
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private readonly sessionLogger: pino.Logger;
private readonly paseoHome: string;
// State machine
private abortController: AbortController;
private processingPhase: ProcessingPhase = "idle";
// Voice mode state
private isVoiceMode = false;
private speechInProgress = false;
private dictationStreamManager!: DictationStreamManager;
private resolveVoiceTurnDetection!: () => TurnDetectionProvider | null;
private voiceTurnController: VoiceTurnController | null = null;
private voiceInputChunkCount = 0;
private voiceInputBytes = 0;
private voiceInputWindowStartedAt = Date.now();
// Audio buffering for interruption handling
private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = [];
private bufferTimeout: ReturnType<typeof setTimeout> | null = null;
private audioBuffer: AudioBufferState | null = null;
// Optional TTS debug capture (persisted per utterance)
private readonly ttsDebugStreams = new Map<string, { format: string; chunks: Buffer[] }>();
// Per-session managers
private ttsManager!: TTSManager;
private sttManager!: STTManager;
// Per-session MCP client and tools
private agentMcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null;
private agentTools: ToolSet | null = null;
private agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly chatService: FileBackedChatService;
private readonly scheduleService: ScheduleService;
private readonly loopService: LoopService;
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly github: GitHubService;
private readonly workspaceGitService: WorkspaceGitService;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly mcpBaseUrl: string | null;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore;
private unsubscribeAgentEvents: (() => void) | null = null;
private agentUpdatesSubscription: AgentUpdatesSubscriptionState | null = null;
private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null;
private clientActivity: {
deviceType: "web" | "mobile";
focusedAgentId: string | null;
lastActivityAt: Date;
appVisible: boolean;
appVisibilityChangedAt: Date;
} | null = null;
private readonly terminalManager: TerminalManager | null;
private readonly providerSnapshotManager: ProviderSnapshotManager;
private unsubscribeProviderSnapshotEvents: (() => void) | null = null;
private readonly scriptRouteStore: ScriptRouteStore | null;
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
private readonly onBranchChanged?: (
workspaceId: string,
oldBranch: string | null,
newBranch: string | null,
) => void;
private readonly getDaemonTcpPort: (() => number | null) | null;
private readonly getDaemonTcpHost: (() => string | null) | null;
private readonly resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
private readonly terminalController: TerminalSessionController;
private inflightRequests = 0;
private peakInflightRequests = 0;
private readonly availableEditorTargetsCache = new TTLCache<
string,
EditorTargetDescriptorPayload[]
>({
ttl: AVAILABLE_EDITOR_TARGETS_CACHE_TTL_MS,
max: 1,
checkAgeOnGet: true,
});
private readonly getMemoizedAvailableEditorTargets = pMemoize(
async () => this.resolveAvailableEditorTargets(),
{
cache: this.availableEditorTargetsCache,
cacheKey: () => AVAILABLE_EDITOR_TARGETS_CACHE_KEY,
},
);
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
private readonly workspaceDirectory: WorkspaceDirectory;
private registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void;
private unregisterVoiceSpeakHandler?: (agentId: string) => void;
private registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void;
private unregisterVoiceCallerContext?: (agentId: string) => void;
private getSpeechReadiness?: () => SpeechReadinessSnapshot;
private readonly sttLanguage: string;
private readonly serverId: string | undefined;
private readonly daemonVersion: string | undefined;
private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"];
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
private voiceModeAgentId: string | null = null;
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
constructor(options: SessionOptions) {
const {
clientId,
appVersion,
clientCapabilities,
onMessage,
onBinaryMessage,
onLifecycleIntent,
logger,
downloadTokenStore,
pushTokenStore,
paseoHome,
agentManager,
agentStorage,
projectRegistry,
workspaceRegistry,
chatService,
scheduleService,
loopService,
checkoutDiffManager,
github,
workspaceGitService,
daemonConfigStore,
mcpBaseUrl,
stt,
sttLanguage,
tts,
terminalManager,
providerSnapshotManager,
scriptRouteStore,
scriptRuntimeStore,
workspaceSetupSnapshots,
onBranchChanged,
getDaemonTcpPort,
getDaemonTcpHost,
resolveScriptHealth,
voice,
voiceBridge,
dictation,
serverId,
daemonVersion,
daemonRuntimeConfig,
} = options;
this.clientId = clientId;
this.appVersion = appVersion ?? null;
this.clientCapabilities = parseClientCapabilities(clientCapabilities);
this.sessionId = uuidv4();
this.onMessage = onMessage;
this.onBinaryMessage = onBinaryMessage ?? null;
this.onLifecycleIntent = onLifecycleIntent ?? null;
this.downloadTokenStore = downloadTokenStore;
this.pushTokenStore = pushTokenStore;
this.paseoHome = paseoHome;
this.sessionLogger = logger.child({
module: "session",
clientId: this.clientId,
sessionId: this.sessionId,
});
this.agentManager = agentManager;
this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry;
this.workspaceRegistry = workspaceRegistry;
this.chatService = chatService;
this.scheduleService = scheduleService;
this.loopService = loopService;
this.checkoutDiffManager = checkoutDiffManager;
this.github = github ?? createGitHubService();
this.workspaceGitService = workspaceGitService;
this.daemonConfigStore = daemonConfigStore;
this.mcpBaseUrl = mcpBaseUrl ?? null;
this.terminalManager = terminalManager;
this.terminalController = new TerminalSessionController({
terminalManager,
emit: (msg) => this.emit(msg),
emitBinary: (frame) => this.emitBinary(frame),
hasBinaryChannel: () => this.onBinaryMessage !== null,
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
sessionLogger: this.sessionLogger,
});
this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
paseoHome: this.paseoHome,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
github: this.github,
workspaceGitService: this.workspaceGitService,
createPaseoWorktreeWorkflow: (input, workflowOptions) =>
this.createPaseoWorktreeWorkflow(input, workflowOptions),
archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message),
emitAgentRemove: (agentId) => {
if (this.agentUpdatesSubscription) {
this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, {
kind: "remove",
agentId,
});
}
},
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
this.markWorkspaceArchiving(workspaceIds, archivingAt),
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
killTerminalsUnderPath: (rootPath) =>
this.terminalController.killTerminalsUnderPath(rootPath),
logger: this.sessionLogger,
});
this.providerSnapshotManager = providerSnapshotManager;
this.scriptRouteStore = scriptRouteStore ?? null;
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map();
this.onBranchChanged = onBranchChanged;
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
this.resolveScriptHealth = resolveScriptHealth ?? null;
this.sttLanguage = sttLanguage ?? "en";
this.subscribeToOptionalManagers();
this.bindVoiceBridges({ voice, voiceBridge, dictation });
this.serverId = serverId;
this.daemonVersion = daemonVersion;
this.daemonRuntimeConfig = daemonRuntimeConfig;
this.abortController = new AbortController();
this.workspaceDirectory = new WorkspaceDirectory({
logger: this.sessionLogger,
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
listAgentPayloads: () => this.listAgentPayloads(),
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
});
this.initializePerSessionManagers({ tts, stt, sttLanguage, dictation });
// Initialize agent MCP client asynchronously
void this.initializeAgentMcp();
this.subscribeToAgentEvents();
this.sessionLogger.trace({}, "agent.session.lifecycle.created");
}
updateAppVersion(appVersion: string | null): void {
if (appVersion && appVersion !== this.appVersion) {
this.appVersion = appVersion;
}
}
updateClientCapabilities(capabilities: Record<string, unknown> | null): void {
this.clientCapabilities = parseClientCapabilities(capabilities);
}
supports(capability: ClientCapability): boolean {
return this.clientCapabilities.has(capability);
}
// COMPAT(customModeIcons): rewrite icons unknown to v0.1.83 clients (whose MODE_ICONS
// map is a closed enum and would render `undefined`, crashing in render). Drop
// this and the cap gate when floor >= v0.1.84.
private downgradeModeIconsForClient<T extends { icon?: string }>(modes: T[]): T[] {
if (this.supports(CLIENT_CAPS.customModeIcons)) return modes;
return modes.map((mode) =>
mode.icon && !LEGACY_MODE_ICONS.has(mode.icon) ? { ...mode, icon: "ShieldCheck" } : mode,
);
}
private downgradeEntryModesForClient<T extends { modes?: { icon?: string }[] }>(
entries: T[],
): T[] {
if (this.supports(CLIENT_CAPS.customModeIcons)) return entries;
return entries.map((entry) =>
entry.modes ? { ...entry, modes: this.downgradeModeIconsForClient(entry.modes) } : entry,
);
}
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
this.syncWorkspaceGitObservers([descriptor]);
}
async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void> {
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true });
}
async archiveWorkspaceRecordForExternalMutation(workspaceId: string): Promise<void> {
await this.archiveWorkspaceRecord(workspaceId);
}
markWorkspaceArchivingForExternalMutation(
workspaceIds: Iterable<string>,
archivingAt: string,
): void {
this.markWorkspaceArchiving(workspaceIds, archivingAt);
}
clearWorkspaceArchivingForExternalMutation(workspaceIds: Iterable<string>): void {
this.clearWorkspaceArchiving(workspaceIds);
}
async emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable<string>): Promise<void> {
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
}
async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable<string>): Promise<void> {
await Promise.all(Array.from(cwds, (cwd) => this.emitWorkspaceUpdateForCwd(cwd)));
}
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
await this.syncWorkspaceGitObserverForWorkspace(workspace);
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
}
/**
* Get the client's current activity state
*/
public getClientActivity(): {
deviceType: "web" | "mobile";
focusedAgentId: string | null;
lastActivityAt: Date;
appVisible: boolean;
appVisibilityChangedAt: Date;
} | null {
return this.clientActivity;
}
private getFocusedAgentSelectionForCwd(cwd: string):
| {
provider?: string | null;
model?: string | null;
thinkingOptionId?: string | null;
}
| undefined {
const focusedAgentId = this.clientActivity?.focusedAgentId;
if (!focusedAgentId) {
return undefined;
}
const agent = this.agentManager.getAgent(focusedAgentId);
if (!agent || agent.cwd !== cwd) {
return undefined;
}
return {
provider: agent.provider,
model: agent.runtimeInfo?.model ?? agent.config.model ?? null,
thinkingOptionId:
agent.runtimeInfo?.thinkingOptionId ?? agent.config.thinkingOptionId ?? null,
};
}
private readStructuredGenerationDaemonConfig(): StructuredGenerationDaemonConfig {
return {
metadataGeneration: this.daemonConfigStore.get().metadataGeneration,
};
}
public getRuntimeMetrics(): SessionRuntimeMetrics {
const terminalMetrics = this.terminalController.getMetrics();
return {
terminalDirectorySubscriptionCount: terminalMetrics.directorySubscriptionCount,
terminalSubscriptionCount: terminalMetrics.streamSubscriptionCount,
inflightRequests: this.inflightRequests,
peakInflightRequests: this.peakInflightRequests,
};
}
public emitServerMessage(message: SessionOutboundMessage): void {
this.emit(message);
}
/**
* Send initial state to client after connection
*/
public async sendInitialState(): Promise<void> {
// No unsolicited agent list hydration. Callers must use fetch_agents_request.
}
/**
* Normalize a user prompt (with optional image metadata) for AgentManager
*/
private buildAgentPrompt(
text: string,
images?: Array<{ data: string; mimeType: string }>,
attachments?: AgentAttachment[],
): AgentPromptInput {
const normalized = text?.trim() ?? "";
const hasImages = Boolean(images && images.length > 0);
const hasAttachments = Boolean(attachments && attachments.length > 0);
if (!hasImages && !hasAttachments) {
return normalized;
}
const blocks: AgentPromptContentBlock[] = [];
if (normalized.length > 0) {
blocks.push({ type: "text", text: normalized });
}
for (const image of images ?? []) {
blocks.push({ type: "image", data: image.data, mimeType: image.mimeType });
}
for (const attachment of attachments ?? []) {
blocks.push(attachment);
}
return blocks;
}
/**
* Interrupt the agent's active run so the next prompt starts a fresh turn.
* Returns once the manager confirms the stream has been cancelled.
*/
private async interruptAgentIfRunning(agentId: string): Promise<void> {
const snapshot = this.agentManager.getAgent(agentId);
if (!snapshot) {
this.sessionLogger.trace({ agentId }, "agent.session.interrupt.not_found");
throw new Error(`Agent ${agentId} not found`);
}
const hasInFlightRun = this.agentManager.hasInFlightRun(agentId);
if (!hasInFlightRun) {
this.sessionLogger.trace(
{
agentId,
provider: snapshot.provider,
lifecycle: snapshot.lifecycle,
hasInFlightRun,
},
"agent.session.interrupt.skip_not_running",
);
return;
}
this.sessionLogger.debug(
{ agentId, lifecycle: snapshot.lifecycle, hasInFlightRun },
"interruptAgentIfRunning: interrupting",
);
const t0 = Date.now();
const cancelled = await this.agentManager.cancelAgentRun(agentId);
this.sessionLogger.debug(
{ agentId, cancelled, durationMs: Date.now() - t0 },
"interruptAgentIfRunning: cancelAgentRun completed",
);
if (!cancelled) {
this.sessionLogger.warn(
{ agentId },
"interruptAgentIfRunning: reported running but no active run was cancelled",
);
}
}
private hasActiveAgentRun(agentId: string | null): boolean {
if (!agentId) {
return false;
}
return this.agentManager.hasInFlightRun(agentId);
}
private handleAgentRunError(agentId: string, error: unknown, context: string): void {
const message = errorToFriendlyMessage(error);
this.sessionLogger.error({ err: error, agentId, context }, `${context} for agent ${agentId}`);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `${context}: ${message}`,
},
});
}
/**
* Initialize Agent MCP client for this session using the daemon's HTTP MCP endpoint.
*/
private async initializeAgentMcp(): Promise<void> {
try {
if (!this.mcpBaseUrl) {
this.sessionLogger.info(
"Skipping Agent MCP initialization because no MCP base URL is configured",
);
return;
}
const transport = new StreamableHTTPClientTransport(new URL(this.mcpBaseUrl));
this.agentMcpClient = await experimental_createMCPClient({
transport,
});
this.agentTools = (await this.agentMcpClient.tools()) as ToolSet;
const agentToolCount = Object.keys(this.agentTools ?? {}).length;
this.sessionLogger.trace({ agentToolCount }, "agent.session.mcp_init");
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to initialize Agent MCP");
}
}
/**
* Subscribe to AgentManager events and forward them to the client
*/
private subscribeToOptionalManagers(): void {
this.terminalController.start();
const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => {
// COMPAT(providersSnapshot): keep provider visibility gating for older clients.
const visibleEntries = entries.filter((entry) =>
this.isProviderVisibleToClient(entry.provider),
);
const snapshotCwd = cwd === resolveSnapshotCwd() ? undefined : cwd;
this.emit({
type: "providers_snapshot_update",
payload: {
...(snapshotCwd ? { cwd: snapshotCwd } : {}),
entries: this.downgradeEntryModesForClient(visibleEntries),
generatedAt: new Date().toISOString(),
},
});
};
this.providerSnapshotManager.on("change", handleProviderSnapshotChange);
this.unsubscribeProviderSnapshotEvents = () => {
this.providerSnapshotManager.off("change", handleProviderSnapshotChange);
};
}
private bindVoiceBridges(params: {
voice: SessionOptions["voice"];
voiceBridge: SessionOptions["voiceBridge"];
dictation: SessionOptions["dictation"];
}): void {
const { voice, voiceBridge, dictation } = params;
this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null);
this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler;
this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler;
this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext;
this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext;
this.getSpeechReadiness = dictation?.getSpeechReadiness;
}
private initializePerSessionManagers(params: {
tts: SessionOptions["tts"];
stt: SessionOptions["stt"];
sttLanguage: SessionOptions["sttLanguage"];
dictation: SessionOptions["dictation"];
}): void {
const { tts, stt, sttLanguage, dictation } = params;
this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts);
this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt, {
language: sttLanguage,
});
this.dictationStreamManager = new DictationStreamManager({
logger: this.sessionLogger,
sessionId: this.sessionId,
emit: (msg) => this.handleDictationManagerMessage(msg),
stt: dictation?.stt ?? null,
language: dictation?.sttLanguage,
finalTimeoutMs: dictation?.finalTimeoutMs,
});
}
private subscribeToAgentEvents(): void {
if (this.unsubscribeAgentEvents) {
this.unsubscribeAgentEvents();
}
this.unsubscribeAgentEvents = this.agentManager.subscribe(
(event) => {
if (event.type === "agent_state") {
this.sessionLogger.trace(
{
agentId: event.agent.id,
provider: event.agent.provider,
providerSessionId: event.agent.persistence?.sessionId ?? undefined,
turnId: event.agent.activeForegroundTurnId ?? undefined,
lifecycle: event.agent.lifecycle,
},
"agent.session.forward_update",
);
void this.forwardAgentUpdate(event.agent);
return;
}
if (
this.isVoiceMode &&
this.voiceModeAgentId === event.agentId &&
event.event.type === "permission_requested" &&
isVoicePermissionAllowed(event.event.request)
) {
const requestId = event.event.request.id;
void this.agentManager
.respondToPermission(event.agentId, requestId, {
behavior: "allow",
})
.catch((error) => {
this.sessionLogger.warn(
{
err: error,
agentId: event.agentId,
requestId,
},
"Failed to auto-allow speak tool permission in voice mode",
);
});
}
const serializedEvent = serializeAgentStreamEvent(event.event);
if (!serializedEvent) {
return;
}
this.sessionLogger.trace(
{
agentId: event.agentId,
provider: event.event.provider,
turnId: getAgentStreamEventTurnId(event.event),
seq: event.seq,
epoch: event.epoch,
event: event.event,
},
"agent.session.forward_stream",
);
this.emit({
type: "agent_stream",
payload: this.buildAgentStreamPayload(event, serializedEvent),
});
if (event.event.type === "permission_requested") {
this.emit({
type: "agent_permission_request",
payload: {
agentId: event.agentId,
request: event.event.request,
},
});
} else if (event.event.type === "permission_resolved") {
this.emit({
type: "agent_permission_resolved",
payload: {
agentId: event.agentId,
requestId: event.event.requestId,
resolution: event.event.resolution,
},
});
}
// Title updates may be applied asynchronously after agent creation.
},
{ replayState: false },
);
}
private buildAgentStreamPayload(
event: Extract<AgentManagerEvent, { type: "agent_stream" }>,
serializedEvent: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"],
): Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"] {
return {
agentId: event.agentId,
event: serializedEvent,
timestamp: event.timestamp ?? new Date().toISOString(),
...(typeof event.seq === "number" ? { seq: event.seq } : {}),
...(typeof event.epoch === "string" ? { epoch: event.epoch } : {}),
};
}
private async buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload> {
const storedRecord = await this.agentStorage.get(agent.id);
const title = storedRecord?.title ?? null;
const payload = toAgentPayload(agent, { title });
const storedUpdatedAt = storedRecord ? resolveStoredAgentPayloadUpdatedAt(storedRecord) : null;
if (storedUpdatedAt) {
const liveUpdatedAt = Date.parse(payload.updatedAt);
const persistedUpdatedAt = Date.parse(storedUpdatedAt);
if (Number.isNaN(liveUpdatedAt) || persistedUpdatedAt > liveUpdatedAt) {
payload.updatedAt = storedUpdatedAt;
}
}
payload.archivedAt = storedRecord?.archivedAt ?? null;
return payload;
}
private buildStoredAgentPayload(
record: StoredAgentRecord,
registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds(),
): AgentSnapshotPayload {
return buildStoredAgentPayload(record, registeredProviderIds);
}
private isProviderVisibleToClient(provider: string): boolean {
if (clientSupportsAllProviders(this.appVersion)) {
return true;
}
return LEGACY_PROVIDER_IDS.has(provider);
}
private filterEditorsForClient(
editors: EditorTargetDescriptorPayload[],
): EditorTargetDescriptorPayload[] {
if (clientSupportsFlexibleEditorIds(this.appVersion)) {
return editors;
}
return editors.filter((editor) => isLegacyEditorTargetId(editor.id));
}
private agentThinkingOptionMatchesFilter(
agent: AgentSnapshotPayload,
filter: AgentUpdatesFilter,
): boolean {
if (filter.thinkingOptionId === undefined) {
return true;
}
const expectedThinkingOptionId = resolveEffectiveThinkingOptionId({
configuredThinkingOptionId: filter.thinkingOptionId ?? null,
});
const resolvedThinkingOptionId =
agent.effectiveThinkingOptionId ??
resolveEffectiveThinkingOptionId({
runtimeInfo: agent.runtimeInfo,
configuredThinkingOptionId: agent.thinkingOptionId ?? null,
});
return resolvedThinkingOptionId === expectedThinkingOptionId;
}
private matchesAgentStructuralFilter(
agent: AgentSnapshotPayload,
project: ProjectPlacementPayload,
filter: AgentUpdatesFilter,
): boolean {
if (filter.statuses && filter.statuses.length > 0) {
const statuses = new Set(filter.statuses);
if (!statuses.has(agent.status)) {
return false;
}
}
if (typeof filter.requiresAttention === "boolean") {
const requiresAttention = agent.requiresAttention ?? false;
if (requiresAttention !== filter.requiresAttention) {
return false;
}
}
if (filter.projectKeys && filter.projectKeys.length > 0) {
const projectKeys = new Set(filter.projectKeys.filter((item) => item.trim().length > 0));
if (projectKeys.size > 0 && !projectKeys.has(project.projectKey)) {
return false;
}
}
return true;
}
private matchesAgentFilter(options: {
agent: AgentSnapshotPayload;
project: ProjectPlacementPayload;
filter?: AgentUpdatesFilter;
}): boolean {
const { agent, project, filter } = options;
if (filter?.labels) {
const matchesLabels = Object.entries(filter.labels).every(
([key, value]) => agent.labels[key] === value,
);
if (!matchesLabels) {
return false;
}
}
const includeArchived = filter?.includeArchived ?? false;
if (!includeArchived && agent.archivedAt) {
return false;
}
if (filter && !this.agentThinkingOptionMatchesFilter(agent, filter)) {
return false;
}
if (filter && !this.matchesAgentStructuralFilter(agent, project, filter)) {
return false;
}
return true;
}
private getAgentUpdateTargetId(update: AgentUpdatePayload): string {
return update.kind === "remove" ? update.agentId : update.agent.id;
}
private bufferOrEmitAgentUpdate(
subscription: AgentUpdatesSubscriptionState,
payload: AgentUpdatePayload,
): void {
if (payload.kind === "upsert" && !this.isProviderVisibleToClient(payload.agent.provider)) {
return;
}
if (subscription.isBootstrapping) {
subscription.pendingUpdatesByAgentId.set(this.getAgentUpdateTargetId(payload), payload);
return;
}
this.emit({
type: "agent_update",
payload,
});
}
private flushBootstrappedAgentUpdates(options?: {
snapshotUpdatedAtByAgentId?: Map<string, number>;
}): void {
const subscription = this.agentUpdatesSubscription;
if (!subscription || !subscription.isBootstrapping) {
return;
}
subscription.isBootstrapping = false;
const pending = Array.from(subscription.pendingUpdatesByAgentId.values());
subscription.pendingUpdatesByAgentId.clear();
for (const payload of pending) {
if (payload.kind === "upsert") {
const snapshotUpdatedAt = options?.snapshotUpdatedAtByAgentId?.get(payload.agent.id);
if (typeof snapshotUpdatedAt === "number") {
const updateUpdatedAt = Date.parse(payload.agent.updatedAt);
if (!Number.isNaN(updateUpdatedAt) && updateUpdatedAt <= snapshotUpdatedAt) {
continue;
}
}
}
this.emit({
type: "agent_update",
payload,
});
}
}
private async findWorkspaceByDirectory(
cwd: string,
options?: { refreshGit?: boolean },
): Promise<PersistedWorkspaceRecord | null> {
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options);
const workspaces = await this.workspaceRegistry.list();
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(normalizedCwd, workspaces);
return workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null;
}
private async findExactWorkspaceByDirectory(
cwd: string,
options?: { refreshGit?: boolean },
): Promise<PersistedWorkspaceRecord | null> {
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options);
const workspaces = await this.workspaceRegistry.list();
return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null;
}
private async resolveWorkspaceDirectory(
cwd: string,
options?: { refreshGit?: boolean },
): Promise<string> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
if (options?.refreshGit === false) {
const snapshot = this.workspaceGitService.peekSnapshot(normalizedCwd);
return normalizePersistedWorkspaceId(snapshot?.git.repoRoot ?? normalizedCwd);
}
const checkout = await this.workspaceGitService.getCheckout(normalizedCwd);
return normalizePersistedWorkspaceId(checkout.worktreeRoot ?? normalizedCwd);
}
private async buildProjectPlacementForWorkspace(
workspace: PersistedWorkspaceRecord,
projectRecord?: PersistedProjectRecord | null,
): Promise<ProjectPlacementPayload> {
const project = projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
if (!project) {
throw new Error(`Project not found for workspace ${workspace.workspaceId}`);
}
const checkout = buildWorkspaceCheckout(workspace, project);
return {
projectKey: project.projectId,
projectName: resolveProjectDisplayName(project),
checkout,
};
}
private async buildProjectPlacementForCwd(
cwd: string,
options?: { refreshGit?: boolean; fallback?: boolean },
): Promise<ProjectPlacementPayload | null> {
const workspace = await this.findWorkspaceByDirectory(cwd, {
refreshGit: options?.refreshGit,
});
if (!workspace) {
if (!options?.fallback) {
return null;
}
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
return {
projectKey: normalizedCwd,
projectName: deriveProjectGroupingName(normalizedCwd),
checkout: {
cwd: normalizedCwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
};
}
return this.buildProjectPlacementForWorkspace(workspace);
}
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
try {
const subscription = this.agentUpdatesSubscription;
const payload = await this.buildAgentPayload(agent);
if (subscription) {
const project = await this.buildProjectPlacementForCwd(payload.cwd, {
refreshGit: false,
fallback: true,
});
if (!project) {
throw new Error(`Workspace not found for agent ${payload.id}`);
}
const matches = this.matchesAgentFilter({
agent: payload,
project,
filter: subscription.filter,
});
if (matches) {
this.bufferOrEmitAgentUpdate(subscription, {
kind: "upsert",
agent: payload,
project,
});
} else {
this.bufferOrEmitAgentUpdate(subscription, {
kind: "remove",
agentId: payload.id,
});
}
}
await this.emitWorkspaceUpdateForCwd(payload.cwd);
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to emit agent update");
}
}
/**
* Main entry point for processing session messages
*/
public async handleMessage(msg: SessionInboundMessage): Promise<void> {
this.inflightRequests++;
if (this.inflightRequests > this.peakInflightRequests) {
this.peakInflightRequests = this.inflightRequests;
}
try {
this.sessionLogger.trace(
{
messageType: msg.type,
payloadBytes: JSON.stringify(msg).length,
},
"agent.session.inbound",
);
try {
await this.dispatchInboundMessage(msg);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.sessionLogger.error({ err }, "Error handling message");
const requestId =
"requestId" in msg && typeof msg.requestId === "string" ? msg.requestId : undefined;
if (typeof requestId === "string") {
try {
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: msg.type,
error: `Request failed: ${err.message}`,
code: "handler_error",
},
});
} catch (emitError) {
this.sessionLogger.error({ err: emitError }, "Failed to emit rpc_error");
}
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Error: ${err.message}`,
},
});
}
} finally {
this.inflightRequests--;
}
}
private async dispatchInboundMessage(msg: SessionInboundMessage): Promise<void> {
const promise =
this.dispatchVoiceAndControlMessage(msg) ??
this.dispatchAgentRewindMessage(msg) ??
this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ??
this.dispatchWorkspaceAndProjectMessage(msg) ??
this.dispatchProviderMessage(msg) ??
this.dispatchTerminalMessage(msg) ??
this.dispatchChatScheduleLoopMessage(msg) ??
this.dispatchMiscMessage(msg);
if (promise) await promise;
}
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "voice_audio_chunk":
return this.handleAudioChunk(msg);
case "abort_request":
return this.handleAbort();
case "audio_played":
this.handleAudioPlayed(msg.id);
return undefined;
case "set_voice_mode":
return this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId);
case "dictation_stream_start":
return this.handleDictationStreamStart(msg);
case "dictation_stream_chunk":
return this.dictationStreamManager.handleChunk({
dictationId: msg.dictationId,
seq: msg.seq,
audioBase64: msg.audio,
format: msg.format,
});
case "dictation_stream_finish":
return this.dictationStreamManager.handleFinish(msg.dictationId, msg.finalSeq);
case "dictation_stream_cancel":
this.dictationStreamManager.handleCancel(msg.dictationId);
return undefined;
case "restart_server_request":
return this.handleRestartServerRequest(msg.requestId, msg.reason);
case "shutdown_server_request":
return this.handleShutdownServerRequest(msg.requestId);
case "client_heartbeat":
this.handleClientHeartbeat(msg);
return undefined;
case "ping": {
const now = Date.now();
this.emit({
type: "pong",
payload: {
requestId: msg.requestId,
clientSentAt: msg.clientSentAt,
serverReceivedAt: now,
serverSentAt: now,
},
});
return undefined;
}
default:
return undefined;
}
}
private dispatchAgentRewindMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "agent.rewind.request":
return this.handleAgentRewindRequest(msg);
default:
return undefined;
}
}
private async handleDictationStreamStart(
msg: Extract<SessionInboundMessage, { type: "dictation_stream_start" }>,
): Promise<void> {
const unavailable = this.resolveVoiceFeatureUnavailableContext("dictation");
if (unavailable) {
this.emit({
type: "dictation_stream_error",
payload: {
dictationId: msg.dictationId,
error: unavailable.message,
retryable: unavailable.retryable,
reasonCode: unavailable.reasonCode,
missingModelIds: unavailable.missingModelIds,
},
});
return;
}
await this.dictationStreamManager.handleStart(msg.dictationId, msg.format);
}
private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "fetch_agents_request":
return this.handleFetchAgents(msg);
case "fetch_agent_history_request":
return this.handleFetchAgentHistory(msg);
case "fetch_recent_provider_sessions_request":
return this.handleFetchRecentProviderSessions(msg);
case "fetch_agent_request":
return this.handleFetchAgent(msg.agentId, msg.requestId);
case "delete_agent_request":
return this.handleDeleteAgentRequest(msg.agentId, msg.requestId);
case "archive_agent_request":
return this.handleArchiveAgentRequest(msg.agentId, msg.requestId);
case "close_items_request":
return this.handleCloseItemsRequest(msg);
case "update_agent_request":
return this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId);
case "project.rename.request":
return this.handleProjectRenameRequest(msg.projectId, msg.customName, msg.requestId);
case "send_agent_message_request":
return this.handleSendAgentMessageRequest(msg);
case "wait_for_finish_request":
return this.handleWaitForFinish(msg.agentId, msg.requestId, msg.timeoutMs);
case "create_agent_request":
return this.handleCreateAgentRequest(msg);
case "resume_agent_request":
return this.handleResumeAgentRequest(msg);
case "import_agent_request":
return this.handleImportAgentRequest(msg);
case "refresh_agent_request":
return this.handleRefreshAgentRequest(msg);
case "cancel_agent_request":
return this.handleCancelAgentRequest(msg.agentId, msg.requestId);
case "fetch_agent_timeline_request":
return this.handleFetchAgentTimelineRequest(msg);
case "agent_permission_response":
return this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response);
case "clear_agent_attention":
return this.handleClearAgentAttention(msg.agentId, msg.requestId);
default:
return undefined;
}
}
private dispatchAgentConfigMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "set_agent_mode_request":
return this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId);
case "set_agent_model_request":
return this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId);
case "set_agent_feature_request":
return this.handleSetAgentFeatureRequest(
msg.agentId,
msg.featureId,
msg.value,
msg.requestId,
);
case "set_agent_thinking_request":
return this.handleSetAgentThinkingRequest(msg.agentId, msg.thinkingOptionId, msg.requestId);
case "get_daemon_config_request":
this.emit({
type: "get_daemon_config_response",
payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() },
});
return undefined;
case "daemon.get_status.request":
return this.handleDaemonGetStatusRequest(msg);
case "daemon.get_pairing_offer.request":
return this.handleDaemonGetPairingOfferRequest(msg);
case "set_daemon_config_request":
this.emit({
type: "set_daemon_config_response",
payload: {
requestId: msg.requestId,
config: this.daemonConfigStore.patch(msg.config),
},
});
return undefined;
case "read_project_config_request":
return this.handleReadProjectConfigRequest(msg);
case "write_project_config_request":
return this.handleWriteProjectConfigRequest(msg);
default:
return undefined;
}
}
private async handleReadProjectConfigRequest(
msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>,
): Promise<void> {
const repoRoot = await resolveKnownProjectRootForConfig({
repoRoot: msg.repoRoot,
projectRegistry: this.projectRegistry,
});
if (!repoRoot) {
this.emitProjectConfigReadFailure(msg, { code: "project_not_found" });
return;
}
const result = readPaseoConfigForEdit(repoRoot);
if (!result.ok) {
this.sessionLogger.warn(
{ repoRoot, requestId: msg.requestId, outcome: result.error.code },
"Failed to read project config",
);
this.emitProjectConfigReadFailure(msg, result.error, repoRoot);
return;
}
if (result.config === null) {
this.sessionLogger.debug(
{ repoRoot, requestId: msg.requestId, outcome: "missing_project_config" },
"Project config missing",
);
}
this.emit({
type: "read_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: true,
config: result.config,
revision: result.revision,
},
});
}
private async handleWriteProjectConfigRequest(
msg: Extract<SessionInboundMessage, { type: "write_project_config_request" }>,
): Promise<void> {
const repoRoot = await resolveKnownProjectRootForConfig({
repoRoot: msg.repoRoot,
projectRegistry: this.projectRegistry,
});
if (!repoRoot) {
this.emitProjectConfigWriteFailure(msg, { code: "project_not_found" });
return;
}
this.sessionLogger.debug(
{ repoRoot, requestId: msg.requestId, outcome: "write_attempt" },
"Writing project config",
);
const result = writePaseoConfigForEdit({
repoRoot,
config: msg.config,
expectedRevision: msg.expectedRevision,
});
if (!result.ok) {
this.sessionLogger.debug(
{ repoRoot, requestId: msg.requestId, outcome: result.error.code },
"Project config write did not complete",
);
this.emitProjectConfigWriteFailure(msg, result.error, repoRoot);
return;
}
this.sessionLogger.debug(
{ repoRoot, requestId: msg.requestId, outcome: "written" },
"Project config written",
);
this.emit({
type: "write_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: true,
config: result.config,
revision: result.revision,
},
});
}
private emitProjectConfigReadFailure(
msg: Extract<SessionInboundMessage, { type: "read_project_config_request" }>,
error: ProjectConfigRpcError,
repoRoot = msg.repoRoot,
): void {
this.emit({
type: "read_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: false,
error,
},
});
}
private emitProjectConfigWriteFailure(
msg: Extract<SessionInboundMessage, { type: "write_project_config_request" }>,
error: ProjectConfigRpcError,
repoRoot = msg.repoRoot,
): void {
this.emit({
type: "write_project_config_response",
payload: {
requestId: msg.requestId,
repoRoot,
ok: false,
error,
},
});
}
// eslint-disable-next-line complexity
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "checkout_status_request":
return this.handleCheckoutStatusRequest(msg);
case "validate_branch_request":
return this.handleValidateBranchRequest(msg);
case "branch_suggestions_request":
return this.handleBranchSuggestionsRequest(msg);
case "directory_suggestions_request":
return this.handleDirectorySuggestionsRequest(msg);
case "subscribe_checkout_diff_request":
return this.handleSubscribeCheckoutDiffRequest(msg);
case "unsubscribe_checkout_diff_request":
this.handleUnsubscribeCheckoutDiffRequest(msg);
return undefined;
case "checkout_switch_branch_request":
return this.handleCheckoutSwitchBranchRequest(msg);
case "checkout.rename_branch.request":
return this.handleCheckoutRenameBranchRequest(msg);
case "checkout_commit_request":
return this.handleCheckoutCommitRequest(msg);
case "checkout_merge_request":
return this.handleCheckoutMergeRequest(msg);
case "checkout_merge_from_base_request":
return this.handleCheckoutMergeFromBaseRequest(msg);
case "checkout_pull_request":
return this.handleCheckoutPullRequest(msg);
case "checkout_push_request":
return this.handleCheckoutPushRequest(msg);
case "checkout.refresh.request":
return this.handleCheckoutRefreshRequest(msg);
case "checkout_pr_create_request":
return this.handleCheckoutPrCreateRequest(msg);
case "checkout_pr_merge_request":
return this.handleCheckoutPrMergeRequest(msg);
case "checkout.github.set_auto_merge.request":
return this.handleCheckoutGithubSetAutoMergeRequest(msg);
case "checkout_pr_status_request":
return this.handleCheckoutPrStatusRequest(msg);
case "pull_request_timeline_request":
return this.handlePullRequestTimelineRequest(msg);
case "github_search_request":
return this.handleGitHubSearchRequest(msg);
case "stash_save_request":
return this.handleStashSaveRequest(msg);
case "stash_pop_request":
return this.handleStashPopRequest(msg);
case "stash_list_request":
return this.handleStashListRequest(msg);
default:
return undefined;
}
}
private dispatchWorkspaceAndProjectMessage(
msg: SessionInboundMessage,
): Promise<void> | undefined {
switch (msg.type) {
case "fetch_workspaces_request":
return this.handleFetchWorkspacesRequest(msg);
case "paseo_worktree_list_request":
return this.handlePaseoWorktreeListRequest(msg);
case "paseo_worktree_archive_request":
return this.handlePaseoWorktreeArchiveRequest(msg);
case "create_paseo_worktree_request":
return this.handleCreatePaseoWorktreeRequest(msg);
case "workspace_setup_status_request":
return this.handleWorkspaceSetupStatusRequest(msg);
case "list_available_editors_request":
return this.handleListAvailableEditorsRequest(msg);
case "open_in_editor_request":
return this.handleOpenInEditorRequest(msg);
case "open_project_request":
return this.handleOpenProjectRequest(msg);
case "archive_workspace_request":
return this.handleArchiveWorkspaceRequest(msg);
case "file_explorer_request":
return this.handleFileExplorerRequest(msg);
case "project_icon_request":
return this.handleProjectIconRequest(msg);
case "file_download_token_request":
return this.handleFileDownloadTokenRequest(msg);
default:
return undefined;
}
}
private dispatchProviderMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "list_provider_models_request":
return this.handleListProviderModelsRequest(msg);
case "list_provider_modes_request":
return this.handleListProviderModesRequest(msg);
case "list_provider_features_request":
return this.handleListProviderFeaturesRequest(msg);
case "list_available_providers_request":
return this.handleListAvailableProvidersRequest(msg);
case "get_providers_snapshot_request":
return this.handleGetProvidersSnapshotRequest(msg);
case "refresh_providers_snapshot_request":
return this.handleRefreshProvidersSnapshotRequest(msg);
case "provider_diagnostic_request":
return this.handleProviderDiagnosticRequest(msg);
default:
return undefined;
}
}
private dispatchTerminalMessage(msg: SessionInboundMessage): Promise<void> | undefined {
if (msg.type === "start_workspace_script_request") {
return this.handleStartWorkspaceScriptRequest(msg);
}
return this.terminalController.dispatch(msg);
}
private dispatchChatScheduleLoopMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "chat/create":
return this.handleChatCreateRequest(msg);
case "chat/list":
return this.handleChatListRequest(msg);
case "chat/inspect":
return this.handleChatInspectRequest(msg);
case "chat/delete":
return this.handleChatDeleteRequest(msg);
case "chat/post":
return this.handleChatPostRequest(msg);
case "chat/read":
return this.handleChatReadRequest(msg);
case "chat/wait":
return this.handleChatWaitRequest(msg);
case "loop/run":
return this.handleLoopRunRequest(msg);
case "loop/list":
return this.handleLoopListRequest(msg);
case "loop/inspect":
return this.handleLoopInspectRequest(msg);
case "loop/logs":
return this.handleLoopLogsRequest(msg);
case "loop/stop":
return this.handleLoopStopRequest(msg);
default:
return this.dispatchScheduleMessage(msg);
}
}
private dispatchScheduleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "schedule/create":
return this.handleScheduleCreateRequest(msg);
case "schedule/list":
return this.handleScheduleListRequest(msg);
case "schedule/inspect":
return this.handleScheduleInspectRequest(msg);
case "schedule/logs":
return this.handleScheduleLogsRequest(msg);
case "schedule/pause":
return this.handleSchedulePauseRequest(msg);
case "schedule/resume":
return this.handleScheduleResumeRequest(msg);
case "schedule/delete":
return this.handleScheduleDeleteRequest(msg);
case "schedule/run-once":
return this.handleScheduleRunOnceRequest(msg);
case "schedule/update":
return this.handleScheduleUpdateRequest(msg);
default:
return undefined;
}
}
private async dispatchMiscMessage(msg: SessionInboundMessage): Promise<void> {
switch (msg.type) {
case "list_commands_request":
await this.handleListCommandsRequest(msg);
return;
case "register_push_token":
this.handleRegisterPushToken(msg.token);
return;
}
}
public resetPeakInflight(): void {
this.peakInflightRequests = this.inflightRequests;
}
public handleBinaryFrame(frame: TerminalStreamFrame): void {
this.terminalController.handleBinaryFrame(frame);
}
private async handleRestartServerRequest(requestId: string, reason?: string): Promise<void> {
const payload: { status: string } & Record<string, unknown> = {
status: "restart_requested",
clientId: this.clientId,
};
if (reason && reason.trim().length > 0) {
payload.reason = reason;
}
payload.requestId = requestId;
this.sessionLogger.warn({ reason }, "Restart requested via websocket");
this.emit({
type: "status",
payload,
});
this.emitLifecycleIntent({
type: "restart",
clientId: this.clientId,
requestId,
...(reason ? { reason } : {}),
});
}
private async handleShutdownServerRequest(requestId: string): Promise<void> {
this.sessionLogger.warn("Shutdown requested via websocket");
this.emit({
type: "status",
payload: {
status: "shutdown_requested",
clientId: this.clientId,
requestId,
},
});
this.emitLifecycleIntent({
type: "shutdown",
clientId: this.clientId,
requestId,
});
}
private emitLifecycleIntent(intent: SessionLifecycleIntent): void {
if (!this.onLifecycleIntent) {
return;
}
try {
this.onLifecycleIntent(intent);
} catch (error) {
this.sessionLogger.error({ err: error, intent }, "Lifecycle intent handler failed");
}
}
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`);
const knownCwd =
this.agentManager.getAgent(agentId)?.cwd ??
(await this.agentStorage.get(agentId))?.cwd ??
null;
// File-backed storage still needs an early delete fence before closeAgent().
beginAgentDeleteIfSupported(this.agentStorage, agentId);
try {
await closeAgentCommand({ agentManager: this.agentManager }, agentId);
} catch (error) {
this.sessionLogger.warn(
{ err: error, agentId },
`Failed to close agent ${agentId} during delete`,
);
}
// Drain queued persistence from the just-closed agent before removing its
// durable snapshot, otherwise an in-flight background write can recreate it.
await this.agentManager.flush();
try {
await this.agentStorage.remove(agentId);
await this.agentManager.deleteCommittedTimeline(agentId);
} catch (error) {
this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`);
}
this.emit({
type: "agent_deleted",
payload: {
agentId,
requestId,
},
});
if (this.agentUpdatesSubscription) {
this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, {
kind: "remove",
agentId,
});
}
if (knownCwd) {
await this.emitWorkspaceUpdateForCwd(knownCwd);
}
}
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`);
const { archivedAt } = await this.archiveAgentForClose(agentId);
this.emit({
type: "agent_archived",
payload: {
agentId,
archivedAt,
requestId,
},
});
}
private async archiveAgentForClose(
agentId: string,
): Promise<{ agentId: string; archivedAt: string }> {
const { archivedAt, record: archivedRecord } = await archiveAgentCommand(
{
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
},
agentId,
);
if (this.agentUpdatesSubscription) {
const payload = this.buildStoredAgentPayload(archivedRecord);
const project = await this.buildProjectPlacementForCwd(payload.cwd);
if (project) {
const matches = this.matchesAgentFilter({
agent: payload,
project,
filter: this.agentUpdatesSubscription.filter,
});
this.bufferOrEmitAgentUpdate(
this.agentUpdatesSubscription,
matches
? {
kind: "upsert",
agent: payload,
project,
}
: {
kind: "remove",
agentId,
},
);
} else {
this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, {
kind: "remove",
agentId,
});
}
await this.emitWorkspaceUpdateForCwd(payload.cwd);
}
return { agentId, archivedAt };
}
private async handleCloseItemsRequest(msg: CloseItemsRequest): Promise<void> {
const archiveResults = await Promise.allSettled(
msg.agentIds.map((agentId) => this.archiveAgentForClose(agentId)),
);
const agents = [];
for (let i = 0; i < archiveResults.length; i += 1) {
const result = archiveResults[i];
if (result.status === "fulfilled") {
agents.push(result.value);
} else {
this.sessionLogger.warn(
{ err: result.reason, agentId: msg.agentIds[i], requestId: msg.requestId },
"Failed to archive agent during close_items batch",
);
}
}
const terminals = [];
for (const terminalId of msg.terminalIds) {
try {
terminals.push(this.terminalController.killTerminalForClose(terminalId));
} catch (error) {
this.sessionLogger.warn(
{ err: error, terminalId, requestId: msg.requestId },
"Failed to kill terminal during close_items batch",
);
terminals.push({
terminalId,
success: false,
});
}
}
this.emit({
type: "close_items_response",
payload: {
agents,
terminals,
requestId: msg.requestId,
},
});
}
private async unarchiveAgentByHandle(handle: AgentPersistenceHandle): Promise<void> {
const records = await this.agentStorage.list();
const matched = records.find(
(record) =>
record.persistence?.provider === handle.provider &&
record.persistence?.sessionId === handle.sessionId,
);
if (!matched) {
return;
}
await unarchiveAgentState(this.agentStorage, this.agentManager, matched.id);
}
private async handleUpdateAgentRequest(
agentId: string,
name: string | undefined,
labels: Record<string, string> | undefined,
requestId: string,
): Promise<void> {
this.sessionLogger.info(
{
agentId,
requestId,
hasName: typeof name === "string",
labelCount: labels ? Object.keys(labels).length : 0,
},
"session: update_agent_request",
);
try {
const result = await updateAgentCommand(
{ agentManager: this.agentManager },
{ agentId, name, labels },
);
if (!result.accepted) {
this.emit({
type: "update_agent_response",
payload: {
requestId,
agentId,
accepted: false,
error: result.error,
},
});
return;
}
this.emit({
type: "update_agent_response",
payload: { requestId, agentId, accepted: true, error: null },
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId, requestId },
"session: update_agent_request error",
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to update agent: ${getErrorMessage(error)}`,
},
});
this.emit({
type: "update_agent_response",
payload: {
requestId,
agentId,
accepted: false,
error: getErrorMessageOr(error, "Failed to update agent"),
},
});
}
}
private async handleProjectRenameRequest(
projectId: string,
customName: string | null,
requestId: string,
): Promise<void> {
this.sessionLogger.info(
{ projectId, requestId, hasCustomName: typeof customName === "string" },
"session: project.rename.request",
);
try {
const existing = await this.projectRegistry.get(projectId);
if (!existing) {
this.emit({
type: "project.rename.response",
payload: {
requestId,
projectId,
accepted: false,
customName: null,
error: "Project not found",
},
});
return;
}
const trimmed = customName?.trim() ?? "";
const nextCustomName = trimmed.length === 0 ? null : trimmed;
await this.projectRegistry.upsert({
...existing,
customName: nextCustomName,
updatedAt: new Date().toISOString(),
});
this.emit({
type: "project.rename.response",
payload: {
requestId,
projectId,
accepted: true,
customName: nextCustomName,
error: null,
},
});
// Re-emit descriptors for every workspace under this project so the new
// resolved name lands in the UI immediately.
const workspaces = await this.workspaceRegistry.list();
const affectedWorkspaceIds = workspaces
.filter((workspace) => workspace.projectId === projectId)
.map((workspace) => workspace.workspaceId);
if (affectedWorkspaceIds.length > 0) {
await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds, {
skipReconcile: true,
});
}
} catch (error) {
this.sessionLogger.error(
{ err: error, projectId, requestId },
"session: project.rename.request error",
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to rename project: ${getErrorMessage(error)}`,
},
});
this.emit({
type: "project.rename.response",
payload: {
requestId,
projectId,
accepted: false,
customName: null,
error: getErrorMessageOr(error, "Failed to rename project"),
},
});
}
}
private toVoiceFeatureUnavailableContext(
state: SpeechReadinessState,
): VoiceFeatureUnavailableContext {
return {
reasonCode: state.reasonCode,
message: state.message,
retryable: state.retryable,
missingModelIds: [...state.missingModelIds],
};
}
private resolveModeReadinessState(
readiness: SpeechReadinessSnapshot,
mode: "voice_mode" | "dictation",
): SpeechReadinessState {
if (mode === "voice_mode") {
return readiness.realtimeVoice;
}
return readiness.dictation;
}
private getVoiceFeatureUnavailableResponseMetadata(
error: unknown,
): VoiceFeatureUnavailableResponseMetadata {
if (!(error instanceof VoiceFeatureUnavailableError)) {
return {};
}
return {
reasonCode: error.reasonCode,
retryable: error.retryable,
missingModelIds: error.missingModelIds,
};
}
private resolveVoiceFeatureUnavailableContext(
mode: "voice_mode" | "dictation",
): VoiceFeatureUnavailableContext | null {
const readiness = this.getSpeechReadiness?.();
if (!readiness) {
return null;
}
const modeReadiness = this.resolveModeReadinessState(readiness, mode);
if (!modeReadiness.enabled) {
return this.toVoiceFeatureUnavailableContext(modeReadiness);
}
if (!readiness.voiceFeature.available) {
return this.toVoiceFeatureUnavailableContext(readiness.voiceFeature);
}
if (!modeReadiness.available) {
return this.toVoiceFeatureUnavailableContext(modeReadiness);
}
return null;
}
/**
* Handle voice mode toggle
*/
private async handleSetVoiceMode(
enabled: boolean,
agentId?: string,
requestId?: string,
): Promise<void> {
const startedAt = Date.now();
try {
this.sessionLogger.info(
{ enabled, requestedAgentId: agentId ?? null, requestId: requestId ?? null },
"set_voice_mode started",
);
if (enabled) {
const unavailable = this.resolveVoiceFeatureUnavailableContext("voice_mode");
if (unavailable) {
throw new VoiceFeatureUnavailableError(unavailable);
}
const normalizedAgentId = this.parseVoiceTargetAgentId(agentId ?? "", "set_voice_mode");
if (
this.isVoiceMode &&
this.voiceModeAgentId &&
this.voiceModeAgentId !== normalizedAgentId
) {
this.sessionLogger.info(
{
previousAgentId: this.voiceModeAgentId,
nextAgentId: normalizedAgentId,
elapsedMs: Date.now() - startedAt,
},
"set_voice_mode disabling previous active voice agent",
);
await this.disableVoiceModeForActiveAgent(true);
}
if (!this.isVoiceMode || this.voiceModeAgentId !== normalizedAgentId) {
this.sessionLogger.info(
{ agentId: normalizedAgentId, elapsedMs: Date.now() - startedAt },
"set_voice_mode enabling voice for agent",
);
const refreshedAgentId = await this.enableVoiceModeForAgent(normalizedAgentId);
this.voiceModeAgentId = refreshedAgentId;
this.sessionLogger.info(
{ agentId: refreshedAgentId, elapsedMs: Date.now() - startedAt },
"set_voice_mode agent enable complete",
);
}
this.sessionLogger.info(
{ agentId: this.voiceModeAgentId, elapsedMs: Date.now() - startedAt },
"set_voice_mode starting voice turn controller",
);
await this.startVoiceTurnController();
this.sessionLogger.info(
{ agentId: this.voiceModeAgentId, elapsedMs: Date.now() - startedAt },
"set_voice_mode voice turn controller started",
);
this.isVoiceMode = true;
this.sessionLogger.info(
{
agentId: this.voiceModeAgentId,
elapsedMs: Date.now() - startedAt,
},
"Voice mode enabled for existing agent",
);
if (requestId) {
this.emit({
type: "set_voice_mode_response",
payload: {
requestId,
enabled: true,
agentId: this.voiceModeAgentId,
accepted: true,
error: null,
},
});
}
return;
}
this.sessionLogger.info(
{ agentId: this.voiceModeAgentId, elapsedMs: Date.now() - startedAt },
"set_voice_mode disabling active voice mode",
);
await this.disableVoiceModeForActiveAgent(true);
this.isVoiceMode = false;
this.sessionLogger.info({ elapsedMs: Date.now() - startedAt }, "Voice mode disabled");
if (requestId) {
this.emit({
type: "set_voice_mode_response",
payload: {
requestId,
enabled: false,
agentId: null,
accepted: true,
error: null,
},
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to set voice mode";
const unavailable = this.getVoiceFeatureUnavailableResponseMetadata(error);
this.sessionLogger.error(
{
err: error,
enabled,
requestedAgentId: agentId ?? null,
elapsedMs: Date.now() - startedAt,
},
"set_voice_mode failed",
);
if (requestId) {
this.emit({
type: "set_voice_mode_response",
payload: {
requestId,
enabled: this.isVoiceMode,
agentId: this.voiceModeAgentId,
accepted: false,
error: errorMessage,
...unavailable,
},
});
return;
}
throw error;
}
}
private parseVoiceTargetAgentId(rawId: string, source: string): string {
const parsed = AgentIdSchema.safeParse(rawId.trim());
if (!parsed.success) {
throw new Error(`${source}: agentId must be a UUID`);
}
return parsed.data;
}
private async enableVoiceModeForAgent(agentId: string): Promise<string> {
const startedAt = Date.now();
this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureAgentLoaded.start");
const existing = await ensureAgentLoaded(agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
this.sessionLogger.info(
{ agentId, elapsedMs: Date.now() - startedAt },
"enableVoiceModeForAgent.ensureAgentLoaded.done",
);
this.registerVoiceBridgeForAgent(agentId);
const baseConfig: VoiceModeBaseConfig = {
systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt),
};
this.voiceModeBaseConfig = baseConfig;
const refreshOverrides: Partial<AgentSessionConfig> = {
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true),
};
try {
this.sessionLogger.info(
{ agentId, elapsedMs: Date.now() - startedAt },
"enableVoiceModeForAgent.reloadAgentSession.start",
);
const refreshed = await this.agentManager.reloadAgentSession(agentId, refreshOverrides);
this.sessionLogger.info(
{ agentId, refreshedAgentId: refreshed.id, elapsedMs: Date.now() - startedAt },
"enableVoiceModeForAgent.reloadAgentSession.done",
);
return refreshed.id;
} catch (error) {
this.unregisterVoiceSpeakHandler?.(agentId);
this.unregisterVoiceCallerContext?.(agentId);
this.voiceModeBaseConfig = null;
throw error;
}
}
private async disableVoiceModeForActiveAgent(restoreAgentConfig: boolean): Promise<void> {
await this.stopVoiceTurnController();
const agentId = this.voiceModeAgentId;
if (!agentId) {
this.voiceModeBaseConfig = null;
return;
}
this.unregisterVoiceSpeakHandler?.(agentId);
this.unregisterVoiceCallerContext?.(agentId);
if (restoreAgentConfig && this.voiceModeBaseConfig) {
const baseConfig = this.voiceModeBaseConfig;
try {
await this.agentManager.reloadAgentSession(agentId, {
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false),
});
} catch (error) {
this.sessionLogger.warn(
{ err: error, agentId },
"Failed to restore agent config while disabling voice mode",
);
}
}
this.voiceModeBaseConfig = null;
this.voiceModeAgentId = null;
}
private handleDictationManagerMessage(msg: DictationStreamOutboundMessage): void {
this.emit(msg as unknown as SessionOutboundMessage);
}
private async startVoiceTurnController(): Promise<void> {
if (this.voiceTurnController) {
this.sessionLogger.info("startVoiceTurnController skipped: already running");
return;
}
const turnDetection = this.resolveVoiceTurnDetection();
if (!turnDetection) {
throw new Error("Voice turn detection is not configured");
}
const stt = this.sttManager.getProvider();
if (!stt) {
throw new Error("Voice speech-to-text is not configured");
}
this.sessionLogger.info(
{ providerId: turnDetection.id },
"startVoiceTurnController creating controller",
);
const controller = createVoiceTurnController({
logger: this.sessionLogger.child({ component: "voice-turn-controller" }),
turnDetection,
stt,
sttLanguage: this.sttLanguage,
callbacks: {
onSpeechStarted: async () => {
this.sessionLogger.debug("Voice VAD speech_started");
},
onPartialTranscript: async ({ segmentId, transcript }) => {
this.sessionLogger.info(
{ segmentId, transcriptLength: transcript.trim().length },
"voice_input_state emitting isSpeaking=true",
);
this.emit({
type: "voice_input_state",
payload: {
isSpeaking: true,
},
});
await this.handleVoiceSpeechStart();
},
onSpeechStopped: async () => {
this.handleVoiceSpeechStopped();
this.setPhase("transcribing");
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "system",
content: "Transcribing audio...",
},
});
},
onFinalTranscript: async ({
transcript,
language,
durationMs,
avgLogprob,
isLowConfidence,
}) => {
const requestId = uuidv4();
const transcriptText = isLowConfidence ? "" : transcript.trim();
if (isLowConfidence) {
this.sessionLogger.debug(
{ text: transcript, avgLogprob },
"Filtered low-confidence transcription (likely non-speech)",
);
}
this.sessionLogger.info(
{
requestId,
isVoiceMode: this.isVoiceMode,
transcriptLength: transcriptText.length,
transcript: transcriptText,
},
"Transcription result",
);
await this.handleTranscriptionResultPayload({
text: transcriptText,
requestId,
...(language ? { language } : {}),
duration: durationMs,
...(avgLogprob !== undefined ? { avgLogprob } : {}),
...(isLowConfidence !== undefined ? { isLowConfidence } : {}),
});
},
onError: (error) => {
this.sessionLogger.error({ err: error }, "Voice turn controller failed");
},
},
});
this.sessionLogger.info("startVoiceTurnController connecting controller");
await controller.start();
this.voiceTurnController = controller;
this.sessionLogger.info("startVoiceTurnController connected");
}
private async stopVoiceTurnController(): Promise<void> {
if (!this.voiceTurnController) {
return;
}
const controller = this.voiceTurnController;
this.voiceTurnController = null;
await controller.stop();
}
private handleVoiceSpeechStopped(): void {
this.sessionLogger.info("voice_input_state emitting isSpeaking=false");
this.emit({
type: "voice_input_state",
payload: {
isSpeaking: false,
},
});
}
/**
* Handle text message to agent (with optional image attachments)
*/
private async handleSendAgentMessage(
agentId: string,
text: string,
messageId?: string,
images?: Array<{ data: string; mimeType: string }>,
attachments?: AgentAttachment[],
runOptions?: AgentRunOptions,
options?: { spokenInput?: boolean },
): Promise<{ ok: true } | { ok: false; error: string }> {
this.sessionLogger.info(
{
agentId,
textPreview: text.substring(0, 50),
imageCount: images?.length ?? 0,
attachmentCount: attachments?.length ?? 0,
},
`Sending text to agent ${agentId}${
images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ""
}${
attachments && attachments.length > 0
? ` and ${attachments.length} structured attachment(s)`
: ""
}`,
);
const promptText = options?.spokenInput ? wrapSpokenInput(text) : text;
const prompt = this.buildAgentPrompt(promptText, images, attachments);
try {
await sendPromptToAgent({
agentManager: this.agentManager,
agentStorage: this.agentStorage,
agentId,
prompt,
messageId,
runOptions,
logger: this.sessionLogger,
});
return { ok: true };
} catch (error) {
this.handleAgentRunError(agentId, error, "Failed to send agent message");
return {
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Handle create agent request
*/
private async handleCreateAgentRequest(
msg: Extract<SessionInboundMessage, { type: "create_agent_request" }>,
): Promise<void> {
const {
config,
worktreeName,
requestId,
initialPrompt,
clientMessageId,
outputSchema,
git,
worktree,
autoArchive,
images,
attachments,
labels,
env,
} = msg;
this.sessionLogger.info(
{ cwd: config.cwd, provider: config.provider, worktreeName },
`Creating agent in ${config.cwd} (${config.provider})${
worktreeName ? ` with worktree ${worktreeName}` : ""
}`,
);
let createdWorktreeForCleanup: CreatePaseoWorktreeWorkflowResult | null = null;
let createdAgentId: string | null = null;
try {
const trimmedPrompt = initialPrompt?.trim();
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
configTitle: config.title,
initialPrompt: trimmedPrompt,
});
const firstAgentContext: FirstAgentContext = {
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}),
};
const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({
cwd: config.cwd,
target: worktree,
firstAgentContext,
hasLegacyGitOptions: Boolean(git),
});
createdWorktreeForCleanup = createdWorktree;
const createAgentConfig: AgentSessionConfig = createdWorktree
? { ...config, cwd: createdWorktree.worktree.worktreePath }
: config;
const { snapshot, liveSnapshot } = await createAgentCommand(
{
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
paseoHome: this.paseoHome,
workspaceGitService: this.workspaceGitService,
providerSnapshotManager: this.providerSnapshotManager,
daemonConfig: this.readStructuredGenerationDaemonConfig(),
},
{
kind: "session",
config: createAgentConfig,
workspaceId: msg.workspaceId,
worktreeName,
initialPrompt,
clientMessageId,
outputSchema,
images,
attachments,
git,
labels,
env,
provisionalTitle,
explicitTitle,
firstAgentContext,
buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
resolveWorkspace: ({ cwd, workspaceId }) =>
this.resolveCreateAgentWorkspace(cwd, workspaceId),
},
);
createdAgentId = snapshot.id;
await this.forwardAgentUpdate(snapshot);
this.createAgentLifecycleDispatch.registerAutoArchiveIfRequested({
autoArchive,
agentId: snapshot.id,
createdWorktree,
});
if (requestId) {
const agentPayload = await this.buildAgentPayload(liveSnapshot);
this.emit({
type: "status",
payload: {
status: "agent_created",
agentId: liveSnapshot.id,
requestId,
agent: agentPayload,
},
});
}
this.sessionLogger.info(
{ agentId: snapshot.id, provider: snapshot.provider },
`Created agent ${snapshot.id} (${snapshot.provider})`,
);
} catch (error) {
await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({
createdWorktree: createdWorktreeForCleanup,
createdAgentId,
});
const wireError = toWorktreeWireError(error);
this.sessionLogger.error({ err: error }, "Failed to create agent");
if (requestId) {
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId,
error: wireError.message,
errorCode: wireError.code,
},
});
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to create agent: ${wireError.message}`,
},
});
}
}
private async resolveCreateAgentWorkspace(
cwd: string,
workspaceId?: string,
): Promise<{ workspaceId: string }> {
const resolvedWorkspace = workspaceId
? await this.workspaceRegistry.get(workspaceId)
: ((await this.findWorkspaceByDirectory(cwd)) ??
(await this.findOrCreateWorkspaceForDirectory(cwd)));
if (!resolvedWorkspace) {
throw new Error(`Workspace not found: ${workspaceId}`);
}
return { workspaceId: resolvedWorkspace.workspaceId };
}
private async handleResumeAgentRequest(
msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>,
): Promise<void> {
const { handle, overrides, requestId } = msg;
if (!handle) {
this.sessionLogger.warn("Resume request missing persistence handle");
if (requestId) {
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: msg.type,
error: "Unable to resume agent: missing persistence handle",
code: "agent_resume_failed",
},
});
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: "Unable to resume agent: missing persistence handle",
},
});
return;
}
this.sessionLogger.info(
{ sessionId: handle.sessionId, provider: handle.provider },
`Resuming agent ${handle.sessionId} (${handle.provider})`,
);
try {
await this.unarchiveAgentByHandle(handle);
const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides);
await unarchiveAgentState(this.agentStorage, this.agentManager, snapshot.id);
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
await this.forwardAgentUpdate(snapshot);
const timelineSize = this.agentManager.getTimeline(snapshot.id).length;
if (requestId) {
const agentPayload = await this.buildAgentPayload(snapshot);
this.emit({
type: "status",
payload: {
status: "agent_resumed",
agentId: snapshot.id,
requestId,
timelineSize,
agent: agentPayload,
},
});
}
} catch (error) {
const message = getErrorMessage(error);
this.sessionLogger.error({ err: error }, "Failed to resume agent");
if (requestId) {
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: msg.type,
error: message,
code: "agent_resume_failed",
},
});
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to resume agent: ${message}`,
},
});
}
}
private async handleImportAgentRequest(
msg: Extract<SessionInboundMessage, { type: "import_agent_request" }>,
): Promise<void> {
const normalized = normalizeImportAgentRequest(msg);
if ("error" in normalized) {
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId: msg.requestId,
error: normalized.error,
},
});
return;
}
const { provider, providerHandleId, requestId } = normalized;
this.sessionLogger.info(
{ providerHandleId, provider },
`Importing agent ${providerHandleId} (${provider})`,
);
try {
const { snapshot, timelineSize } = await importProviderSession({
request: normalized,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
workspaceGitService: this.workspaceGitService,
providerSnapshotManager: this.providerSnapshotManager,
daemonConfig: this.readStructuredGenerationDaemonConfig(),
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
await this.registerWorkspaceForImportedAgent(snapshot.cwd);
await this.forwardAgentUpdate(snapshot);
const agentPayload = await this.buildAgentPayload(snapshot);
this.emit({
type: "status",
payload: {
status: "agent_resumed",
agentId: snapshot.id,
requestId,
timelineSize,
agent: agentPayload,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.sessionLogger.error({ err: error }, "Failed to import agent");
this.emit({
type: "status",
payload: {
status: "agent_create_failed",
requestId,
error: message,
},
});
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to import agent: ${message}`,
},
});
}
}
private async handleRefreshAgentRequest(
msg: Extract<SessionInboundMessage, { type: "refresh_agent_request" }>,
): Promise<void> {
const { agentId, requestId } = msg;
this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`);
try {
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
let snapshot: ManagedAgent;
const existing = this.agentManager.getAgent(agentId);
if (existing) {
await this.interruptAgentIfRunning(agentId);
snapshot = await this.agentManager.reloadAgentSession(agentId, undefined, {
rehydrateFromDisk: true,
});
} else {
const record = await this.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds();
if (!isStoredAgentProviderAvailable(record, registeredProviderIds)) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
}
const handle = toAgentPersistenceHandle(registeredProviderIds, record.persistence);
if (!handle) {
throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`);
}
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
);
}
await this.agentManager.hydrateTimelineFromProvider(agentId);
await this.forwardAgentUpdate(snapshot);
const timelineSize = this.agentManager.getTimeline(agentId).length;
if (requestId) {
this.emit({
type: "status",
payload: {
status: "agent_refreshed",
agentId,
requestId,
timelineSize,
},
});
}
} catch (error) {
const message = getErrorMessage(error);
this.sessionLogger.error({ err: error, agentId }, `Failed to refresh agent ${agentId}`);
if (requestId) {
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: msg.type,
error: message,
code: "agent_refresh_failed",
},
});
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to refresh agent: ${message}`,
},
});
}
}
private async handleCancelAgentRequest(agentId: string, requestId?: string): Promise<void> {
this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`);
try {
await cancelAgentRunCommand(
{ agentManager: this.agentManager, logger: this.sessionLogger },
agentId,
);
if (requestId) {
const agent = this.agentManager.getAgent(agentId);
const payload = agent ? await this.buildAgentPayload(agent) : null;
this.emit({
type: "cancel_agent_response",
payload: {
requestId,
agentId,
agent: payload,
},
});
}
} catch (error) {
this.handleAgentRunError(agentId, error, "Failed to cancel running agent on request");
}
}
private async handleAgentRewindRequest(
msg: Extract<SessionInboundMessage, { type: "agent.rewind.request" }>,
): Promise<void> {
try {
await this.agentManager.rewind(msg.agentId, msg.messageId, msg.mode);
this.emit({
type: "agent.rewind.response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
ok: true,
error: null,
},
});
} catch (error) {
this.emit({
type: "agent.rewind.response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
ok: false,
error: error instanceof Error ? error.message : "Failed to rewind agent",
},
});
}
}
private async buildAgentSessionConfig(
config: AgentSessionConfig,
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
firstAgentContext?: FirstAgentContext,
): Promise<{
sessionConfig: AgentSessionConfig;
setupContinuation?: CreatePaseoWorktreeWorkflowResult["setupContinuation"];
}> {
return buildWorktreeAgentSessionConfig(
{
paseoHome: this.paseoHome,
sessionLogger: this.sessionLogger,
workspaceGitService: this.workspaceGitService,
createPaseoWorktree: (input, serviceOptions) =>
this.createPaseoWorktreeWorkflow(input, {
...serviceOptions,
setupContinuation: {
kind: "agent",
terminalManager: this.terminalManager,
appendTimelineItem: ({ agentId, item }) =>
appendTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId,
item,
}),
emitLiveTimelineItem: ({ agentId, item }) =>
emitLiveTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId,
item,
}),
logger: this.sessionLogger,
},
}),
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
createBranchFromBase: (params) => this.createBranchFromBase(params),
github: this.github,
},
config,
gitOptions,
legacyWorktreeName,
firstAgentContext,
);
}
private scheduleAutoNameWorkspaceBranchForFirstAgent(input: {
workspace: PersistedWorkspaceRecord;
firstAgentContext: FirstAgentContext;
}): void {
setTimeout(() => {
void this.maybeAutoNameWorkspaceBranchForFirstAgent(input).catch((error) => {
this.sessionLogger.warn(
{ err: error, cwd: input.workspace.cwd },
"Failed to auto-name worktree branch",
);
});
}, 0);
}
private async maybeAutoNameWorkspaceBranchForFirstAgent(input: {
workspace: PersistedWorkspaceRecord;
firstAgentContext: FirstAgentContext;
}): Promise<PersistedWorkspaceRecord> {
const result = await attemptFirstAgentBranchAutoName({
cwd: input.workspace.cwd,
firstAgentContext: input.firstAgentContext,
generateBranchNameFromContext: ({ cwd, firstAgentContext }) => {
return generateBranchNameFromFirstAgentContext({
agentManager: this.agentManager,
cwd,
workspaceGitService: this.workspaceGitService,
providerSnapshotManager: this.providerSnapshotManager,
daemonConfig: this.readStructuredGenerationDaemonConfig(),
currentSelection: this.getFocusedAgentSelectionForCwd(cwd),
firstAgentContext,
logger: this.sessionLogger,
});
},
});
if (!result.renamed || !result.branchName) {
return input.workspace;
}
const updatedWorkspace: PersistedWorkspaceRecord = {
...input.workspace,
displayName: result.branchName,
updatedAt: new Date().toISOString(),
};
await this.workspaceRegistry.upsert(updatedWorkspace);
await this.notifyGitMutation(input.workspace.cwd, "rename-branch");
await this.emitWorkspaceUpdateForCwd(input.workspace.cwd);
return updatedWorkspace;
}
private emitProviderDisabledResponse(
kind: "models" | "modes",
provider: AgentProvider,
requestId: string,
fetchedAt: string,
): void {
const payload = {
provider,
error: `Provider ${provider} is disabled`,
fetchedAt,
requestId,
};
if (kind === "models") {
this.emit({ type: "list_provider_models_response", payload });
} else {
this.emit({ type: "list_provider_modes_response", payload });
}
}
private async handleListProviderModelsRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>,
): Promise<void> {
const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined);
const fetchedAt = new Date().toISOString();
const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
if (!entry) {
this.emit({
type: "list_provider_models_response",
payload: {
provider: msg.provider,
error: `Unknown provider: ${msg.provider}`,
fetchedAt,
requestId: msg.requestId,
},
});
return;
}
if (!entry.enabled) {
this.emitProviderDisabledResponse("models", msg.provider, msg.requestId, fetchedAt);
return;
}
if (entry.status === "ready") {
this.emit({
type: "list_provider_models_response",
payload: {
provider: msg.provider,
models: entry.models ?? [],
error: null,
fetchedAt: entry.fetchedAt ?? fetchedAt,
requestId: msg.requestId,
},
});
return;
}
const errorMessage =
entry.status === "error"
? (entry.error ?? `Failed to list models for ${msg.provider}`)
: `Provider ${msg.provider} is not available`;
this.emit({
type: "list_provider_models_response",
payload: {
provider: msg.provider,
error: errorMessage,
fetchedAt,
requestId: msg.requestId,
},
});
}
private async handleListProviderModesRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_modes_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined);
const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
if (!entry) {
this.emit({
type: "list_provider_modes_response",
payload: {
provider: msg.provider,
error: `Unknown provider: ${msg.provider}`,
fetchedAt,
requestId: msg.requestId,
},
});
return;
}
if (!entry.enabled) {
this.emitProviderDisabledResponse("modes", msg.provider, msg.requestId, fetchedAt);
return;
}
if (entry.status === "ready") {
this.emit({
type: "list_provider_modes_response",
payload: {
provider: msg.provider,
modes: this.downgradeModeIconsForClient(entry.modes ?? []),
error: null,
fetchedAt: entry.fetchedAt ?? fetchedAt,
requestId: msg.requestId,
},
});
return;
}
const errorMessage =
entry.status === "error"
? (entry.error ?? `Failed to list modes for ${msg.provider}`)
: `Provider ${msg.provider} is not available`;
this.emit({
type: "list_provider_modes_response",
payload: {
provider: msg.provider,
error: errorMessage,
fetchedAt,
requestId: msg.requestId,
},
});
}
private async getProviderSnapshotEntryForRead(
cwd: string,
provider: AgentProvider,
): Promise<ProviderSnapshotEntry | undefined> {
const manager = this.providerSnapshotManager;
const findEntry = () =>
manager.getSnapshot(cwd).find((candidate) => candidate.provider === provider);
let entry = findEntry();
if (entry && !entry.enabled) {
return entry;
}
if (!entry || entry.status === "loading") {
// Awaits the in-flight warmup (deduped per-cwd) so old clients still get
// a resolved answer rather than a loading placeholder.
await manager.warmUpSnapshotForCwd({ cwd, providers: [provider] });
entry = findEntry();
}
return entry;
}
private buildDraftAgentSessionConfig(draftConfig: {
provider: AgentProvider;
cwd: string;
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
}): AgentSessionConfig {
return {
provider: draftConfig.provider,
cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}),
...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}),
...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}),
};
}
private async handleListProviderFeaturesRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_features_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig);
const features = await this.agentManager.listDraftFeatures(sessionConfig);
this.emit({
type: "list_provider_features_response",
payload: {
provider: msg.draftConfig.provider,
features,
error: null,
fetchedAt,
requestId: msg.requestId,
},
});
} catch (error) {
this.sessionLogger.error(
{ err: error, provider: msg.draftConfig.provider, draftConfig: msg.draftConfig },
`Failed to list features for ${msg.draftConfig.provider}`,
);
this.emit({
type: "list_provider_features_response",
payload: {
provider: msg.draftConfig.provider,
error: getErrorMessage(error),
fetchedAt,
requestId: msg.requestId,
},
});
}
}
private async handleDaemonGetStatusRequest(
msg: Extract<SessionInboundMessage, { type: "daemon.get_status.request" }>,
): Promise<void> {
try {
const pidInfo = await getPidLockInfo(this.paseoHome);
const providers = (await this.agentManager.listProviderAvailability()).map((p) => ({
provider: p.provider,
available: p.available,
error: p.error ?? null,
}));
this.emit({
type: "daemon.get_status.response",
payload: {
requestId: msg.requestId,
serverId: this.serverId ?? "",
version: this.daemonVersion ?? null,
pid: process.pid,
nodePath: process.execPath,
startedAt: pidInfo?.startedAt ?? null,
listen: this.daemonRuntimeConfig?.listen ?? null,
relay: this.daemonRuntimeConfig?.relay ?? null,
providers,
},
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to handle daemon status request");
this.emit({
type: "daemon.get_status.response",
payload: {
requestId: msg.requestId,
serverId: this.serverId ?? "",
version: this.daemonVersion ?? null,
pid: process.pid,
nodePath: process.execPath,
startedAt: null,
listen: null,
relay: null,
providers: [],
},
});
}
}
private async handleDaemonGetPairingOfferRequest(
msg: Extract<SessionInboundMessage, { type: "daemon.get_pairing_offer.request" }>,
): Promise<void> {
try {
const relay = this.daemonRuntimeConfig?.relay;
const pairing = await generateLocalPairingOffer({
paseoHome: this.paseoHome,
relayEnabled: relay?.enabled ?? true,
relayEndpoint: relay?.endpoint,
relayPublicEndpoint: relay?.publicEndpoint,
relayUseTls: relay?.useTls,
relayPublicUseTls: relay?.publicUseTls,
includeQr: true,
logger: this.sessionLogger,
});
this.emit({
type: "daemon.get_pairing_offer.response",
payload: {
requestId: msg.requestId,
url: pairing.url ?? "",
qr: pairing.qr ?? null,
relayEnabled: pairing.relayEnabled,
},
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to handle daemon pairing offer request");
this.emit({
type: "rpc_error",
payload: {
requestId: msg.requestId,
requestType: "daemon.get_pairing_offer.request",
error: error instanceof Error ? error.message : String(error),
},
});
}
}
private async handleListAvailableProvidersRequest(
msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const providers = (await this.agentManager.listProviderAvailability()).filter((provider) =>
this.isProviderVisibleToClient(provider.provider),
);
this.emit({
type: "list_available_providers_response",
payload: {
providers,
error: null,
fetchedAt,
requestId: msg.requestId,
},
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to list provider availability");
this.emit({
type: "list_available_providers_response",
payload: {
providers: [],
error: getErrorMessage(error),
fetchedAt,
requestId: msg.requestId,
},
});
}
}
private async handleGetProvidersSnapshotRequest(
msg: Extract<SessionInboundMessage, { type: "get_providers_snapshot_request" }>,
): Promise<void> {
// COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow.
const entries = this.providerSnapshotManager
.getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined)
.filter((entry) => this.isProviderVisibleToClient(entry.provider));
this.emit({
type: "get_providers_snapshot_response",
payload: {
entries: this.downgradeEntryModesForClient(entries),
generatedAt: new Date().toISOString(),
requestId: msg.requestId,
},
});
}
private async handleRefreshProvidersSnapshotRequest(
msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
): Promise<void> {
if (msg.cwd) {
await this.providerSnapshotManager.refreshSnapshotForCwd({
cwd: expandTilde(msg.cwd),
providers: msg.providers,
});
} else {
await this.providerSnapshotManager.refreshSettingsSnapshot({
providers: msg.providers,
});
}
this.emit({
type: "refresh_providers_snapshot_response",
payload: {
acknowledged: true,
requestId: msg.requestId,
},
});
}
private async handleProviderDiagnosticRequest(
msg: Extract<SessionInboundMessage, { type: "provider_diagnostic_request" }>,
): Promise<void> {
try {
const { diagnostic } = await this.providerSnapshotManager.getProviderDiagnostic(msg.provider);
this.emit({
type: "provider_diagnostic_response",
payload: {
provider: msg.provider,
diagnostic,
requestId: msg.requestId,
},
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.sessionLogger.error(
{ err, provider: msg.provider },
`Failed to get provider diagnostic for ${msg.provider}`,
);
this.emit({
type: "rpc_error",
payload: {
requestId: msg.requestId,
requestType: msg.type,
error: `Failed to get provider diagnostic: ${err.message}`,
code: "provider_diagnostic_failed",
},
});
}
}
private assertSafeGitRef(ref: string, label: string): void {
if (!/^[A-Za-z0-9._/-]+$/.test(ref)) {
throw new Error(`Invalid ${label}: ${ref}`);
}
assertWorktreeSafeGitRef(ref, label);
}
private isPathWithinRoot(rootPath: string, candidatePath: string): boolean {
const resolvedRoot = resolve(rootPath);
const resolvedCandidate = resolve(candidatePath);
if (resolvedCandidate === resolvedRoot) {
return true;
}
return resolvedCandidate.startsWith(resolvedRoot + sep);
}
private async generateCommitMessage(cwd: string): Promise<string> {
const diff = await this.workspaceGitService.getCheckoutDiff(cwd, {
mode: "uncommitted",
includeStructured: true,
});
const schema = z.object({
message: z
.string()
.min(1)
.max(72)
.describe("Concise git commit message, imperative mood, no trailing period."),
});
const fileList =
diff.structured && diff.structured.length > 0
? [
"Files changed:",
...diff.structured.map((file) => {
const changeType = diffChangeTypeFor(file);
const status = file.status && file.status !== "ok" ? ` [${file.status}]` : "";
return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`;
}),
].join("\n")
: "Files changed: (unknown)";
const maxPatchChars = 120_000;
const patch =
diff.diff.length > maxPatchChars
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
: diff.diff;
const prompt = await buildMetadataPrompt({
cwd,
workspaceGitService: this.workspaceGitService,
configKey: "commitMessage",
before: "Write a concise git commit message for the changes below.",
after: [
"Return JSON only with a single field 'message'.",
"",
fileList,
"",
patch.length > 0 ? patch : "(No diff available)",
].join("\n"),
});
const providers = await resolveStructuredGenerationProviders({
cwd,
providerSnapshotManager: this.providerSnapshotManager,
daemonConfig: this.readStructuredGenerationDaemonConfig(),
currentSelection: this.getFocusedAgentSelectionForCwd(cwd),
});
try {
const result = await generateStructuredAgentResponseWithFallback({
manager: this.agentManager,
cwd,
prompt,
schema,
schemaName: "CommitMessage",
maxRetries: 2,
providers,
persistSession: false,
agentConfigOverrides: {
title: "Commit generator",
internal: true,
},
});
return result.message;
} catch (error) {
if (
error instanceof StructuredAgentResponseError ||
error instanceof StructuredAgentFallbackError
) {
return "Update files";
}
throw error;
}
}
private async generatePullRequestText(
cwd: string,
baseRef?: string,
): Promise<{
title: string;
body: string;
}> {
const diff = await this.workspaceGitService.getCheckoutDiff(cwd, {
mode: "base",
baseRef,
includeStructured: true,
});
const schema = z.object({
title: z.string().min(1).max(72),
body: z.string().min(1),
});
const fileList =
diff.structured && diff.structured.length > 0
? [
"Files changed:",
...diff.structured.map((file) => {
const changeType = diffChangeTypeFor(file);
const status = file.status && file.status !== "ok" ? ` [${file.status}]` : "";
return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`;
}),
].join("\n")
: "Files changed: (unknown)";
const maxPatchChars = 200_000;
const patch =
diff.diff.length > maxPatchChars
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
: diff.diff;
const prompt = await buildMetadataPrompt({
cwd,
workspaceGitService: this.workspaceGitService,
configKey: "pullRequest",
before: "Write a pull request title and body for the changes below.",
after: [
"Return JSON only with fields 'title' and 'body'.",
"",
fileList,
"",
patch.length > 0 ? patch : "(No diff available)",
].join("\n"),
});
const providers = await resolveStructuredGenerationProviders({
cwd,
providerSnapshotManager: this.providerSnapshotManager,
daemonConfig: this.readStructuredGenerationDaemonConfig(),
currentSelection: this.getFocusedAgentSelectionForCwd(cwd),
});
try {
return await generateStructuredAgentResponseWithFallback({
manager: this.agentManager,
cwd,
prompt,
schema,
schemaName: "PullRequest",
maxRetries: 2,
providers,
persistSession: false,
agentConfigOverrides: {
title: "PR generator",
internal: true,
},
});
} catch (error) {
if (
error instanceof StructuredAgentResponseError ||
error instanceof StructuredAgentFallbackError
) {
return {
title: "Update changes",
body: "Automated PR generated by Paseo.",
};
}
throw error;
}
}
private async ensureCleanWorkingTree(cwd: string): Promise<void> {
const dirty = await this.isWorkingTreeDirty(cwd);
if (dirty) {
throw new Error(
"Working directory has uncommitted changes. Commit or stash before switching branches.",
);
}
}
private async isWorkingTreeDirty(cwd: string): Promise<boolean> {
try {
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
return snapshot.git.isDirty === true;
} catch (error) {
throw new Error(`Unable to inspect git status for ${cwd}: ${getErrorMessage(error)}`, {
cause: error,
});
}
}
private async checkoutExistingBranch(
cwd: string,
branch: string,
): Promise<CheckoutExistingBranchResult> {
this.assertSafeGitRef(branch, "branch");
const resolution = await this.workspaceGitService.validateBranchRef(cwd, branch);
if (resolution.kind === "not-found") {
throw new Error(`Branch not found: ${branch}`);
}
await this.ensureCleanWorkingTree(cwd);
const result = await checkoutResolvedBranch({
cwd,
resolution,
});
await this.notifyGitMutation(cwd, "switch-branch", { invalidateGithub: true });
return result;
}
private async createBranchFromBase(params: {
cwd: string;
baseBranch: string;
newBranchName: string;
}): Promise<void> {
const { cwd, baseBranch, newBranchName } = params;
this.assertSafeGitRef(baseBranch, "base branch");
this.assertSafeGitRef(newBranchName, "new branch");
const baseResolution = await this.workspaceGitService.validateBranchRef(cwd, baseBranch);
if (baseResolution.kind === "not-found") {
throw new Error(`Base branch not found: ${baseBranch}`);
}
const exists = await this.doesLocalBranchExist(cwd, newBranchName);
if (exists) {
throw new Error(`Branch already exists: ${newBranchName}`);
}
await this.ensureCleanWorkingTree(cwd);
await execCommand("git", ["checkout", "-b", newBranchName, baseBranch], {
cwd,
});
await this.notifyGitMutation(cwd, "create-branch");
}
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
this.assertSafeGitRef(branch, "branch");
return this.workspaceGitService.hasLocalBranch(cwd, branch);
}
private async notifyGitMutation(
cwd: string,
reason: GitMutationRefreshReason,
options?: { invalidateGithub?: boolean },
): Promise<void> {
if (options?.invalidateGithub) {
this.github.invalidate({ cwd });
}
try {
await this.workspaceGitService.getSnapshot(cwd, { force: true, reason });
} catch (error) {
this.sessionLogger.warn(
{ err: error, cwd, reason },
"Failed to force-refresh workspace git snapshot after mutation",
);
}
}
/**
* Handle set agent mode request
*/
private async handleSetAgentModeRequest(
agentId: string,
modeId: string,
requestId: string,
): Promise<void> {
this.sessionLogger.info({ agentId, modeId, requestId }, "session: set_agent_mode_request");
try {
await setAgentModeCommand({ agentManager: this.agentManager }, { agentId, modeId });
this.sessionLogger.info(
{ agentId, modeId, requestId },
"session: set_agent_mode_request success",
);
this.emit({
type: "set_agent_mode_response",
payload: { requestId, agentId, accepted: true, error: null },
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId, modeId, requestId },
"session: set_agent_mode_request error",
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to set agent mode: ${getErrorMessage(error)}`,
},
});
this.emit({
type: "set_agent_mode_response",
payload: {
requestId,
agentId,
accepted: false,
error: getErrorMessageOr(error, "Failed to set agent mode"),
},
});
}
}
private async handleSetAgentModelRequest(
agentId: string,
modelId: string | null,
requestId: string,
): Promise<void> {
this.sessionLogger.info({ agentId, modelId, requestId }, "session: set_agent_model_request");
try {
await this.agentManager.setAgentModel(agentId, modelId);
this.sessionLogger.info(
{ agentId, modelId, requestId },
"session: set_agent_model_request success",
);
this.emit({
type: "set_agent_model_response",
payload: { requestId, agentId, accepted: true, error: null },
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId, modelId, requestId },
"session: set_agent_model_request error",
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to set agent model: ${getErrorMessage(error)}`,
},
});
this.emit({
type: "set_agent_model_response",
payload: {
requestId,
agentId,
accepted: false,
error: getErrorMessageOr(error, "Failed to set agent model"),
},
});
}
}
private async handleSetAgentFeatureRequest(
agentId: string,
featureId: string,
value: unknown,
requestId: string,
): Promise<void> {
this.sessionLogger.info(
{ agentId, featureId, value, requestId },
"session: set_agent_feature_request",
);
try {
await this.agentManager.setAgentFeature(agentId, featureId, value);
this.sessionLogger.info(
{ agentId, featureId, value, requestId },
"session: set_agent_feature_request success",
);
this.emit({
type: "set_agent_feature_response",
payload: { requestId, agentId, accepted: true, error: null },
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId, featureId, value, requestId },
"session: set_agent_feature_request error",
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to set agent feature: ${getErrorMessage(error)}`,
},
});
this.emit({
type: "set_agent_feature_response",
payload: {
requestId,
agentId,
accepted: false,
error: getErrorMessageOr(error, "Failed to set agent feature"),
},
});
}
}
private async handleSetAgentThinkingRequest(
agentId: string,
thinkingOptionId: string | null,
requestId: string,
): Promise<void> {
this.sessionLogger.info(
{ agentId, thinkingOptionId, requestId },
"session: set_agent_thinking_request",
);
try {
await this.agentManager.setAgentThinkingOption(agentId, thinkingOptionId);
this.sessionLogger.info(
{ agentId, thinkingOptionId, requestId },
"session: set_agent_thinking_request success",
);
this.emit({
type: "set_agent_thinking_response",
payload: { requestId, agentId, accepted: true, error: null },
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId, thinkingOptionId, requestId },
"session: set_agent_thinking_request error",
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to set agent thinking option: ${getErrorMessage(error)}`,
},
});
this.emit({
type: "set_agent_thinking_response",
payload: {
requestId,
agentId,
accepted: false,
error: getErrorMessageOr(error, "Failed to set agent thinking option"),
},
});
}
}
/**
* Handle clearing agent attention flag
*/
private async handleClearAgentAttention(
agentId: string | string[],
requestId?: string,
): Promise<void> {
const agentIds = Array.isArray(agentId) ? agentId : [agentId];
try {
await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id)));
if (requestId) {
const agents = (
await Promise.all(
agentIds.map(async (id) => {
const agent = this.agentManager.getAgent(id);
return agent ? this.buildAgentPayload(agent) : null;
}),
)
).filter((payload): payload is NonNullable<typeof payload> => payload !== null);
this.emit({
type: "clear_agent_attention_response",
payload: {
requestId,
agentId,
agents,
},
});
}
} catch (error) {
this.sessionLogger.error({ err: error, agentIds }, "Failed to clear agent attention");
// Don't throw - this is not critical
}
}
/**
* Handle client heartbeat for activity tracking
*/
private handleClientHeartbeat(msg: {
deviceType: "web" | "mobile";
focusedAgentId: string | null;
lastActivityAt: string;
appVisible: boolean;
appVisibilityChangedAt?: string;
}): void {
const appVisibilityChangedAt = msg.appVisibilityChangedAt
? new Date(msg.appVisibilityChangedAt)
: new Date(msg.lastActivityAt);
this.clientActivity = {
deviceType: msg.deviceType,
focusedAgentId: msg.focusedAgentId,
lastActivityAt: new Date(msg.lastActivityAt),
appVisible: msg.appVisible,
appVisibilityChangedAt,
};
}
/**
* Handle push token registration
*/
private handleRegisterPushToken(token: string): void {
this.pushTokenStore.addToken(token);
this.sessionLogger.info("Registered push token");
}
/**
* Handle list commands request for an agent
*/
private async handleListCommandsRequest(
msg: Extract<SessionInboundMessage, { type: "list_commands_request" }>,
): Promise<void> {
const { agentId, requestId, draftConfig } = msg;
this.sessionLogger.debug(
{ agentId, draftConfig },
`Handling list commands request for agent ${agentId}`,
);
try {
const agents = this.agentManager.listAgents();
const agent = agents.find((a) => a.id === agentId);
if (agent?.session?.listCommands) {
const commands = await agent.session.listCommands();
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands,
error: null,
requestId,
},
});
return;
}
if (!agent && draftConfig) {
const sessionConfig: AgentSessionConfig = {
provider: draftConfig.provider,
cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}),
...(draftConfig.thinkingOptionId
? { thinkingOptionId: draftConfig.thinkingOptionId }
: {}),
};
const commands = await this.agentManager.listDraftCommands(sessionConfig);
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands,
error: null,
requestId,
},
});
return;
}
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands: [],
error: agent ? `Agent does not support listing commands` : `Agent not found: ${agentId}`,
requestId,
},
});
} catch (error) {
this.sessionLogger.error({ err: error, agentId, draftConfig }, "Failed to list commands");
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands: [],
error: getErrorMessage(error),
requestId,
},
});
}
}
/**
* Handle agent permission response from user
*/
private async handleAgentPermissionResponse(
agentId: string,
requestId: string,
response: AgentPermissionResponse,
): Promise<void> {
try {
await respondToAgentPermission({
agentManager: this.agentManager,
agentId,
requestId,
response,
logger: this.sessionLogger,
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId, requestId },
"Failed to respond to permission",
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to respond to permission: ${getErrorMessage(error)}`,
},
});
throw error;
}
}
private async handleCheckoutStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
const resolvedCwd = expandTilde(cwd);
try {
const snapshot = await this.workspaceGitService.getSnapshot(resolvedCwd);
this.emit({
type: "checkout_status_response",
payload: buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
});
} catch (error) {
this.emit({
type: "checkout_status_response",
payload: {
cwd,
isGit: false,
repoRoot: null,
currentBranch: null,
isDirty: null,
baseRef: null,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
hasRemote: false,
remoteUrl: null,
isPaseoOwnedWorktree: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleValidateBranchRequest(
msg: Extract<SessionInboundMessage, { type: "validate_branch_request" }>,
): Promise<void> {
const { cwd, branchName, requestId } = msg;
try {
const resolvedCwd = expandTilde(cwd);
this.assertSafeGitRef(branchName, "branch");
const resolution = await this.workspaceGitService.validateBranchRef(resolvedCwd, branchName);
switch (resolution.kind) {
case "local":
this.emit({
type: "validate_branch_response",
payload: {
exists: true,
resolvedRef: resolution.name,
isRemote: false,
error: null,
requestId,
},
});
return;
case "remote-only":
this.emit({
type: "validate_branch_response",
payload: {
exists: true,
resolvedRef: resolution.remoteRef,
isRemote: true,
error: null,
requestId,
},
});
return;
case "not-found":
this.emit({
type: "validate_branch_response",
payload: {
exists: false,
resolvedRef: null,
isRemote: false,
error: null,
requestId,
},
});
return;
default: {
const exhaustiveCheck: never = resolution;
throw new Error(`Unhandled branch resolution: ${getErrorMessage(exhaustiveCheck)}`);
}
}
} catch (error) {
this.emit({
type: "validate_branch_response",
payload: {
exists: false,
resolvedRef: null,
isRemote: false,
error: error instanceof Error ? error.message : String(error),
requestId,
},
});
}
}
private async handleBranchSuggestionsRequest(
msg: Extract<SessionInboundMessage, { type: "branch_suggestions_request" }>,
): Promise<void> {
const { cwd, query, limit, requestId } = msg;
try {
const resolvedCwd = expandTilde(cwd);
const branchDetails = await this.workspaceGitService.suggestBranchesForCwd(resolvedCwd, {
query,
limit,
});
this.emit({
type: "branch_suggestions_response",
payload: {
branches: branchDetails.map((branch) => branch.name),
branchDetails,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "branch_suggestions_response",
payload: {
branches: [],
branchDetails: [],
error: error instanceof Error ? error.message : String(error),
requestId,
},
});
}
}
private async handleGitHubSearchRequest(
msg: Extract<SessionInboundMessage, { type: "github_search_request" }>,
): Promise<void> {
const { cwd, query, limit, kinds, requestId } = msg;
try {
const resolvedCwd = expandTilde(cwd);
const result = await this.github.searchIssuesAndPrs({
cwd: resolvedCwd,
query,
limit,
kinds,
});
this.emit({
type: "github_search_response",
payload: {
items: result.items,
githubFeaturesEnabled: result.githubFeaturesEnabled,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "github_search_response",
payload: {
items: [],
githubFeaturesEnabled: true,
error: error instanceof Error ? error.message : String(error),
requestId,
},
});
}
}
private async handleDirectorySuggestionsRequest(msg: DirectorySuggestionsRequest): Promise<void> {
const { query, limit, requestId, cwd, includeFiles, includeDirectories, matchMode } = msg;
try {
const workspaceCwd = cwd?.trim();
const entries = workspaceCwd
? await searchWorkspaceEntries({
cwd: expandTilde(workspaceCwd),
query,
limit,
includeFiles,
includeDirectories,
matchMode,
})
: (
await searchHomeDirectories({
homeDir: process.env.HOME ?? homedir(),
query,
limit,
})
).map((path) => ({ path, kind: "directory" as const }));
const directories = entries
.filter((entry) => entry.kind === "directory")
.map((entry) => entry.path);
this.emit({
type: "directory_suggestions_response",
payload: {
directories,
entries,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "directory_suggestions_response",
payload: {
directories: [],
entries: [],
error: error instanceof Error ? error.message : String(error),
requestId,
},
});
}
}
private closeWorkspaceGitWatchTarget(target: WorkspaceGitWatchTarget): void {
if (target.debounceTimer) {
clearTimeout(target.debounceTimer);
target.debounceTimer = null;
}
for (const watcher of target.watchers) {
try {
watcher.close();
} catch {
// Ignore watcher close errors
}
}
target.watchers.length = 0;
}
private async removeWorkspaceGitWatchTarget(cwd: string): Promise<void> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const target = this.workspaceGitWatchTargets.get(normalizedCwd);
if (target) {
this.closeWorkspaceGitWatchTarget(target);
this.workspaceGitWatchTargets.delete(normalizedCwd);
}
}
private removeWorkspaceGitSubscription(cwd: string): void {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const target = this.workspaceGitWatchTargets.get(normalizedCwd);
if (target) {
const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(normalizedCwd);
unsubscribeFetch?.();
this.workspaceGitFetchSubscriptions.delete(normalizedCwd);
this.closeWorkspaceGitWatchTarget(target);
this.workspaceGitWatchTargets.delete(normalizedCwd);
}
this.workspaceGitSubscriptions.get(normalizedCwd)?.();
this.workspaceGitSubscriptions.delete(normalizedCwd);
}
private workspaceGitDescriptorStateKey(workspace: WorkspaceDescriptorPayload | null): string {
if (!workspace) {
return WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY;
}
return JSON.stringify([
workspace.name,
workspace.diffStat ? [workspace.diffStat.additions, workspace.diffStat.deletions] : null,
]);
}
private shouldSkipWorkspaceGitWatchUpdate(
workspaceId: string,
workspace: WorkspaceDescriptorPayload | null,
): boolean {
const target = this.workspaceGitWatchTargets.get(workspaceId);
if (!target) {
return false;
}
const nextStateKey = this.workspaceGitDescriptorStateKey(workspace);
if (target.latestDescriptorStateKey === nextStateKey) {
return true;
}
target.latestDescriptorStateKey = nextStateKey;
return false;
}
private rememberWorkspaceGitDescriptorState(
workspaceId: string,
workspace: WorkspaceDescriptorPayload | null,
): void {
const target = this.workspaceGitWatchTargets.get(workspaceId);
if (!target) {
return;
}
target.latestDescriptorStateKey = this.workspaceGitDescriptorStateKey(workspace);
target.lastBranchName = workspace?.name ?? null;
}
private handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void {
const target = this.workspaceGitWatchTargets.get(normalizePersistedWorkspaceId(cwd));
if (!target) {
return;
}
const previousBranchName = target.lastBranchName;
if (branchName === previousBranchName) {
return;
}
target.lastBranchName = branchName;
this.onBranchChanged?.(target.workspaceId, previousBranchName, branchName);
}
private syncWorkspaceGitObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
for (const workspace of workspaces) {
this.syncWorkspaceGitObserver(workspace.workspaceDirectory, {
isGit: workspace.projectKind === "git",
workspaceId: workspace.id,
});
this.rememberWorkspaceGitDescriptorState(workspace.workspaceDirectory, workspace);
}
}
private syncWorkspaceGitObserver(
cwd: string,
options: { isGit: boolean; workspaceId: string },
): void {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
if (!options.isGit) {
this.removeWorkspaceGitSubscription(normalizedCwd);
return;
}
if (this.workspaceGitSubscriptions.has(normalizedCwd)) {
return;
}
const target: WorkspaceGitWatchTarget = {
cwd: normalizedCwd,
workspaceId: options.workspaceId,
watchers: [],
debounceTimer: null,
refreshPromise: null,
refreshQueued: false,
latestDescriptorStateKey: null,
lastBranchName: null,
};
this.workspaceGitWatchTargets.set(normalizedCwd, target);
const subscription = this.workspaceGitService.registerWorkspace(
{ cwd: normalizedCwd },
(snapshot) => {
this.handleWorkspaceGitBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null);
void this.emitWorkspaceUpdateForCwd(normalizedCwd);
this.emitCheckoutStatusUpdate(normalizedCwd, snapshot);
},
);
this.workspaceGitSubscriptions.set(normalizedCwd, subscription.unsubscribe);
}
private async handleSubscribeCheckoutDiffRequest(
msg: SubscribeCheckoutDiffRequest,
): Promise<void> {
const cwd = expandTilde(msg.cwd);
this.checkoutDiffSubscriptions.get(msg.subscriptionId)?.();
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
const subscription = await this.checkoutDiffManager.subscribe(
{ cwd, compare: msg.compare },
(snapshot) => {
this.emit({
type: "checkout_diff_update",
payload: {
subscriptionId: msg.subscriptionId,
...snapshot,
},
});
},
);
this.checkoutDiffSubscriptions.set(msg.subscriptionId, subscription.unsubscribe);
this.emit({
type: "subscribe_checkout_diff_response",
payload: {
subscriptionId: msg.subscriptionId,
...subscription.initial,
requestId: msg.requestId,
},
});
}
private handleUnsubscribeCheckoutDiffRequest(msg: UnsubscribeCheckoutDiffRequest): void {
this.checkoutDiffSubscriptions.get(msg.subscriptionId)?.();
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
}
private emitCheckoutStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void {
try {
const requestId = `subscription:${cwd}`;
this.emit({
type: "checkout_status_update",
payload: {
...buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
prStatus: buildCheckoutPrStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
},
});
} catch (error) {
this.sessionLogger.warn(
{ err: error, cwd },
"Failed to emit workspace checkout status update",
);
}
}
private async handleCheckoutSwitchBranchRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
): Promise<void> {
const { cwd, branch, requestId } = msg;
try {
const checkoutResult = await this.checkoutExistingBranch(cwd, branch);
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
// Push a workspace_update immediately so the sidebar/header reflect
// the new branch name without waiting for the background git watcher.
await this.emitWorkspaceUpdateForCwd(cwd);
this.emit({
type: "checkout_switch_branch_response",
payload: {
cwd,
success: true,
branch,
source: checkoutResult.source,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_switch_branch_response",
payload: {
cwd,
success: false,
branch,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleCheckoutRenameBranchRequest(msg: CheckoutRenameBranchRequest): Promise<void> {
const { cwd, branch, requestId } = msg;
const validation = validateBranchSlug(branch);
if (!validation.valid) {
this.emit({
type: "checkout.rename_branch.response",
payload: {
cwd,
success: false,
currentBranch: null,
error: toCheckoutError(new Error(validation.error ?? "Invalid branch name")),
requestId,
},
});
return;
}
try {
const result = await renameCurrentBranch(cwd, branch);
await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true });
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
// Push a workspace_update immediately so the sidebar/header reflect
// the new branch name without waiting for the background git watcher.
await this.emitWorkspaceUpdateForCwd(cwd);
this.emit({
type: "checkout.rename_branch.response",
payload: {
cwd,
success: true,
currentBranch: result.currentBranch,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout.rename_branch.response",
payload: {
cwd,
success: false,
currentBranch: null,
error: toCheckoutError(error),
requestId,
},
});
}
}
// ---------------------------------------------------------------------------
// Stash handlers
// ---------------------------------------------------------------------------
private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:";
private async handleStashSaveRequest(
msg: Extract<SessionInboundMessage, { type: "stash_save_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const branchLabel = msg.branch?.trim() ?? "";
const message = branchLabel
? `${Session.PASEO_STASH_PREFIX} ${branchLabel}`
: `${Session.PASEO_STASH_PREFIX} unnamed`;
await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], {
cwd,
});
await this.notifyGitMutation(cwd, "stash-push");
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "stash_save_response",
payload: { cwd, success: true, error: null, requestId },
});
} catch (error) {
this.emit({
type: "stash_save_response",
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
});
}
}
private async handleStashPopRequest(
msg: Extract<SessionInboundMessage, { type: "stash_pop_request" }>,
): Promise<void> {
const { cwd, stashIndex, requestId } = msg;
try {
await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], {
cwd,
});
await this.notifyGitMutation(cwd, "stash-pop");
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "stash_pop_response",
payload: { cwd, success: true, error: null, requestId },
});
} catch (error) {
this.emit({
type: "stash_pop_response",
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
});
}
}
private async handleStashListRequest(
msg: Extract<SessionInboundMessage, { type: "stash_list_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
const paseoOnly = msg.paseoOnly !== false;
try {
const entries = await this.workspaceGitService.listStashes(cwd, { paseoOnly });
this.emit({
type: "stash_list_response",
payload: { cwd, entries, error: null, requestId },
});
} catch (error) {
this.emit({
type: "stash_list_response",
payload: { cwd, entries: [], error: toCheckoutError(error), requestId },
});
}
}
private async handleCheckoutCommitRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_commit_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
let message = msg.message?.trim() ?? "";
if (!message) {
message = await this.generateCommitMessage(cwd);
}
if (!message) {
throw new Error("Commit message is required");
}
await commitChanges(cwd, {
message,
addAll: msg.addAll ?? true,
});
await this.notifyGitMutation(cwd, "commit-changes");
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "checkout_commit_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_commit_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleCheckoutMergeRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_merge_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
if (!snapshot.git.isGit) {
throw new Error(`Not a git repository: ${cwd}`);
}
if (msg.requireCleanTarget) {
if (snapshot.git.isDirty) {
throw new Error("Working directory has uncommitted changes.");
}
}
let baseRef = msg.baseRef ?? snapshot.git.baseRef;
if (!baseRef) {
throw new Error("Base branch is required for merge");
}
if (baseRef.startsWith("origin/")) {
baseRef = baseRef.slice("origin/".length);
}
const mutatedCwd = await mergeToBase(
cwd,
{
baseRef,
mode: msg.strategy === "squash" ? "squash" : "merge",
},
{ paseoHome: this.paseoHome },
);
await Promise.all([
this.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }),
...(mutatedCwd !== cwd ? [this.notifyGitMutation(cwd, "merge-to-base")] : []),
]);
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "checkout_merge_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_merge_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleCheckoutMergeFromBaseRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_merge_from_base_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
if (msg.requireCleanTarget ?? true) {
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
if (snapshot.git.isDirty) {
throw new Error("Working directory has uncommitted changes.");
}
}
await mergeFromBase(cwd, {
baseRef: msg.baseRef,
requireCleanTarget: msg.requireCleanTarget ?? true,
});
await this.notifyGitMutation(cwd, "merge-from-base", { invalidateGithub: true });
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "checkout_merge_from_base_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_merge_from_base_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleCheckoutPullRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pull_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
await pullCurrentBranch(cwd);
await this.notifyGitMutation(cwd, "pull", { invalidateGithub: true });
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "checkout_pull_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_pull_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleCheckoutPushRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_push_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
await pushCurrentBranch(cwd);
await this.notifyGitMutation(cwd, "push", { invalidateGithub: true });
this.emit({
type: "checkout_push_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_push_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleCheckoutRefreshRequest(
msg: Extract<SessionInboundMessage, { type: "checkout.refresh.request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
this.github.invalidate({ cwd });
await this.workspaceGitService.getSnapshot(cwd, {
force: true,
includeGitHub: true,
reason: "manual-refresh",
});
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.emit({
type: "checkout.refresh.response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout.refresh.response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleCheckoutPrCreateRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_create_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
let title = msg.title?.trim() ?? "";
let body = msg.body?.trim() ?? "";
if (!title || !body) {
const generated = await this.generatePullRequestText(cwd, msg.baseRef);
if (!title) title = generated.title;
if (!body) body = generated.body;
}
const result = await createPullRequest(
cwd,
{
title,
body,
base: msg.baseRef,
},
this.github,
);
await this.notifyGitMutation(cwd, "create-pr", { invalidateGithub: true });
this.emit({
type: "checkout_pr_create_response",
payload: {
cwd,
url: result.url ?? null,
number: result.number ?? null,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_pr_create_response",
payload: {
cwd,
url: null,
number: null,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handleCheckoutPrMergeRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_merge_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const pullRequest = await this.resolveCurrentPullRequest(cwd, "merge", {
force: true,
includeGitHub: true,
reason: "merge-pr-validation",
});
this.assertCurrentPullRequestHasGithubMergeFacts(pullRequest);
await this.github.mergePullRequest({
cwd,
prNumber: pullRequest.number,
mergeMethod: msg.mergeMethod,
status: pullRequest,
});
await this.notifyGitMutation(cwd, "merge-pr", { invalidateGithub: true });
this.emit({
type: "checkout_pr_merge_response",
payload: {
cwd,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_pr_merge_response",
payload: {
cwd,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private assertCurrentPullRequestHasGithubMergeFacts(
pullRequest: CurrentWorkspacePullRequest,
): void {
if (!pullRequest.github) {
throw new Error("GitHub merge facts are unavailable for this pull request");
}
}
private async handleCheckoutGithubSetAutoMergeRequest(
msg: Extract<SessionInboundMessage, { type: "checkout.github.set_auto_merge.request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const pullRequest = await this.resolveCurrentPullRequest(cwd, "auto-merge", {
force: true,
includeGitHub: true,
reason: "auto-merge-validation",
});
if (msg.enabled) {
const mergeMethod = msg.mergeMethod;
if (!mergeMethod) {
throw new Error("mergeMethod is required when enabling auto-merge");
}
assertPullRequestAutoMergeEnableReady({
mergeMethod,
status: pullRequest,
});
await this.github.enablePullRequestAutoMerge({
cwd,
prNumber: pullRequest.number,
mergeMethod,
status: pullRequest,
});
} else {
if (msg.mergeMethod) {
throw new Error("mergeMethod is not allowed when disabling auto-merge");
}
assertPullRequestAutoMergeDisableReady({ status: pullRequest });
await this.github.disablePullRequestAutoMerge({
cwd,
prNumber: pullRequest.number,
status: pullRequest,
});
}
await this.notifyGitMutation(
cwd,
msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge",
{
invalidateGithub: true,
},
);
this.emit({
type: "checkout.github.set_auto_merge.response",
payload: {
cwd,
enabled: msg.enabled,
success: true,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout.github.set_auto_merge.response",
payload: {
cwd,
enabled: msg.enabled,
success: false,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async resolveCurrentPullRequest(
cwd: string,
operation: "merge" | "auto-merge",
options?: WorkspaceGitSnapshotOptions,
): Promise<CurrentWorkspacePullRequest> {
const snapshot = await this.workspaceGitService.getSnapshot(cwd, options);
const pullRequest = snapshot.github.pullRequest;
if (!pullRequest || typeof pullRequest.number !== "number") {
throw new Error(`Unable to determine GitHub pull request number for ${operation}`);
}
return { ...pullRequest, number: pullRequest.number };
}
private async handleCheckoutPrStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_pr_status_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
try {
const snapshot = await this.workspaceGitService.getSnapshot(cwd);
this.emit({
type: "checkout_pr_status_response",
payload: buildCheckoutPrStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
});
} catch (error) {
this.emit({
type: "checkout_pr_status_response",
payload: {
cwd,
status: null,
githubFeaturesEnabled: true,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handlePullRequestTimelineRequest(
msg: Extract<SessionInboundMessage, { type: "pull_request_timeline_request" }>,
): Promise<void> {
const { cwd, prNumber, repoOwner, repoName, requestId } = msg;
if (!isValidPullRequestTimelineIdentity({ prNumber, repoOwner, repoName })) {
this.emit({
type: "pull_request_timeline_response",
payload: {
cwd,
prNumber,
items: [],
truncated: false,
error: {
kind: "unknown",
message: "Pull request timeline request has invalid PR identity",
},
requestId,
githubFeaturesEnabled: true,
},
});
return;
}
const githubFeaturesEnabled = await this.github.isAuthenticated({ cwd });
if (!githubFeaturesEnabled) {
this.emit({
type: "pull_request_timeline_response",
payload: {
cwd,
prNumber,
items: [],
truncated: false,
error: {
kind: "unknown",
message: "GitHub CLI is unavailable or not authenticated",
},
requestId,
githubFeaturesEnabled: false,
},
});
return;
}
try {
const timeline = await this.github.getPullRequestTimeline({
cwd,
prNumber,
repoOwner,
repoName,
});
this.emit({
type: "pull_request_timeline_response",
payload: {
cwd,
prNumber: timeline.prNumber,
items: timeline.items.map(toPullRequestTimelinePayloadItem),
truncated: timeline.truncated,
error: timeline.error,
requestId,
githubFeaturesEnabled: true,
},
});
} catch (error) {
this.emit({
type: "pull_request_timeline_response",
payload: {
cwd,
prNumber,
items: [],
truncated: false,
error: {
kind: "unknown",
message: error instanceof Error ? error.message : String(error),
},
requestId,
githubFeaturesEnabled: true,
},
});
}
}
private async handlePaseoWorktreeListRequest(
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
): Promise<void> {
return handleWorktreeListRequest(
{
emit: (message) => this.emit(message),
paseoHome: this.paseoHome,
workspaceGitService: this.workspaceGitService,
},
msg,
);
}
private async handlePaseoWorktreeArchiveRequest(
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
): Promise<void> {
return handleWorktreeArchiveRequest(
{
paseoHome: this.paseoHome,
github: this.github,
workspaceGitService: this.workspaceGitService,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message),
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
this.markWorkspaceArchiving(workspaceIds, archivingAt),
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
isPathWithinRoot: (rootPath, candidatePath) =>
this.isPathWithinRoot(rootPath, candidatePath),
killTerminalsUnderPath: (rootPath) =>
this.terminalController.killTerminalsUnderPath(rootPath),
sessionLogger: this.sessionLogger,
},
msg,
);
}
/**
* Handle read-only file explorer requests scoped to a workspace cwd
*/
private async handleFileExplorerRequest(request: FileExplorerRequest): Promise<void> {
const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request;
const cwd = workspaceCwd.trim();
if (!cwd) {
this.emit({
type: "file_explorer_response",
payload: {
cwd: workspaceCwd,
path: requestedPath,
mode,
directory: null,
file: null,
error: "cwd is required",
requestId,
},
});
return;
}
try {
if (mode === "list") {
const directory = await listDirectoryEntries({
root: cwd,
relativePath: requestedPath,
});
this.emit({
type: "file_explorer_response",
payload: {
cwd,
path: directory.path,
mode,
directory,
file: null,
error: null,
requestId,
},
});
} else {
if (request.acceptBinary && this.onBinaryMessage) {
const file = await readExplorerFileBytes({
root: cwd,
relativePath: requestedPath,
});
this.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId,
metadata: {
mime: file.mimeType,
size: file.size,
encoding: file.encoding,
modifiedAt: file.modifiedAt,
},
}),
);
this.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId,
payload: file.bytes,
}),
);
this.emitBinary(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileEnd,
requestId,
}),
);
} else {
const file = await readExplorerFile({
root: cwd,
relativePath: requestedPath,
});
this.emit({
type: "file_explorer_response",
payload: {
cwd,
path: file.path,
mode,
directory: null,
file,
error: null,
requestId,
},
});
}
}
} catch (error) {
this.sessionLogger.error(
{ err: error, cwd, path: requestedPath },
`Failed to fulfill file explorer request for workspace ${cwd}`,
);
this.emit({
type: "file_explorer_response",
payload: {
cwd,
path: requestedPath,
mode,
directory: null,
file: null,
error: getErrorMessage(error),
requestId,
},
});
}
}
/**
* Handle project icon request for a given cwd
*/
private async handleProjectIconRequest(
request: Extract<SessionInboundMessage, { type: "project_icon_request" }>,
): Promise<void> {
const { cwd, requestId } = request;
try {
const icon = await getProjectIcon(cwd);
this.emit({
type: "project_icon_response",
payload: {
cwd,
icon,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "project_icon_response",
payload: {
cwd,
icon: null,
error: getErrorMessage(error),
requestId,
},
});
}
}
/**
* Handle file download token request scoped to a workspace cwd
*/
private async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise<void> {
const { cwd: workspaceCwd, path: requestedPath, requestId } = request;
const cwd = workspaceCwd.trim();
if (!cwd) {
this.emit({
type: "file_download_token_response",
payload: {
cwd: workspaceCwd,
path: requestedPath,
token: null,
fileName: null,
mimeType: null,
size: null,
error: "cwd is required",
requestId,
},
});
return;
}
this.sessionLogger.debug(
{ cwd, path: requestedPath },
`Handling file download token request for workspace ${cwd} (${requestedPath})`,
);
try {
const info = await getDownloadableFileInfo({
root: cwd,
relativePath: requestedPath,
});
const entry = this.downloadTokenStore.issueToken({
path: info.path,
absolutePath: info.absolutePath,
fileName: info.fileName,
mimeType: info.mimeType,
size: info.size,
});
this.emit({
type: "file_download_token_response",
payload: {
cwd,
path: info.path,
token: entry.token,
fileName: entry.fileName,
mimeType: entry.mimeType,
size: entry.size,
error: null,
requestId,
},
});
} catch (error) {
this.sessionLogger.error(
{ err: error, cwd, path: requestedPath },
`Failed to issue download token for workspace ${cwd}`,
);
this.emit({
type: "file_download_token_response",
payload: {
cwd,
path: requestedPath,
token: null,
fileName: null,
mimeType: null,
size: null,
error: getErrorMessage(error),
requestId,
},
});
}
}
/**
* Build the current agent list payload (live + persisted), optionally filtered by labels.
*/
private async listAgentPayloads(filter?: {
labels?: Record<string, string>;
includeUnavailablePersisted?: boolean;
}): Promise<AgentSnapshotPayload[]> {
// Get live agents with session modes
const agentSnapshots = this.agentManager.listAgents();
const liveAgents = await Promise.all(
agentSnapshots.map((agent) => this.buildAgentPayload(agent)),
);
// Add persisted agents that have not been lazily initialized yet
// (excluding internal agents which are for ephemeral system tasks)
const registryRecords = await this.agentStorage.list();
const liveIds = new Set(agentSnapshots.map((a) => a.id));
const registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds();
const persistedAgents = registryRecords
.filter((record) => !liveIds.has(record.id) && !record.internal)
.filter(
(record) =>
filter?.includeUnavailablePersisted === true ||
isStoredAgentProviderAvailable(record, registeredProviderIds),
)
.map((record) => this.buildStoredAgentPayload(record, registeredProviderIds));
let agents = [...liveAgents, ...persistedAgents];
agents = agents.filter((agent) => this.isProviderVisibleToClient(agent.provider));
// Filter by labels if filter provided
if (filter?.labels) {
const filterLabels = filter.labels;
agents = agents.filter((agent) =>
Object.entries(filterLabels).every(([key, value]) => agent.labels[key] === value),
);
}
return agents;
}
private async resolveAgentIdentifier(
identifier: string,
): Promise<{ ok: true; agentId: string } | { ok: false; error: string }> {
const trimmed = identifier.trim();
if (!trimmed) {
return { ok: false, error: "Agent identifier cannot be empty" };
}
const stored = await this.agentStorage.list();
const storedRecords = stored.filter((record) => !record.internal);
const knownIds = new Set<string>();
for (const record of storedRecords) {
knownIds.add(record.id);
}
for (const agent of this.agentManager.listAgents()) {
knownIds.add(agent.id);
}
if (knownIds.has(trimmed)) {
return { ok: true, agentId: trimmed };
}
const prefixMatches = Array.from(knownIds).filter((id) => id.startsWith(trimmed));
if (prefixMatches.length === 1) {
return { ok: true, agentId: prefixMatches[0] };
}
if (prefixMatches.length > 1) {
return {
ok: false,
error: `Agent identifier "${trimmed}" is ambiguous (${prefixMatches
.slice(0, 5)
.map((id) => id.slice(0, 8))
.join(", ")}${prefixMatches.length > 5 ? ", …" : ""})`,
};
}
const titleMatches = storedRecords.filter((record) => record.title === trimmed);
if (titleMatches.length === 1) {
return { ok: true, agentId: titleMatches[0].id };
}
if (titleMatches.length > 1) {
return {
ok: false,
error: `Agent title "${trimmed}" is ambiguous (${titleMatches
.slice(0, 5)
.map((r) => r.id.slice(0, 8))
.join(", ")}${titleMatches.length > 5 ? ", …" : ""})`,
};
}
return { ok: false, error: `Agent not found: ${trimmed}` };
}
private async getAgentPayloadById(agentId: string): Promise<AgentSnapshotPayload | null> {
const live = this.agentManager.getAgent(agentId);
if (live) {
const payload = await this.buildAgentPayload(live);
return this.isProviderVisibleToClient(payload.provider) ? payload : null;
}
const record = await this.agentStorage.get(agentId);
if (!record || record.internal) {
return null;
}
const payload = this.buildStoredAgentPayload(record);
return this.isProviderVisibleToClient(payload.provider) ? payload : null;
}
private async buildActiveProjectPlacementsByWorkspaceCwd(): Promise<
Map<string, ProjectPlacementPayload>
> {
const [persistedWorkspaces, persistedProjects] = await Promise.all([
this.workspaceRegistry.list(),
this.projectRegistry.list(),
]);
const activeProjects = new Map(
persistedProjects
.filter((project) => !project.archivedAt)
.map((project) => [project.projectId, project] as const),
);
const placementsByCwd = new Map<string, ProjectPlacementPayload>();
const pairs = persistedWorkspaces.flatMap((workspace) => {
if (workspace.archivedAt) return [];
const project = activeProjects.get(workspace.projectId);
if (!project) return [];
return [{ workspace, project }];
});
const placements = await Promise.all(
pairs.map(({ workspace, project }) =>
this.buildProjectPlacementForWorkspace(workspace, project),
),
);
for (let i = 0; i < pairs.length; i += 1) {
placementsByCwd.set(normalizePersistedWorkspaceId(pairs[i].workspace.cwd), placements[i]);
}
return placementsByCwd;
}
private async collectFetchAgentsEntries(params: {
candidates: AgentSnapshotPayload[];
limit: number;
getPlacement: (cwd: string) => Promise<ProjectPlacementPayload | null>;
filter: AgentUpdatesFilter | undefined;
}): Promise<FetchAgentsResponseEntry[]> {
const { candidates, limit, getPlacement, filter } = params;
const matchedEntries: FetchAgentsResponseEntry[] = [];
const batchSize = 25;
for (
let start = 0;
start < candidates.length && matchedEntries.length <= limit;
start += batchSize
) {
const batch = candidates.slice(start, start + batchSize);
const batchEntries = await Promise.all(
batch.map(async (agent) => {
const project = await getPlacement(agent.cwd);
return project ? { agent, project } : null;
}),
);
for (const entry of batchEntries) {
if (!entry) {
continue;
}
if (
!this.matchesAgentFilter({
agent: entry.agent,
project: entry.project,
filter,
})
) {
continue;
}
matchedEntries.push(entry);
if (matchedEntries.length > limit) {
break;
}
}
}
return matchedEntries;
}
private async listFetchAgentsEntries(request: AgentDirectoryRequestMessage): Promise<{
entries: FetchAgentsResponseEntry[];
pageInfo: FetchAgentsResponsePageInfo;
}> {
const filter =
request.type === "fetch_agent_history_request" &&
request.filter?.includeArchived === undefined
? { ...request.filter, includeArchived: true }
: request.filter;
const scope = request.type === "fetch_agents_request" ? request.scope : undefined;
const sort = this.agentsPager.normalizeSort(request.sort);
let agents = await this.listAgentPayloads({
labels: filter?.labels,
includeUnavailablePersisted: request.type === "fetch_agent_history_request",
});
const activePlacementsByCwd =
scope === "active" ? await this.buildActiveProjectPlacementsByWorkspaceCwd() : null;
if (activePlacementsByCwd) {
agents = agents.filter(
(agent) =>
!agent.archivedAt && activePlacementsByCwd.has(normalizePersistedWorkspaceId(agent.cwd)),
);
}
const placementByCwd = new Map<string, Promise<ProjectPlacementPayload | null>>();
const getPlacement = (cwd: string): Promise<ProjectPlacementPayload | null> => {
if (activePlacementsByCwd) {
return Promise.resolve(
activePlacementsByCwd.get(normalizePersistedWorkspaceId(cwd)) ?? null,
);
}
const existing = placementByCwd.get(cwd);
if (existing) {
return existing;
}
const placementPromise = this.buildProjectPlacementForCwd(cwd);
placementByCwd.set(cwd, placementPromise);
return placementPromise;
};
let candidates = [...agents];
candidates.sort((left, right) => this.agentsPager.compare(left, right, sort));
const cursorToken = request.page?.cursor;
if (cursorToken) {
const cursor = this.decodeAgentCursor(cursorToken, sort);
candidates = candidates.filter(
(agent) => this.agentsPager.compareWithCursor(agent, cursor, sort) > 0,
);
}
const limit = request.page?.limit ?? 200;
const matchedEntries = await this.collectFetchAgentsEntries({
candidates,
limit,
getPlacement,
filter,
});
const pagedEntries = matchedEntries.slice(0, limit);
const hasMore = matchedEntries.length > limit;
const nextCursor =
hasMore && pagedEntries.length > 0
? this.agentsPager.encode(pagedEntries[pagedEntries.length - 1].agent, sort)
: null;
return {
entries: pagedEntries,
pageInfo: {
nextCursor,
prevCursor: request.page?.cursor ?? null,
hasMore,
},
};
}
private readonly agentsPager = new SortablePager<
AgentSnapshotPayload,
FetchAgentsRequestSort["key"]
>({
validKeys: FETCH_AGENTS_SORT_KEYS,
defaultSort: [{ key: "updated_at", direction: "desc" }],
label: "fetch_agents",
getId: (agent) => agent.id,
getSortValue: (agent, key): number | string => {
switch (key) {
case "status_priority":
return getAgentStatusPriority({
status: agent.status,
pendingPermissionCount: agent.pendingPermissions?.length ?? 0,
requiresAttention: agent.requiresAttention,
attentionReason: agent.attentionReason ?? null,
});
case "created_at":
return Date.parse(agent.createdAt);
case "updated_at":
return Date.parse(agent.updatedAt);
case "title":
return agent.title?.toLocaleLowerCase() ?? "";
}
},
});
private decodeAgentCursor(token: string, sort: SortSpec<FetchAgentsRequestSort["key"]>[]) {
try {
return this.agentsPager.decode(token, sort);
} catch (error) {
if (error instanceof CursorError) {
throw new SessionRequestError("invalid_cursor", error.message);
}
throw error;
}
}
private async describeWorkspaceRecord(
workspace: PersistedWorkspaceRecord,
projectRecord?: PersistedProjectRecord | null,
): Promise<WorkspaceDescriptorPayload> {
const resolvedProjectRecord =
projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
let diffStat: { additions: number; deletions: number } | null = null;
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
if (snapshot?.git.diffStat) {
diffStat = snapshot.git.diffStat;
}
return {
id: workspace.workspaceId,
projectId: workspace.projectId,
projectDisplayName: resolvedProjectRecord
? resolveProjectDisplayName(resolvedProjectRecord)
: workspace.projectId,
projectCustomName: resolvedProjectRecord?.customName ?? null,
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
workspaceDirectory: workspace.cwd,
projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git",
workspaceKind: workspace.kind,
name: workspace.displayName,
archivingAt: null,
status: "done",
activityAt: null,
diffStat,
scripts:
this.scriptRouteStore && this.scriptRuntimeStore
? buildWorkspaceScriptPayloads({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.cwd,
paseoConfig: readPaseoConfigForProjection(workspace.cwd, this.sessionLogger),
routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspace.cwd),
resolveHealth: this.resolveScriptHealth ?? undefined,
})
: [],
...(resolvedProjectRecord
? {
project: await this.buildProjectPlacementForWorkspace(workspace, resolvedProjectRecord),
}
: {}),
};
}
private buildWorkspaceGitRuntimePayload(
snapshot: WorkspaceGitRuntimeSnapshot,
): NonNullable<WorkspaceDescriptorPayload["gitRuntime"]> | null {
if (!snapshot.git.isGit) {
return null;
}
return {
currentBranch: snapshot.git.currentBranch,
remoteUrl: snapshot.git.remoteUrl,
isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree,
isDirty: snapshot.git.isDirty,
aheadBehind: snapshot.git.aheadBehind,
aheadOfOrigin: snapshot.git.aheadOfOrigin,
behindOfOrigin: snapshot.git.behindOfOrigin,
};
}
private buildWorkspaceGitHubRuntimePayload(
snapshot: WorkspaceGitRuntimeSnapshot,
): NonNullable<WorkspaceDescriptorPayload["githubRuntime"]> {
return {
featuresEnabled: snapshot.github.featuresEnabled,
pullRequest: snapshot.github.pullRequest,
error: snapshot.github.error,
};
}
private async describeWorkspaceRecordWithGitData(
workspace: PersistedWorkspaceRecord,
projectRecord?: PersistedProjectRecord | null,
): Promise<WorkspaceDescriptorPayload> {
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
if (!snapshot) {
return base;
}
const checkout = checkoutLiteFromGitSnapshot(workspace.cwd, snapshot.git);
const displayName = deriveWorkspaceDisplayName({ cwd: workspace.cwd, checkout });
return {
...base,
name: displayName,
diffStat: snapshot.git.diffStat ?? null,
gitRuntime: this.buildWorkspaceGitRuntimePayload(snapshot) ?? undefined,
githubRuntime: this.buildWorkspaceGitHubRuntimePayload(snapshot),
};
}
private async describeCreatedWorktreeWorkspace(
result: CreatePaseoWorktreeResult,
): Promise<WorkspaceDescriptorPayload> {
const projectRecord = await this.projectRegistry.get(result.workspace.projectId);
return {
id: result.workspace.workspaceId,
projectId: result.workspace.projectId,
projectDisplayName: projectRecord
? resolveProjectDisplayName(projectRecord)
: result.workspace.projectId,
projectCustomName: projectRecord?.customName ?? null,
projectRootPath: projectRecord?.rootPath ?? result.repoRoot,
workspaceDirectory: result.workspace.cwd,
projectKind: "git",
workspaceKind: result.workspace.kind,
name: result.worktree.branchName || result.workspace.displayName,
archivingAt: null,
status: "done",
activityAt: null,
diffStat: { additions: 0, deletions: 0 },
scripts: [],
gitRuntime: {
currentBranch: result.worktree.branchName || null,
remoteUrl: null,
isPaseoOwnedWorktree: true,
isDirty: false,
aheadBehind: null,
aheadOfOrigin: null,
behindOfOrigin: null,
},
githubRuntime: null,
};
}
private async buildWorkspaceDescriptor(input: {
workspace: PersistedWorkspaceRecord;
projectRecord?: PersistedProjectRecord | null;
includeGitData: boolean;
}): Promise<WorkspaceDescriptorPayload> {
if (input.includeGitData && input.projectRecord?.kind === "git") {
return this.describeWorkspaceRecordWithGitData(input.workspace, input.projectRecord);
}
return this.describeWorkspaceRecord(input.workspace, input.projectRecord);
}
markWorkspaceArchiving(workspaceIds: Iterable<string>, archivingAt: string): void {
this.workspaceDirectory.markArchiving(workspaceIds, archivingAt);
}
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void {
this.workspaceDirectory.clearArchiving(workspaceIds);
}
private async buildWorkspaceDescriptorMap(options: {
includeGitData: boolean;
workspaceIds?: Iterable<string>;
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
return this.workspaceDirectory.buildDescriptorMap(options);
}
private resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string {
return this.workspaceDirectory.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
}
private matchesWorkspaceFilter(input: {
workspace: WorkspaceDescriptorPayload;
filter: FetchWorkspacesRequestFilter | undefined;
}): boolean {
return this.workspaceDirectory.matchesFilter(input);
}
private async listFetchWorkspacesEntries(
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
): Promise<{
entries: FetchWorkspacesResponseEntry[];
pageInfo: FetchWorkspacesResponsePageInfo;
}> {
try {
return await this.workspaceDirectory.listFetchEntries(request);
} catch (error) {
if (error instanceof CursorError) {
throw new SessionRequestError("invalid_cursor", error.message);
}
throw error;
}
}
private bufferOrEmitWorkspaceUpdate(
subscription: WorkspaceUpdatesSubscriptionState,
payload: WorkspaceUpdatePayload,
): void {
if (subscription.isBootstrapping) {
const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id;
subscription.pendingUpdatesByWorkspaceId.set(workspaceId, payload);
return;
}
const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id;
subscription.lastEmittedByWorkspaceId.set(workspaceId, payload);
this.emit({
type: "workspace_update",
payload,
});
}
private flushBootstrappedWorkspaceUpdates(options?: {
snapshotLatestActivityByWorkspaceId?: Map<string, number>;
}): void {
const subscription = this.workspaceUpdatesSubscription;
if (!subscription || !subscription.isBootstrapping) {
return;
}
subscription.isBootstrapping = false;
const pending = Array.from(subscription.pendingUpdatesByWorkspaceId.values());
subscription.pendingUpdatesByWorkspaceId.clear();
for (const payload of pending) {
if (payload.kind === "upsert") {
const snapshotLatestActivity = options?.snapshotLatestActivityByWorkspaceId?.get(
payload.workspace.id,
);
if (typeof snapshotLatestActivity === "number") {
const updateLatestActivity = payload.workspace.activityAt
? Date.parse(payload.workspace.activityAt)
: Number.NEGATIVE_INFINITY;
if (
!Number.isNaN(updateLatestActivity) &&
updateLatestActivity <= snapshotLatestActivity
) {
continue;
}
}
}
this.emit({
type: "workspace_update",
payload,
});
}
}
private async findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
const inputCwd = normalizePersistedWorkspaceId(cwd);
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const existingWorkspace = await this.findExactWorkspaceByDirectory(normalizedCwd, {
refreshGit: false,
});
if (existingWorkspace) {
if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) {
const timestamp = new Date().toISOString();
const displayName = basename(inputCwd) || inputCwd;
const projectRecord = createPersistedProjectRecord({
projectId: inputCwd,
rootPath: inputCwd,
kind: "non_git",
displayName,
createdAt: timestamp,
updatedAt: timestamp,
});
await this.projectRegistry.upsert(projectRecord);
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId: inputCwd,
projectId: projectRecord.projectId,
cwd: inputCwd,
kind: "directory",
displayName,
createdAt: timestamp,
updatedAt: timestamp,
});
await this.workspaceRegistry.upsert(workspaceRecord);
return workspaceRecord;
}
return this.reclassifyOrUnarchiveWorkspaceForDirectory({
workspace: existingWorkspace,
project: await this.projectRegistry.get(existingWorkspace.projectId),
cwd: normalizedCwd,
});
}
return this.createWorkspaceForDirectory(normalizedCwd);
}
private async createWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
const checkout = await this.workspaceGitService.getCheckout(cwd);
const membership = classifyDirectoryForProjectMembership({ cwd, checkout });
const timestamp = new Date().toISOString();
const projectRecord = await this.resolveProjectRecordForPlacement({
membership,
timestamp,
});
await this.projectRegistry.upsert(projectRecord);
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId: membership.workspaceId,
projectId: projectRecord.projectId,
cwd,
kind: membership.workspaceKind,
displayName: membership.workspaceDisplayName,
createdAt: timestamp,
updatedAt: timestamp,
});
await this.workspaceRegistry.upsert(workspaceRecord);
return workspaceRecord;
}
private async reclassifyOrUnarchiveWorkspaceForDirectory(input: {
workspace: PersistedWorkspaceRecord;
project: PersistedProjectRecord | null;
cwd: string;
}): Promise<PersistedWorkspaceRecord> {
const checkout = await this.workspaceGitService.getCheckout(input.cwd);
const membership = classifyDirectoryForProjectMembership({ cwd: input.cwd, checkout });
const timestamp = new Date().toISOString();
const projectRecord = await this.resolveProjectRecordForPlacement({
membership,
timestamp,
});
const projectId = projectRecord.projectId;
const kind = membership.workspaceKind;
const displayName = membership.workspaceDisplayName;
if (
input.workspace.workspaceId === membership.workspaceId &&
input.workspace.projectId === projectId &&
input.workspace.kind === kind &&
input.workspace.displayName === displayName
) {
return this.ensureWorkspaceRecordUnarchived(input.workspace);
}
await this.projectRegistry.upsert(projectRecord);
const nextWorkspace = {
...input.workspace,
workspaceId: membership.workspaceId,
projectId,
cwd: input.cwd,
kind,
displayName,
archivedAt: null,
updatedAt: timestamp,
};
await this.workspaceRegistry.upsert(nextWorkspace);
return nextWorkspace;
}
private async resolveProjectRecordForPlacement(input: {
membership: ReturnType<typeof classifyDirectoryForProjectMembership>;
timestamp: string;
}): Promise<PersistedProjectRecord> {
const rootPath = input.membership.projectRootPath;
const kind = input.membership.projectKind;
const projects = await this.projectRegistry.list();
const existingProject =
projects.find((project) => !project.archivedAt && project.rootPath === rootPath) ??
projects.find((project) => project.rootPath === rootPath) ??
null;
if (!existingProject) {
return createPersistedProjectRecord({
projectId: input.membership.projectKey,
rootPath,
kind,
displayName: input.membership.projectName,
createdAt: input.timestamp,
updatedAt: input.timestamp,
});
}
return {
...existingProject,
rootPath,
kind,
archivedAt: null,
updatedAt: input.timestamp,
};
}
private async ensureWorkspaceRecordUnarchived(
workspace: PersistedWorkspaceRecord,
): Promise<PersistedWorkspaceRecord> {
const project = await this.projectRegistry.get(workspace.projectId);
if (!workspace.archivedAt && (!project || !project.archivedAt)) {
return workspace;
}
const timestamp = new Date().toISOString();
let unarchivedWorkspace = workspace;
if (workspace.archivedAt) {
unarchivedWorkspace = { ...workspace, archivedAt: null, updatedAt: timestamp };
await this.workspaceRegistry.upsert(unarchivedWorkspace);
}
if (project?.archivedAt) {
await this.projectRegistry.upsert({
...project,
archivedAt: null,
updatedAt: timestamp,
});
}
return unarchivedWorkspace;
}
private async createPaseoWorktree(
input: CreatePaseoWorktreeInput,
options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
},
): Promise<CreatePaseoWorktreeResult> {
const result = await createPaseoWorktree(input, {
github: this.github,
...(options?.resolveDefaultBranch
? { resolveDefaultBranch: options.resolveDefaultBranch }
: {}),
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
workspaceGitService: this.workspaceGitService,
});
void Promise.all([
this.notifyGitMutation(input.cwd, "create-worktree"),
this.notifyGitMutation(result.worktree.worktreePath, "create-worktree"),
]).catch((error) => {
this.sessionLogger.warn(
{ err: error, cwd: input.cwd, worktreePath: result.worktree.worktreePath },
"Failed to warm git snapshots after creating worktree",
);
});
return result;
}
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const existingWorkspace = await archivePersistedWorkspaceRecord({
workspaceId,
archivedAt,
workspaceRegistry: this.workspaceRegistry,
projectRegistry: this.projectRegistry,
});
if (!existingWorkspace) {
this.removeWorkspaceGitSubscription(workspaceId);
return;
}
await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd);
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd);
this.removeWorkspaceGitSubscription(workspaceId);
}
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
if (!this.workspaceUpdatesSubscription) {
return;
}
try {
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords();
if (changedWorkspaceIds.size === 0) {
return;
}
await this.emitWorkspaceUpdatesForWorkspaceIds(changedWorkspaceIds, {
skipReconcile: true,
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Background workspace reconciliation failed");
}
}
private async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
const service = new WorkspaceReconciliationService({
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
logger: this.sessionLogger,
workspaceGitService: this.workspaceGitService,
});
const result = await service.runOnce();
const changedWorkspaceIds = new Set<string>();
const changedProjectIds = new Set<string>();
await Promise.all(
result.changesApplied.map(async (change) => {
switch (change.kind) {
case "workspace_archived":
await this.removeWorkspaceGitWatchTarget(change.directory);
this.scriptRuntimeStore?.removeForWorkspace(change.directory);
this.removeWorkspaceGitSubscription(change.workspaceId);
changedWorkspaceIds.add(change.workspaceId);
break;
case "workspace_updated":
changedWorkspaceIds.add(change.workspaceId);
break;
case "project_archived":
case "project_updated":
changedProjectIds.add(change.projectId);
break;
}
}),
);
if (changedProjectIds.size > 0) {
for (const workspace of await this.workspaceRegistry.list()) {
if (changedProjectIds.has(workspace.projectId)) {
changedWorkspaceIds.add(workspace.workspaceId);
}
}
}
return changedWorkspaceIds;
}
private async emitWorkspaceUpdatesForWorkspaceIds(
workspaceIds: Iterable<string>,
options?: { skipReconcile?: boolean; dedupeGitState?: boolean },
): Promise<void> {
const subscription = this.workspaceUpdatesSubscription;
if (!subscription) {
return;
}
const uniqueWorkspaceIds = new Set(Array.from(workspaceIds));
if (uniqueWorkspaceIds.size === 0) {
return;
}
const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({
workspaceIds: uniqueWorkspaceIds,
includeGitData: true,
});
for (const workspaceId of uniqueWorkspaceIds) {
const workspace = descriptorsByWorkspaceId.get(workspaceId);
const nextWorkspace =
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
? workspace
: null;
if (
options?.dedupeGitState &&
this.shouldSkipWorkspaceGitWatchUpdate(workspaceId, nextWorkspace)
) {
continue;
}
const watchTarget = this.workspaceGitWatchTargets.get(workspaceId);
if (watchTarget && this.onBranchChanged) {
const newBranchName = nextWorkspace?.name ?? null;
if (newBranchName !== watchTarget.lastBranchName) {
this.onBranchChanged(workspaceId, watchTarget.lastBranchName, newBranchName);
}
}
this.rememberWorkspaceGitDescriptorState(workspaceId, nextWorkspace);
if (!nextWorkspace) {
subscription.lastEmittedByWorkspaceId.delete(workspaceId);
this.bufferOrEmitWorkspaceUpdate(subscription, {
kind: "remove",
id: workspaceId,
});
continue;
}
const nextPayload: WorkspaceUpdatePayload = {
kind: "upsert",
workspace: nextWorkspace,
};
const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId);
if (
lastEmitted &&
lastEmitted.kind === "upsert" &&
equal(lastEmitted.workspace, nextWorkspace)
) {
continue;
}
this.bufferOrEmitWorkspaceUpdate(subscription, nextPayload);
}
if (!options?.skipReconcile) {
void this.reconcileAndEmitWorkspaceUpdates();
}
}
private async emitWorkspaceUpdateForCwd(
cwd: string,
options?: {
skipReconcile?: boolean;
dedupeGitState?: boolean;
},
): Promise<void> {
const workspaces = await this.workspaceRegistry.list();
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options);
}
private async handleFetchAgents(
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
): Promise<void> {
const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim();
const subscriptionId = resolveSubscriptionId(request.subscribe, requestedSubscriptionId);
try {
if (subscriptionId) {
this.agentUpdatesSubscription = {
subscriptionId,
filter: request.filter,
isBootstrapping: true,
pendingUpdatesByAgentId: new Map(),
};
}
const payload = await this.listFetchAgentsEntries(request);
const snapshotUpdatedAtByAgentId = new Map<string, number>();
for (const entry of payload.entries) {
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt);
if (!Number.isNaN(parsedUpdatedAt)) {
snapshotUpdatedAtByAgentId.set(entry.agent.id, parsedUpdatedAt);
}
}
this.emit({
type: "fetch_agents_response",
payload: {
requestId: request.requestId,
...(subscriptionId ? { subscriptionId } : {}),
...payload,
},
});
if (subscriptionId && this.agentUpdatesSubscription?.subscriptionId === subscriptionId) {
this.flushBootstrappedAgentUpdates({ snapshotUpdatedAtByAgentId });
}
} catch (error) {
if (subscriptionId && this.agentUpdatesSubscription?.subscriptionId === subscriptionId) {
this.agentUpdatesSubscription = null;
}
const code = error instanceof SessionRequestError ? error.code : "fetch_agents_failed";
const message = error instanceof Error ? error.message : "Failed to fetch agents";
this.sessionLogger.error({ err: error }, "Failed to handle fetch_agents_request");
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code,
},
});
}
}
private async handleFetchAgentHistory(
request: Extract<SessionInboundMessage, { type: "fetch_agent_history_request" }>,
): Promise<void> {
try {
const payload = await this.listFetchAgentsEntries(request);
this.emit({
type: "fetch_agent_history_response",
payload: {
requestId: request.requestId,
...payload,
},
});
} catch (error) {
const code = error instanceof SessionRequestError ? error.code : "fetch_agent_history_failed";
const message = error instanceof Error ? error.message : "Failed to fetch agent history";
this.sessionLogger.error({ err: error }, "Failed to handle fetch_agent_history_request");
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code,
},
});
}
}
private async handleFetchRecentProviderSessions(
request: Extract<SessionInboundMessage, { type: "fetch_recent_provider_sessions_request" }>,
): Promise<void> {
try {
const result = await listImportableProviderSessions({
request,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
providerSnapshotManager: this.providerSnapshotManager,
});
this.emit({
type: "fetch_recent_provider_sessions_response",
payload: {
requestId: request.requestId,
entries: result.entries,
...(result.filteredAlreadyImportedCount > 0
? { filteredAlreadyImportedCount: result.filteredAlreadyImportedCount }
: {}),
},
});
} catch (error) {
const code =
error instanceof ImportSessionsRequestError
? error.code
: "fetch_recent_provider_sessions_failed";
const message =
error instanceof Error ? error.message : "Failed to fetch recent provider sessions";
this.sessionLogger.error(
{ err: error },
"Failed to handle fetch_recent_provider_sessions_request",
);
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code,
},
});
}
}
private async handleFetchWorkspacesRequest(
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
): Promise<void> {
const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim();
const subscriptionId = resolveSubscriptionId(request.subscribe, requestedSubscriptionId);
try {
this.sessionLogger.debug(
{
requestId: request.requestId,
subscribeRequested: Boolean(request.subscribe),
filter: request.filter ?? null,
sort: request.sort ?? null,
page: request.page ?? null,
},
"fetch_workspaces_request_received",
);
if (subscriptionId) {
this.workspaceUpdatesSubscription = {
subscriptionId,
filter: request.filter,
isBootstrapping: true,
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
}
const payload = await this.listFetchWorkspacesEntries(request);
this.syncWorkspaceGitObservers(payload.entries);
this.sessionLogger.debug(
{
requestId: request.requestId,
subscriptionId,
pageInfo: payload.pageInfo,
payload: summarizeFetchWorkspacesEntries(payload.entries),
},
"fetch_workspaces_response_ready",
);
const snapshotLatestActivityByWorkspaceId = new Map<string, number>();
for (const entry of payload.entries) {
const parsedLatestActivity = entry.activityAt
? Date.parse(entry.activityAt)
: Number.NEGATIVE_INFINITY;
if (!Number.isNaN(parsedLatestActivity)) {
snapshotLatestActivityByWorkspaceId.set(entry.id, parsedLatestActivity);
}
}
this.emit({
type: "fetch_workspaces_response",
payload: {
requestId: request.requestId,
...(subscriptionId ? { subscriptionId } : {}),
...payload,
},
});
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId });
void this.reconcileAndEmitWorkspaceUpdates();
}
} catch (error) {
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
this.workspaceUpdatesSubscription = null;
}
const code = error instanceof SessionRequestError ? error.code : "fetch_workspaces_failed";
const message = error instanceof Error ? error.message : "Failed to fetch workspaces";
this.sessionLogger.error({ err: error }, "Failed to handle fetch_workspaces_request");
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code,
},
});
}
}
private async registerWorkspaceForImportedAgent(cwd: string): Promise<void> {
try {
const workspace = await this.findOrCreateWorkspaceForDirectory(cwd);
await this.syncWorkspaceGitObserverForWorkspace(workspace);
await this.describeWorkspaceRecord(workspace);
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
} catch (error) {
this.sessionLogger.warn(
{ err: error, cwd },
"Failed to register workspace for imported agent",
);
}
}
private async handleOpenProjectRequest(
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
): Promise<void> {
try {
const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd);
await this.syncWorkspaceGitObserverForWorkspace(workspace);
const descriptor = await this.describeWorkspaceRecord(workspace);
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
this.emit({
type: "open_project_response",
payload: {
requestId: request.requestId,
workspace: descriptor,
error: null,
},
});
void this.workspaceGitService
.getSnapshot(workspace.cwd, {
force: true,
includeGitHub: true,
reason: "open_project",
})
.catch((error) => {
this.sessionLogger.warn(
{ err: error, cwd: workspace.cwd },
"Background snapshot refresh failed after open_project",
);
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to open project";
this.sessionLogger.error({ err: error, cwd: request.cwd }, "Failed to open project");
this.emit({
type: "open_project_response",
payload: {
requestId: request.requestId,
workspace: null,
error: message,
},
});
}
}
private buildWorkspaceScriptPayloadSnapshot(
workspaceId: string,
workspaceDirectory: string,
): WorkspaceDescriptorPayload["scripts"] {
if (!this.scriptRouteStore || !this.scriptRuntimeStore) {
return [];
}
return buildWorkspaceScriptPayloads({
workspaceId,
workspaceDirectory,
paseoConfig: readPaseoConfigForProjection(workspaceDirectory, this.sessionLogger),
routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
gitMetadata: this.resolveWorkspaceScriptGitMetadata(workspaceDirectory),
resolveHealth: this.resolveScriptHealth ?? undefined,
});
}
private resolveWorkspaceScriptGitMetadata(
workspaceDirectory: string,
): { projectSlug: string; currentBranch: string | null } | undefined {
const snapshot = this.workspaceGitService.peekSnapshot(workspaceDirectory);
if (!snapshot) {
return undefined;
}
return {
projectSlug: deriveProjectSlug(
workspaceDirectory,
snapshot.git.isGit ? snapshot.git.remoteUrl : null,
),
currentBranch: snapshot.git.currentBranch,
};
}
private emitWorkspaceScriptStatusUpdate(workspaceId: string, workspaceDirectory: string): void {
this.emit({
type: "script_status_update",
payload: {
workspaceId,
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspaceId, workspaceDirectory),
},
});
}
async resolveAvailableEditorTargets(): Promise<EditorTargetDescriptorPayload[]> {
return listAvailableEditorTargets();
}
async getAvailableEditorTargets() {
return this.filterEditorsForClient(await this.getMemoizedAvailableEditorTargets());
}
async openEditorTarget(options: { editorId: EditorTargetId; path: string }): Promise<void> {
await openInEditorTarget(options);
}
private async handleStartWorkspaceScriptRequest(
request: StartWorkspaceScriptRequest,
): Promise<void> {
try {
if (!this.terminalManager || !this.scriptRouteStore || !this.scriptRuntimeStore) {
throw new Error("Workspace scripts are not available on this daemon");
}
const workspace = await this.workspaceRegistry.get(request.workspaceId);
if (!workspace) {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
const gitMetadata = await this.workspaceGitService.getWorkspaceGitMetadata(workspace.cwd);
const serviceResult = await spawnWorkspaceScript({
repoRoot: workspace.cwd,
workspaceId: workspace.workspaceId,
projectSlug: gitMetadata.projectSlug,
branchName: gitMetadata.currentBranch,
scriptName: request.scriptName,
daemonPort: this.getDaemonTcpPort?.() ?? null,
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore,
terminalManager: this.terminalManager,
logger: this.sessionLogger,
onLifecycleChanged: () => {
this.emitWorkspaceScriptStatusUpdate(workspace.workspaceId, workspace.cwd);
},
});
this.emitWorkspaceScriptStatusUpdate(workspace.workspaceId, workspace.cwd);
this.emit({
type: "start_workspace_script_response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
scriptName: request.scriptName,
terminalId: serviceResult.terminalId,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to start workspace script";
this.sessionLogger.error(
{
err: error,
workspaceId: request.workspaceId,
scriptName: request.scriptName,
},
"Failed to start workspace script",
);
this.emit({
type: "start_workspace_script_response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
scriptName: request.scriptName,
terminalId: null,
error: message,
},
});
}
}
private async handleListAvailableEditorsRequest(
request: Extract<SessionInboundMessage, { type: "list_available_editors_request" }>,
): Promise<void> {
try {
const editors = await this.getAvailableEditorTargets();
this.emit({
type: "list_available_editors_response",
payload: {
requestId: request.requestId,
editors,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to list available editors";
this.sessionLogger.error(
{ err: error, requestType: request.type },
"Failed to list available editors",
);
this.emit({
type: "list_available_editors_response",
payload: {
requestId: request.requestId,
editors: [],
error: message,
},
});
}
}
private async handleOpenInEditorRequest(
request: Extract<SessionInboundMessage, { type: "open_in_editor_request" }>,
): Promise<void> {
try {
await this.openEditorTarget({ editorId: request.editorId, path: request.path });
this.emit({
type: "open_in_editor_response",
payload: {
requestId: request.requestId,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to open in editor";
this.sessionLogger.error(
{
err: error,
editorId: request.editorId,
path: request.path,
requestType: request.type,
},
"Failed to open in editor",
);
this.emit({
type: "open_in_editor_response",
payload: {
requestId: request.requestId,
error: message,
},
});
}
}
private async handleCreatePaseoWorktreeRequest(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): Promise<void> {
return handleCreateWorktreeRequest(
{
paseoHome: this.paseoHome,
describeWorkspaceRecord: (result) => this.describeCreatedWorktreeWorkspace(result),
emit: (message) => this.emit(message),
sessionLogger: this.sessionLogger,
createPaseoWorktreeWorkflow: (input) => this.createPaseoWorktreeWorkflow(input),
},
request,
);
}
private async createPaseoWorktreeWorkflow(
input: CreatePaseoWorktreeInput,
options?: {
resolveDefaultBranch?: (repoRoot: string) => Promise<string>;
setupContinuation?: CreatePaseoWorktreeSetupContinuationInput;
},
): Promise<CreatePaseoWorktreeWorkflowResult> {
return createWorktreeWorkflow(
{
paseoHome: this.paseoHome,
createPaseoWorktree: (workflowInput, serviceOptions) =>
this.createPaseoWorktree(workflowInput, serviceOptions),
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
this.scheduleAutoNameWorkspaceBranchForFirstAgent(autoNameInput),
emitWorkspaceUpdateForCwd: (cwd, emitOptions) =>
this.emitWorkspaceUpdateForCwd(cwd, emitOptions),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
this.workspaceSetupSnapshots.set(workspaceId, snapshot);
},
emit: (message) => this.emit(message),
sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager,
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
scriptRouteStore: this.scriptRouteStore,
scriptRuntimeStore: this.scriptRuntimeStore,
getDaemonTcpPort: this.getDaemonTcpPort,
getDaemonTcpHost: this.getDaemonTcpHost,
onScriptsChanged: (workspaceId, workspaceDirectory) => {
this.emitWorkspaceScriptStatusUpdate(workspaceId, workspaceDirectory);
},
},
input,
options,
);
}
private async handleWorkspaceSetupStatusRequest(
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
): Promise<void> {
return handleWorkspaceSetupStatusRequestMessage(
{
emit: (message) => this.emit(message),
workspaceSetupSnapshots: this.workspaceSetupSnapshots,
},
request,
);
}
private async handleArchiveWorkspaceRequest(
request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>,
): Promise<void> {
try {
const existing = await this.workspaceRegistry.get(request.workspaceId);
if (!existing) {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
if (existing.kind === "worktree") {
throw new Error("Use worktree archive for Paseo worktrees");
}
const archivedAt = new Date().toISOString();
await this.archiveWorkspaceRecord(existing.workspaceId, archivedAt);
await this.emitWorkspaceUpdateForCwd(existing.cwd);
this.emit({
type: "archive_workspace_response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
archivedAt,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to archive workspace";
this.sessionLogger.error(
{ err: error, workspaceId: request.workspaceId },
"Failed to archive workspace",
);
this.emit({
type: "archive_workspace_response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
archivedAt: null,
error: message,
},
});
}
}
private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise<void> {
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier);
if (!resolved.ok) {
this.emit({
type: "fetch_agent_response",
payload: { requestId, agent: null, project: null, error: resolved.error },
});
return;
}
const agent = await this.getAgentPayloadById(resolved.agentId);
if (!agent) {
this.emit({
type: "fetch_agent_response",
payload: {
requestId,
agent: null,
project: null,
error: `Agent not found: ${resolved.agentId}`,
},
});
return;
}
const project = await this.buildProjectPlacementForCwd(agent.cwd);
this.emit({
type: "fetch_agent_response",
payload: { requestId, agent, project, error: null },
});
}
private shouldUseFullTimelineForProjectedPage(input: {
timeline: AgentTimelineFetchResult;
}): boolean {
const { timeline } = input;
if (timeline.reset || timeline.rows.length === 0 || !timeline.hasOlder) {
return false;
}
const firstRow = timeline.rows[0];
if (
firstRow?.item.type === "assistant_message" ||
firstRow?.item.type === "reasoning" ||
firstRow?.item.type === "tool_call"
) {
return true;
}
return timeline.rows.some((row) => row.item.type === "tool_call");
}
private async handleFetchAgentTimelineRequest(
msg: Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>,
): Promise<void> {
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
const projection: TimelineProjectionMode = "projected";
const requestedLimit = msg.limit;
const pageLimit = requestedLimit ?? (direction === "after" ? 0 : 200);
const cursor: AgentTimelineCursor | undefined = msg.cursor
? {
epoch: msg.cursor.epoch,
seq: msg.cursor.seq,
}
: undefined;
try {
const snapshot = await ensureAgentLoaded(msg.agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
const agentPayload = await this.buildAgentPayload(snapshot);
const controlTimeline = this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit: pageLimit,
});
const timeline = this.shouldUseFullTimelineForProjectedPage({
timeline: controlTimeline,
})
? this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 0 })
: controlTimeline;
const projectedPage = selectProjectedTimelinePage({
rows: timeline.rows,
bounds: timeline.window,
direction: controlTimeline.reset ? "tail" : direction,
...(cursor ? { cursorSeq: cursor.seq } : {}),
limit: pageLimit,
});
const startCursor =
projectedPage.startSeq !== null
? { epoch: timeline.epoch, seq: projectedPage.startSeq }
: null;
const endCursor =
projectedPage.endSeq !== null ? { epoch: timeline.epoch, seq: projectedPage.endSeq } : null;
this.emit({
type: "fetch_agent_timeline_response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
agent: agentPayload,
direction,
projection,
epoch: timeline.epoch,
reset: controlTimeline.reset,
staleCursor: controlTimeline.staleCursor,
gap: controlTimeline.gap,
window: timeline.window,
startCursor,
endCursor,
hasOlder:
projectedPage.hasOlder ||
(projectedPage.startSeq !== null && projectedPage.startSeq > timeline.window.minSeq),
hasNewer: projectedPage.hasNewer,
entries: projectedPage.entries.map((entry) => ({
provider: snapshot.provider,
item: entry.item,
timestamp: entry.timestamp,
seqStart: entry.seqStart,
seqEnd: entry.seqEnd,
sourceSeqRanges: entry.sourceSeqRanges,
collapsed: this.supports(CLIENT_CAPS.reasoningMergeEnum)
? entry.collapsed
: entry.collapsed.filter((value) => value !== "reasoning_merge"),
})),
error: null,
},
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId: msg.agentId },
"Failed to handle fetch_agent_timeline_request",
);
this.emit({
type: "fetch_agent_timeline_response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
agent: null,
direction,
projection,
epoch: "",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 0, maxSeq: 0, nextSeq: 0 },
startCursor: null,
endCursor: null,
hasOlder: false,
hasNewer: false,
entries: [],
error: error instanceof Error ? error.message : String(error),
},
});
}
}
private async handleSendAgentMessageRequest(
msg: Extract<SessionInboundMessage, { type: "send_agent_message_request" }>,
): Promise<void> {
const resolved = await this.resolveAgentIdentifier(msg.agentId);
if (!resolved.ok) {
this.emit({
type: "send_agent_message_response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
accepted: false,
error: resolved.error,
},
});
return;
}
try {
const agentId = resolved.agentId;
const prompt = this.buildAgentPrompt(msg.text, msg.images, msg.attachments);
this.sessionLogger.trace(
{
agentId,
messageId: msg.messageId,
textPrefix: msg.text.slice(0, 80),
},
"agent.session.send_agent_message",
);
let dispatchResult: { outOfBand: boolean };
try {
dispatchResult = await sendPromptToAgent({
agentManager: this.agentManager,
agentStorage: this.agentStorage,
agentId,
prompt,
messageId: msg.messageId,
logger: this.sessionLogger,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.handleAgentRunError(agentId, error, "Failed to send agent message");
this.emit({
type: "send_agent_message_response",
payload: {
requestId: msg.requestId,
agentId,
accepted: false,
error: message,
},
});
return;
}
if (dispatchResult.outOfBand) {
this.emit({
type: "send_agent_message_response",
payload: {
requestId: msg.requestId,
agentId,
accepted: true,
error: null,
},
});
return;
}
try {
await waitForAgentRunStartWithTimeout(this.agentManager, agentId);
} catch (error) {
this.emit({
type: "send_agent_message_response",
payload: {
requestId: msg.requestId,
agentId,
accepted: false,
error: errorToFriendlyMessage(error),
},
});
return;
}
this.emit({
type: "send_agent_message_response",
payload: {
requestId: msg.requestId,
agentId,
accepted: true,
error: null,
},
});
} catch (error) {
this.emit({
type: "send_agent_message_response",
payload: {
requestId: msg.requestId,
agentId: resolved.agentId,
accepted: false,
error: errorToFriendlyMessage(error),
},
});
}
}
private async handleWaitForFinish(
agentIdOrIdentifier: string,
requestId: string,
timeoutMs?: number,
): Promise<void> {
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier);
if (!resolved.ok) {
this.emit({
type: "wait_for_finish_response",
payload: {
requestId,
status: "error",
final: null,
error: resolved.error,
lastMessage: null,
},
});
return;
}
const agentId = resolved.agentId;
const live = this.agentManager.getAgent(agentId);
if (!live) {
const record = await this.agentStorage.get(agentId);
if (!record || record.internal) {
this.emit({
type: "wait_for_finish_response",
payload: {
requestId,
status: "error",
final: null,
error: `Agent not found: ${agentId}`,
lastMessage: null,
},
});
return;
}
const final = this.buildStoredAgentPayload(record);
let status: "permission" | "error" | "idle";
if (record.attentionReason === "permission") {
status = "permission";
} else if (record.lastStatus === "error") {
status = "error";
} else {
status = "idle";
}
const error = resolveWaitForFinishError({ status, final });
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status, final, error, lastMessage: null },
});
return;
}
const abortController = new AbortController();
const hasTimeout = typeof timeoutMs === "number" && timeoutMs > 0;
const timeoutHandle = hasTimeout
? setTimeout(() => {
abortController.abort("timeout");
}, timeoutMs)
: null;
try {
let result = await this.agentManager.waitForAgentEvent(agentId, {
signal: abortController.signal,
waitForActive: true,
});
let final = await this.getAgentPayloadById(agentId);
if (!final) {
throw new Error(`Agent ${agentId} disappeared while waiting`);
}
let status: "permission" | "error" | "idle";
if (result.permission) {
status = "permission";
} else if (result.status === "error") {
status = "error";
} else {
status = "idle";
}
const error = resolveWaitForFinishError({ status, final });
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status, final, error, lastMessage: result.lastMessage },
});
} catch (error) {
const isAbort =
error instanceof Error &&
(error.name === "AbortError" || error.message.toLowerCase().includes("aborted"));
if (!isAbort) {
const message = errorToFriendlyMessage(error);
this.sessionLogger.error({ err: error, agentId }, "wait_for_finish_request failed");
const final = await this.getAgentPayloadById(agentId);
this.emit({
type: "wait_for_finish_response",
payload: {
requestId,
status: "error",
final,
error: message,
lastMessage: null,
},
});
return;
}
const final = await this.getAgentPayloadById(agentId);
if (!final) {
throw new Error(`Agent ${agentId} disappeared while waiting`, { cause: error });
}
this.emit({
type: "wait_for_finish_response",
payload: { requestId, status: "timeout", final, error: null, lastMessage: null },
});
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
/**
* Handle audio chunk for buffering and transcription
*/
private async ensureAudioBufferForFormat(
chunkFormat: string,
isPCMChunk: boolean,
): Promise<AudioBufferState> {
if (!this.audioBuffer) {
this.audioBuffer = {
chunks: [],
format: chunkFormat,
isPCM: isPCMChunk,
totalPCMBytes: 0,
};
return this.audioBuffer;
}
if (this.audioBuffer.isPCM !== isPCMChunk) {
this.sessionLogger.debug(
{
oldFormat: this.audioBuffer.isPCM ? "pcm" : this.audioBuffer.format,
newFormat: chunkFormat,
},
`Audio format changed mid-stream, flushing current buffer`,
);
const finalized = this.finalizeBufferedAudio();
if (finalized) {
await this.processCompletedAudio(finalized.audio, finalized.format);
}
this.audioBuffer = {
chunks: [],
format: chunkFormat,
isPCM: isPCMChunk,
totalPCMBytes: 0,
};
return this.audioBuffer;
}
if (!this.audioBuffer.isPCM) {
this.audioBuffer.format = chunkFormat;
}
return this.audioBuffer;
}
private async forwardAudioChunkToVoiceTurn(
msg: Extract<SessionInboundMessage, { type: "voice_audio_chunk" }>,
chunkFormat: string,
): Promise<void> {
if (!this.voiceTurnController) {
throw new Error("Voice mode is enabled but the voice turn controller is not running");
}
const chunkBytes = Buffer.byteLength(msg.audio, "base64");
this.voiceInputChunkCount += 1;
this.voiceInputBytes += chunkBytes;
const now = Date.now();
if (this.voiceInputChunkCount % 50 === 0 || now - this.voiceInputWindowStartedAt >= 1000) {
this.sessionLogger.info(
{
chunkCount: this.voiceInputChunkCount,
audioBytes: this.voiceInputBytes,
windowMs: now - this.voiceInputWindowStartedAt,
format: chunkFormat,
},
"Voice input chunk summary",
);
this.voiceInputWindowStartedAt = now;
this.voiceInputChunkCount = 0;
this.voiceInputBytes = 0;
}
await this.voiceTurnController.appendClientChunk({
audioBase64: msg.audio,
format: chunkFormat,
});
}
private async handleAudioChunk(
msg: Extract<SessionInboundMessage, { type: "voice_audio_chunk" }>,
): Promise<void> {
if (!this.isVoiceMode) {
this.sessionLogger.warn(
"Received voice_audio_chunk while voice mode is disabled; transcript will be emitted but voice assistant turn is skipped",
);
}
const chunkFormat = msg.format || "audio/wav";
if (this.isVoiceMode) {
await this.forwardAudioChunkToVoiceTurn(msg, chunkFormat);
return;
}
const chunkBuffer = Buffer.from(msg.audio, "base64");
const isPCMChunk = chunkFormat.toLowerCase().includes("pcm");
const buffer = await this.ensureAudioBufferForFormat(chunkFormat, isPCMChunk);
buffer.chunks.push(chunkBuffer);
if (buffer.isPCM) {
buffer.totalPCMBytes += chunkBuffer.length;
}
// In non-voice mode, use streaming threshold to process chunks
const reachedStreamingThreshold =
!this.isVoiceMode && buffer.isPCM && buffer.totalPCMBytes >= MIN_STREAMING_SEGMENT_BYTES;
if (!msg.isLast && reachedStreamingThreshold) {
return;
}
const bufferedState = this.audioBuffer;
const finalized = this.finalizeBufferedAudio();
if (!finalized) {
return;
}
if (!msg.isLast && reachedStreamingThreshold) {
this.sessionLogger.debug(
{
minDuration: MIN_STREAMING_SEGMENT_DURATION_MS,
pcmBytes: bufferedState?.totalPCMBytes ?? 0,
},
`Minimum chunk duration reached (~${MIN_STREAMING_SEGMENT_DURATION_MS}ms, ${
bufferedState?.totalPCMBytes ?? 0
} PCM bytes) – triggering STT`,
);
} else {
this.sessionLogger.debug(
{ audioBytes: finalized.audio.length, chunks: bufferedState?.chunks.length ?? 0 },
`Complete audio segment (${finalized.audio.length} bytes, ${bufferedState?.chunks.length ?? 0} chunk(s))`,
);
}
await this.processCompletedAudio(finalized.audio, finalized.format);
}
private finalizeBufferedAudio(): { audio: Buffer; format: string } | null {
if (!this.audioBuffer) {
return null;
}
const bufferState = this.audioBuffer;
this.audioBuffer = null;
if (bufferState.isPCM) {
const pcmBuffer = Buffer.concat(bufferState.chunks);
const wavBuffer = convertPCMToWavBuffer(
pcmBuffer,
PCM_SAMPLE_RATE,
PCM_CHANNELS,
PCM_BITS_PER_SAMPLE,
);
return {
audio: wavBuffer,
format: "audio/wav",
};
}
return {
audio: Buffer.concat(bufferState.chunks),
format: bufferState.format,
};
}
private async processCompletedAudio(audio: Buffer, format: string): Promise<void> {
if (this.processingPhase === "transcribing") {
this.sessionLogger.debug(
{ phase: this.processingPhase, segmentCount: this.pendingAudioSegments.length + 1 },
`Buffering audio segment (phase: ${this.processingPhase})`,
);
this.pendingAudioSegments.push({
audio,
format,
});
this.setBufferTimeout();
return;
}
if (this.pendingAudioSegments.length > 0) {
this.pendingAudioSegments.push({
audio,
format,
});
this.sessionLogger.debug(
{ segmentCount: this.pendingAudioSegments.length },
`Processing ${this.pendingAudioSegments.length} buffered segments together`,
);
const pendingSegments = [...this.pendingAudioSegments];
this.pendingAudioSegments = [];
this.clearBufferTimeout();
const combinedAudio = Buffer.concat(pendingSegments.map((segment) => segment.audio));
const combinedFormat = pendingSegments[pendingSegments.length - 1].format;
await this.processAudio(combinedAudio, combinedFormat);
return;
}
await this.processAudio(audio, format);
}
private async flushPendingAudioSegments(reason: string): Promise<void> {
if (this.processingPhase === "transcribing" || this.pendingAudioSegments.length === 0) {
return;
}
const pendingSegments = [...this.pendingAudioSegments];
this.pendingAudioSegments = [];
this.clearBufferTimeout();
this.sessionLogger.debug(
{ reason, segmentCount: pendingSegments.length },
`Flushing ${pendingSegments.length} buffered audio segment(s)`,
);
const combinedAudio = Buffer.concat(pendingSegments.map((segment) => segment.audio));
const combinedFormat = pendingSegments[pendingSegments.length - 1].format;
await this.processAudio(combinedAudio, combinedFormat);
}
/**
* Process audio through STT and then LLM
*/
private async processAudio(audio: Buffer, format: string): Promise<void> {
this.setPhase("transcribing");
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "system",
content: "Transcribing audio...",
},
});
try {
const requestId = uuidv4();
const result = await this.sttManager.transcribe(audio, format, {
requestId,
label: this.isVoiceMode ? "voice" : "buffered",
});
const transcriptText = result.text.trim();
this.sessionLogger.info(
{
requestId,
isVoiceMode: this.isVoiceMode,
transcriptLength: transcriptText.length,
transcript: transcriptText,
},
"Transcription result",
);
await this.handleTranscriptionResultPayload({
text: result.text,
language: result.language,
duration: result.duration,
requestId,
avgLogprob: result.avgLogprob,
isLowConfidence: result.isLowConfidence,
byteLength: result.byteLength,
format: result.format,
debugRecordingPath: result.debugRecordingPath,
});
} catch (error) {
this.setPhase("idle");
this.clearSpeechInProgress("transcription error");
await this.flushPendingAudioSegments("transcription error");
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Transcription error: ${getErrorMessage(error)}`,
},
});
throw error;
}
}
private async handleTranscriptionResultPayload(
result: VoiceTranscriptionResultPayload,
): Promise<void> {
const transcriptText = result.text.trim();
this.emit({
type: "transcription_result",
payload: {
text: result.text,
...(result.language ? { language: result.language } : {}),
...(result.duration !== undefined ? { duration: result.duration } : {}),
requestId: result.requestId,
...(result.avgLogprob !== undefined ? { avgLogprob: result.avgLogprob } : {}),
...(result.isLowConfidence !== undefined
? { isLowConfidence: result.isLowConfidence }
: {}),
...(result.byteLength !== undefined ? { byteLength: result.byteLength } : {}),
...(result.format ? { format: result.format } : {}),
...(result.debugRecordingPath ? { debugRecordingPath: result.debugRecordingPath } : {}),
},
});
if (!transcriptText) {
this.sessionLogger.debug("Empty transcription (false positive), not aborting");
this.setPhase("idle");
this.clearSpeechInProgress("empty transcription");
await this.flushPendingAudioSegments("empty transcription");
return;
}
// Has content - abort any in-progress stream now
this.createAbortController();
if (result.debugRecordingPath) {
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "system",
content: `Saved input audio: ${result.debugRecordingPath}`,
metadata: {
recordingPath: result.debugRecordingPath,
...(result.format ? { format: result.format } : {}),
requestId: result.requestId,
},
},
});
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "transcript",
content: result.text,
metadata: {
...(result.language ? { language: result.language } : {}),
...(result.duration !== undefined ? { duration: result.duration } : {}),
},
},
});
this.clearSpeechInProgress("transcription complete");
this.setPhase("idle");
if (!this.isVoiceMode) {
this.sessionLogger.debug(
{ requestId: result.requestId },
"Skipping voice agent processing because voice mode is disabled",
);
await this.flushPendingAudioSegments("voice mode disabled");
return;
}
const agentId = this.voiceModeAgentId;
if (!agentId) {
this.sessionLogger.warn(
{ requestId: result.requestId },
"Skipping voice agent processing because no agent is currently voice-enabled",
);
await this.flushPendingAudioSegments("no active voice agent");
return;
}
await this.handleSendAgentMessage(
agentId,
result.text,
undefined,
undefined,
undefined,
undefined,
{ spokenInput: true },
);
await this.flushPendingAudioSegments("transcription complete");
}
private registerVoiceBridgeForAgent(agentId: string): void {
this.registerVoiceSpeakHandler?.(agentId, async ({ text, signal }) => {
this.sessionLogger.info(
{
agentId,
textLength: text.length,
preview: text.slice(0, 160),
},
"Voice speak tool call received by session handler",
);
const abortSignal = signal ?? this.abortController.signal;
await this.ttsManager.generateAndWaitForPlayback(
text,
(msg) => this.emit(msg),
abortSignal,
true,
);
this.sessionLogger.info(
{ agentId, textLength: text.length },
"Voice speak tool call finished playback",
);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "assistant",
content: text,
},
});
});
this.registerVoiceCallerContext?.(agentId, {
childAgentDefaultLabels: {},
allowCustomCwd: false,
enableVoiceTools: true,
});
}
/**
* Handle abort request from client
*/
private async handleAbort(): Promise<void> {
this.sessionLogger.info(
{ phase: this.processingPhase },
`Abort request, phase: ${this.processingPhase}`,
);
this.abortController.abort();
this.ttsManager.cancelPendingPlaybacks("abort request");
// Voice abort should always interrupt active agent output immediately.
if (this.isVoiceMode && this.voiceModeAgentId) {
try {
await this.interruptAgentIfRunning(this.voiceModeAgentId);
} catch (error) {
this.sessionLogger.warn(
{ err: error, agentId: this.voiceModeAgentId },
"Failed to interrupt active voice-mode agent on abort",
);
}
}
if (this.processingPhase === "transcribing") {
// Still in STT phase - we'll buffer the next audio
this.sessionLogger.debug("Will buffer next audio (currently transcribing)");
// Phase stays as 'transcribing', handleAudioChunk will handle buffering
return;
}
// Reset phase to idle and clear pending non-voice buffers.
this.setPhase("idle");
this.pendingAudioSegments = [];
this.clearBufferTimeout();
}
/**
* Handle audio playback confirmation from client
*/
private handleAudioPlayed(id: string): void {
this.ttsManager.confirmAudioPlayed(id);
}
/**
* Mark speech detection start and abort any active playback/agent run.
*/
private async handleVoiceSpeechStart(): Promise<void> {
if (this.speechInProgress) {
return;
}
const chunkReceivedAt = Date.now();
const phaseBeforeAbort = this.processingPhase;
const hadActiveStream = this.hasActiveAgentRun(this.voiceModeAgentId);
this.speechInProgress = true;
this.sessionLogger.debug("Voice speech detected – aborting playback and active agent run");
if (this.pendingAudioSegments.length > 0) {
this.sessionLogger.debug(
{ segmentCount: this.pendingAudioSegments.length },
`Dropping ${this.pendingAudioSegments.length} buffered audio segment(s) due to voice speech`,
);
this.pendingAudioSegments = [];
}
if (this.audioBuffer) {
this.sessionLogger.debug(
{ chunks: this.audioBuffer.chunks.length, pcmBytes: this.audioBuffer.totalPCMBytes },
`Clearing partial audio buffer (${this.audioBuffer.chunks.length} chunk(s)${
this.audioBuffer.isPCM ? `, ${this.audioBuffer.totalPCMBytes} PCM bytes` : ""
})`,
);
this.audioBuffer = null;
}
this.clearBufferTimeout();
this.abortController.abort();
await this.handleAbort();
const latencyMs = Date.now() - chunkReceivedAt;
this.sessionLogger.debug(
{ latencyMs, phaseBeforeAbort, hadActiveStream },
"[Telemetry] barge_in.llm_abort_latency",
);
}
/**
* Clear speech-in-progress flag once the user turn has completed
*/
private clearSpeechInProgress(reason: string): void {
if (!this.speechInProgress) {
return;
}
this.speechInProgress = false;
this.sessionLogger.debug({ reason }, `Speech turn complete (${reason}) – resuming TTS`);
}
/**
* Create new AbortController, aborting the previous one
*/
private createAbortController(): AbortController {
this.abortController.abort();
this.abortController = new AbortController();
this.ttsDebugStreams.clear();
return this.abortController;
}
/**
* Set the processing phase
*/
private setPhase(phase: ProcessingPhase): void {
this.processingPhase = phase;
this.sessionLogger.debug({ phase }, `Phase: ${phase}`);
}
/**
* Set timeout to process buffered audio segments
*/
private setBufferTimeout(): void {
this.clearBufferTimeout();
this.bufferTimeout = setTimeout(async () => {
this.sessionLogger.debug("Buffer timeout reached, processing pending segments");
if (this.processingPhase === "transcribing") {
this.sessionLogger.debug(
{ segmentCount: this.pendingAudioSegments.length },
"Buffer timeout deferred because transcription is still in progress",
);
this.setBufferTimeout();
return;
}
if (this.pendingAudioSegments.length > 0) {
const segments = [...this.pendingAudioSegments];
this.pendingAudioSegments = [];
this.bufferTimeout = null;
const combined = Buffer.concat(segments.map((s) => s.audio));
await this.processAudio(combined, segments[0].format);
}
}, 10000); // 10 second timeout
}
/**
* Clear buffer timeout
*/
private clearBufferTimeout(): void {
if (this.bufferTimeout) {
clearTimeout(this.bufferTimeout);
this.bufferTimeout = null;
}
}
/**
* Emit a message to the client
*/
private emit(msg: SessionOutboundMessage): void {
this.sessionLogger.trace(
{
messageType: msg.type,
payloadBytes: JSON.stringify(msg).length,
},
"agent.session.outbound",
);
if (
msg.type === "audio_output" &&
(process.env.TTS_DEBUG_AUDIO_DIR || isPaseoDictationDebugEnabled()) &&
msg.payload.groupId &&
typeof msg.payload.audio === "string"
) {
const groupId = msg.payload.groupId;
const existing =
this.ttsDebugStreams.get(groupId) ??
({ format: msg.payload.format, chunks: [] } satisfies {
format: string;
chunks: Buffer[];
});
try {
existing.chunks.push(Buffer.from(msg.payload.audio, "base64"));
existing.format = msg.payload.format;
this.ttsDebugStreams.set(groupId, existing);
} catch {
// ignore malformed base64
}
if (msg.payload.isLastChunk) {
const final = this.ttsDebugStreams.get(groupId);
this.ttsDebugStreams.delete(groupId);
if (final && final.chunks.length > 0) {
void (async () => {
const recordingPath = await maybePersistTtsDebugAudio(
Buffer.concat(final.chunks),
{ sessionId: this.sessionId, groupId, format: final.format },
this.sessionLogger,
);
if (recordingPath) {
this.onMessage({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "system",
content: `Saved TTS audio: ${recordingPath}`,
metadata: { recordingPath, format: final.format, groupId },
},
});
}
})();
}
}
}
this.onMessage(msg);
}
private emitBinary(frame: Uint8Array): void {
if (!this.onBinaryMessage) {
return;
}
try {
this.onBinaryMessage(frame);
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to emit binary frame");
}
}
/**
* Clean up session resources
*/
public async cleanup(): Promise<void> {
this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup");
if (this.unsubscribeAgentEvents) {
this.unsubscribeAgentEvents();
this.unsubscribeAgentEvents = null;
}
if (this.unsubscribeProviderSnapshotEvents) {
this.unsubscribeProviderSnapshotEvents();
this.unsubscribeProviderSnapshotEvents = null;
}
// Abort any ongoing operations
this.abortController.abort();
// Clear timeouts
this.clearBufferTimeout();
// Clear buffers
this.pendingAudioSegments = [];
this.audioBuffer = null;
await this.stopVoiceTurnController();
// Cleanup managers
this.ttsManager.cleanup();
this.sttManager.cleanup();
this.dictationStreamManager.cleanupAll();
// Close MCP clients
if (this.agentMcpClient) {
try {
await this.agentMcpClient.close();
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to close Agent MCP client");
}
this.agentMcpClient = null;
this.agentTools = null;
}
await this.disableVoiceModeForActiveAgent(true);
this.isVoiceMode = false;
this.terminalController.dispose();
for (const unsubscribe of this.checkoutDiffSubscriptions.values()) {
unsubscribe();
}
this.checkoutDiffSubscriptions.clear();
for (const unsubscribe of this.workspaceGitSubscriptions.values()) {
unsubscribe();
}
this.workspaceGitSubscriptions.clear();
}
private emitChatRpcError(request: { requestId: string; type: string }, error: unknown): void {
const message = error instanceof Error ? error.message : "Chat request failed";
const code = error instanceof ChatServiceError ? error.code : "chat_request_failed";
this.sessionLogger.error({ err: error, requestType: request.type }, "Chat request failed");
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code,
},
});
}
private async handleChatCreateRequest(
request: Extract<SessionInboundMessage, { type: "chat/create" }>,
): Promise<void> {
try {
const room = await this.chatService.createRoom({
name: request.name,
purpose: request.purpose,
});
this.emit({
type: "chat/create/response",
payload: {
requestId: request.requestId,
room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private async handleChatListRequest(
request: Extract<SessionInboundMessage, { type: "chat/list" }>,
): Promise<void> {
try {
const rooms = await this.chatService.listRooms();
this.emit({
type: "chat/list/response",
payload: {
requestId: request.requestId,
rooms,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private async handleChatInspectRequest(
request: Extract<SessionInboundMessage, { type: "chat/inspect" }>,
): Promise<void> {
try {
const result = await this.chatService.inspectRoom({
room: request.room,
});
this.emit({
type: "chat/inspect/response",
payload: {
requestId: request.requestId,
room: result.room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private async handleChatDeleteRequest(
request: Extract<SessionInboundMessage, { type: "chat/delete" }>,
): Promise<void> {
try {
const result = await this.chatService.deleteRoom({
room: request.room,
});
this.emit({
type: "chat/delete/response",
payload: {
requestId: request.requestId,
room: result.room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private async handleChatPostRequest(
request: Extract<SessionInboundMessage, { type: "chat/post" }>,
): Promise<void> {
try {
const authorAgentId = request.authorAgentId?.trim() || this.clientId;
const mentionAgentIds = parseMentionAgentIds(request.body);
const storedAgents = await this.agentStorage.list();
const liveAgents = this.agentManager.listAgents();
const fanout = await prepareChatMentionFanout({
authorAgentId,
mentionAgentIds,
storedAgents,
liveAgents,
listRoomPosterAgentIds: () =>
this.chatService.listRoomPosterAgentIds({ room: request.room }),
});
if (!fanout.ok) {
throw new ChatServiceError("chat_mention_fanout_limit_exceeded", fanout.error);
}
const message = await this.chatService.dispatchMessage({
room: request.room,
authorAgentId,
body: request.body,
replyToMessageId: request.replyToMessageId,
});
this.emit({
type: "chat/post/response",
payload: {
requestId: request.requestId,
message,
error: null,
},
});
void notifyChatMentions({
room: request.room,
authorAgentId,
body: request.body,
mentionAgentIds: message.mentionAgentIds,
logger: this.sessionLogger,
storedAgents,
liveAgents,
prepared: fanout.prepared,
resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier),
sendAgentMessage: async (agentId, text) => {
await sendPromptToAgent({
agentManager: this.agentManager,
agentStorage: this.agentStorage,
agentId,
prompt: formatSystemNotificationPrompt(text),
unarchive: false,
logger: this.sessionLogger,
});
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private async handleChatReadRequest(
request: Extract<SessionInboundMessage, { type: "chat/read" }>,
): Promise<void> {
try {
const messages = await this.chatService.readMessages({
room: request.room,
limit: request.limit,
since: request.since,
authorAgentId: request.authorAgentId,
});
this.emit({
type: "chat/read/response",
payload: {
requestId: request.requestId,
messages,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private async handleChatWaitRequest(
request: Extract<SessionInboundMessage, { type: "chat/wait" }>,
): Promise<void> {
try {
const messages = await this.chatService.waitForMessages({
room: request.room,
afterMessageId: request.afterMessageId,
timeoutMs: request.timeoutMs,
});
this.emit({
type: "chat/wait/response",
payload: {
requestId: request.requestId,
messages,
timedOut: messages.length === 0,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private toScheduleSummary(
schedule: Awaited<ReturnType<ScheduleService["inspect"]>>,
): Extract<
SessionOutboundMessage,
{ type: "schedule/list/response" }
>["payload"]["schedules"][number] {
const { runs: _runs, ...summary } = schedule;
return summary;
}
private emitScheduleRpcError(
request: Extract<
SessionInboundMessage,
{
type:
| "schedule/create"
| "schedule/list"
| "schedule/inspect"
| "schedule/logs"
| "schedule/pause"
| "schedule/resume"
| "schedule/delete"
| "schedule/run-once"
| "schedule/update";
}
>,
error: unknown,
): void {
const message = error instanceof Error ? error.message : String(error);
this.sessionLogger.error({ err: error, requestType: request.type }, "Schedule request failed");
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code: "schedule_request_failed",
},
});
}
private async handleScheduleCreateRequest(
request: Extract<SessionInboundMessage, { type: "schedule/create" }>,
): Promise<void> {
try {
const target =
request.target.type === "self"
? { type: "agent" as const, agentId: request.target.agentId }
: request.target;
const schedule = await this.scheduleService.create({
prompt: request.prompt,
name: request.name,
cadence: request.cadence,
target,
maxRuns: request.maxRuns,
expiresAt: request.expiresAt,
runOnCreate: request.runOnCreate,
});
this.emit({
type: "schedule/create/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleScheduleListRequest(
request: Extract<SessionInboundMessage, { type: "schedule/list" }>,
): Promise<void> {
try {
const schedules = await this.scheduleService.list();
this.emit({
type: "schedule/list/response",
payload: {
requestId: request.requestId,
schedules: schedules.map((schedule) => this.toScheduleSummary(schedule)),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleScheduleInspectRequest(
request: Extract<SessionInboundMessage, { type: "schedule/inspect" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.inspect(request.scheduleId);
this.emit({
type: "schedule/inspect/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleScheduleLogsRequest(
request: Extract<SessionInboundMessage, { type: "schedule/logs" }>,
): Promise<void> {
try {
const runs = await this.scheduleService.logs(request.scheduleId);
this.emit({
type: "schedule/logs/response",
payload: {
requestId: request.requestId,
runs,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleSchedulePauseRequest(
request: Extract<SessionInboundMessage, { type: "schedule/pause" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.pause(request.scheduleId);
this.emit({
type: "schedule/pause/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleScheduleResumeRequest(
request: Extract<SessionInboundMessage, { type: "schedule/resume" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.resume(request.scheduleId);
this.emit({
type: "schedule/resume/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleScheduleDeleteRequest(
request: Extract<SessionInboundMessage, { type: "schedule/delete" }>,
): Promise<void> {
try {
await this.scheduleService.delete(request.scheduleId);
this.emit({
type: "schedule/delete/response",
payload: {
requestId: request.requestId,
scheduleId: request.scheduleId,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleScheduleRunOnceRequest(
request: Extract<SessionInboundMessage, { type: "schedule/run-once" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.runOnce(request.scheduleId);
this.emit({
type: "schedule/run-once/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private async handleScheduleUpdateRequest(
request: Extract<SessionInboundMessage, { type: "schedule/update" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.update({
id: request.scheduleId,
...(request.name !== undefined ? { name: request.name } : {}),
...(request.prompt !== undefined ? { prompt: request.prompt } : {}),
...(request.cadence !== undefined ? { cadence: request.cadence } : {}),
...(request.newAgentConfig !== undefined ? { newAgentConfig: request.newAgentConfig } : {}),
...(request.maxRuns !== undefined ? { maxRuns: request.maxRuns } : {}),
...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}),
});
this.emit({
type: "schedule/update/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private emitLoopRpcError(
request: Extract<
SessionInboundMessage,
{
type: "loop/run" | "loop/list" | "loop/inspect" | "loop/logs" | "loop/stop";
}
>,
error: unknown,
): void {
const message = error instanceof Error ? error.message : String(error);
this.sessionLogger.error({ err: error, requestType: request.type }, "Loop request failed");
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code: "loop_request_failed",
},
});
}
private async handleLoopRunRequest(
request: Extract<SessionInboundMessage, { type: "loop/run" }>,
): Promise<void> {
try {
const loop = await this.loopService.runLoop({
prompt: request.prompt,
cwd: request.cwd,
provider: request.provider,
model: request.model,
modeId: request.modeId,
workerProvider: request.workerProvider,
workerModel: request.workerModel,
verifierProvider: request.verifierProvider,
verifierModel: request.verifierModel,
verifierModeId: request.verifierModeId,
verifyPrompt: request.verifyPrompt,
verifyChecks: request.verifyChecks,
archive: request.archive,
name: request.name,
sleepMs: request.sleepMs,
maxIterations: request.maxIterations,
maxTimeMs: request.maxTimeMs,
});
this.emit({
type: "loop/run/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
private async handleLoopListRequest(
request: Extract<SessionInboundMessage, { type: "loop/list" }>,
): Promise<void> {
try {
const loops = await this.loopService.listLoops();
this.emit({
type: "loop/list/response",
payload: {
requestId: request.requestId,
loops,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
private async handleLoopInspectRequest(
request: Extract<SessionInboundMessage, { type: "loop/inspect" }>,
): Promise<void> {
try {
const loop = await this.loopService.inspectLoop(request.id);
this.emit({
type: "loop/inspect/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
private async handleLoopLogsRequest(
request: Extract<SessionInboundMessage, { type: "loop/logs" }>,
): Promise<void> {
try {
const result = await this.loopService.getLoopLogs(request.id, request.afterSeq ?? 0);
this.emit({
type: "loop/logs/response",
payload: {
requestId: request.requestId,
loop: result.loop,
entries: result.entries,
nextCursor: result.nextCursor,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
private async handleLoopStopRequest(
request: Extract<SessionInboundMessage, { type: "loop/stop" }>,
): Promise<void> {
try {
const loop = await this.loopService.stopLoop(request.id);
this.emit({
type: "loop/stop/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
}
function isValidPullRequestTimelineIdentity(options: {
prNumber: number;
repoOwner: string;
repoName: string;
}): boolean {
if (!Number.isInteger(options.prNumber) || options.prNumber <= 0) {
return false;
}
return isValidGitHubRepoSegment(options.repoOwner) && isValidGitHubRepoSegment(options.repoName);
}
function isValidGitHubRepoSegment(value: string): boolean {
return /^[A-Za-z0-9._-]+$/.test(value);
}
function toPullRequestTimelinePayloadItem(
item: PullRequestTimelineItem,
): PullRequestTimelinePayloadItem {
const { authorUrl: _authorUrl, ...payload } = item;
return payload;
}