Files
paseo/packages/server/src/server/session.ts
T
Mohamed Boudra 7819019cea feat(server,app,website): prefix service URLs with project slug
Service hostnames now follow DNS convention with a project label so
independently opened projects no longer collide on routes like
`web.localhost`. Format becomes `{script}.{project}.localhost` for
default branches and `{script}.{branch}.{project}.localhost` for other
branches. The project slug comes from the GitHub repository name when a
remote exists, otherwise from the workspace directory basename.

Server changes:
- `buildScriptHostname` takes `{ projectSlug, branchName, scriptName }`
  and slugifies all labels at the boundary; `untitled` fallback only
  applies at hostname-label sites, never to shared `slugify`.
- `deriveProjectSlug(cwd)` and `parseGitHubRepoNameFromRemote` derive
  the slug; GitHub URL parsing now requires `host === "github.com"` to
  avoid embedded-path false matches.
- `ScriptRouteEntry` carries `projectSlug`; branch-rename handler reuses
  it instead of re-reading git in hot paths.
- Status projection derives slug once per call and uses route hostname
  when available; only falls back to building when no route exists.
- Spawn cleanup removes routes by `{ workspaceId, scriptName }` so
  cleanup after a branch rename doesn't leave a stale entry behind.

App and shared message tests updated to the new fixture shape; service
URLs continue to be opaque server-provided strings.

Landing-page service URL examples updated; allowlist docs unchanged.
2026-04-18 19:31:23 +07:00

8742 lines
266 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 type { FSWatcher } from "node:fs";
import { exec } from "node:child_process";
import { promisify } from "util";
import { resolve, sep } from "path";
import { homedir } from "node:os";
import { z } from "zod";
import type { ToolSet } from "ai";
import {
isLegacyEditorTargetId,
serializeAgentStreamEvent,
type AgentSnapshotPayload,
type AgentAttachment,
type SessionInboundMessage,
type SessionOutboundMessage,
type FileExplorerRequest,
type FileDownloadTokenRequest,
type GitSetupOptions,
type ListTerminalsRequest,
type SubscribeTerminalsRequest,
type UnsubscribeTerminalsRequest,
type CreateTerminalRequest,
type StartWorkspaceScriptRequest,
type SubscribeTerminalRequest,
type UnsubscribeTerminalRequest,
type TerminalInput,
type CloseItemsRequest,
type KillTerminalRequest,
type CaptureTerminalRequest,
type SubscribeCheckoutDiffRequest,
type UnsubscribeCheckoutDiffRequest,
type DirectorySuggestionsRequest,
type EditorTargetDescriptorPayload,
type EditorTargetId,
type ProjectPlacementPayload,
type WorkspaceSetupSnapshot,
type WorkspaceDescriptorPayload,
type WorkspaceStateBucket,
} from "./messages.js";
import type { TerminalManager, TerminalsChangedEvent } from "../terminal/terminal-manager.js";
import { captureTerminalLines, type TerminalSession } from "../terminal/terminal.js";
import {
TerminalStreamOpcode,
encodeTerminalSnapshotPayload,
encodeTerminalStreamFrame,
decodeTerminalResizePayload,
type TerminalStreamFrame,
} from "../shared/terminal-stream-protocol.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 {
DictationStreamManager,
type DictationStreamOutboundMessage,
} from "./dictation/dictation-stream-manager.js";
import {
createVoiceTurnController,
type VoiceTurnController,
} from "./voice/voice-turn-controller.js";
import {
buildConfigOverrides,
extractTimestamps,
toAgentPersistenceHandle,
} from "./persistence-hooks.js";
import { ensureAgentLoaded } from "./agent/agent-loading.js";
import { sendPromptToAgent, unarchiveAgentState } from "./agent/mcp-shared.js";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
import { buildWorkspaceScriptPayloads } from "./script-status-projection.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
import { deriveProjectSlug, readGitCommand } from "./workspace-git-metadata.js";
import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type { DaemonConfigStore } from "./daemon-config-store.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import { buildProviderRegistry } from "./agent/provider-registry.js";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
} from "./agent/provider-launch-config.js";
import { AgentManager } from "./agent/agent-manager.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import type {
AgentTimelineCursor,
AgentTimelineFetchDirection,
ManagedAgent,
} from "./agent/agent-manager.js";
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
import {
buildStoredAgentPayload,
resolveEffectiveThinkingOptionId,
resolveStoredAgentPayloadUpdatedAt,
toAgentPayload,
} from "./agent/agent-projections.js";
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent/agent-title-limits.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./agent/timeline-append.js";
import {
projectTimelineRows,
selectTimelineWindowByProjectedLimit,
type TimelineProjectionMode,
} from "./agent/timeline-projection.js";
import {
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
StructuredAgentFallbackError,
StructuredAgentResponseError,
generateStructuredAgentResponseWithFallback,
} from "./agent/agent-response-loop.js";
import type {
AgentPersistenceHandle,
AgentPermissionResponse,
AgentProvider,
AgentPromptContentBlock,
AgentPromptInput,
AgentRunOptions,
AgentSessionConfig,
AgentStreamEvent,
ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import {
checkoutLiteFromGitSnapshot,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
deriveWorkspaceId,
deriveProjectRootPath,
deriveProjectKind,
deriveWorkspaceKind,
deriveWorkspaceDisplayName,
buildProjectPlacementForCwd as buildProjectPlacementForCwdStandalone,
} from "./workspace-registry-model.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
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,
getDownloadableFileInfo,
} from "./file-explorer/service.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import { type WorktreeConfig } from "../utils/worktree.js";
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
import type { ScriptRouteStore } from "./script-proxy.js";
import {
getCheckoutDiff,
getCachedCheckoutShortstat,
getCheckoutStatus,
listBranchSuggestions,
commitChanges,
mergeToBase,
mergeFromBase,
pullCurrentBranch,
pushCurrentBranch,
createPullRequest,
searchGitHubIssuesAndPrs,
warmCheckoutShortstatInBackground,
} from "../utils/checkout-git.js";
import { getProjectIcon } from "../utils/project-icon.js";
import { expandTilde } from "../utils/path.js";
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.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 { resolveClientMessageId } from "./client-message-id.js";
import { ChatServiceError, FileBackedChatService } from "./chat/chat-service.js";
import { notifyChatMentions } from "./chat/chat-mentions.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { execCommand } from "../utils/spawn.js";
import { createGitHubService, type GitHubService } from "../services/github-service.js";
import {
createPaseoWorktree,
type CreatePaseoWorktreeInput,
type CreatePaseoWorktreeResult,
} from "./paseo-worktree-service.js";
import { createWorktreeCoreDeps } from "./worktree-core.js";
import {
assertSafeGitRef as assertWorktreeSafeGitRef,
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
runWorktreeSetupInBackground as runWorktreeSetupInBackgroundSession,
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
killTerminalsUnderPath as killWorktreeTerminalsUnderPath,
} from "./worktree-session.js";
import { toWorktreeWireError } from "./worktree-errors.js";
const execAsync = promisify(exec);
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
// 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"]);
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50";
function isAppVersionAtLeast(appVersion: string | null, minVersion: string): boolean {
if (!appVersion) return false;
// Strip RC/prerelease suffix: "0.1.45-rc.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);
}
const MAX_TERMINAL_STREAM_SLOTS = 256;
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);
}
}
function deriveInitialAgentTitle(prompt: string): string | null {
const firstContentLine = prompt
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstContentLine) {
return null;
}
const normalized = firstContentLine.replace(/\s+/g, " ").trim();
if (!normalized) {
return null;
}
const clamped = normalized.slice(0, MAX_INITIAL_AGENT_TITLE_CHARS).trim();
return clamped.length > 0 ? clamped : null;
}
export function resolveCreateAgentTitles(options: {
configTitle?: string | null;
initialPrompt?: string | null;
}): { explicitTitle: string | null; provisionalTitle: string | null } {
const explicitTitle =
typeof options.configTitle === "string" && options.configTitle.trim().length > 0
? options.configTitle.trim()
: null;
const trimmedPrompt = options.initialPrompt?.trim();
const provisionalTitle =
explicitTitle ?? (trimmedPrompt ? deriveInitialAgentTitle(trimmedPrompt) : null);
return {
explicitTitle,
provisionalTitle,
};
}
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";
type WorkspaceGitWatchTarget = {
cwd: string;
watchers: FSWatcher[];
debounceTimer: ReturnType<typeof setTimeout> | null;
refreshPromise: Promise<void> | null;
refreshQueued: boolean;
latestFingerprint: string | null;
lastBranchName: string | null;
};
type ActiveTerminalStream = {
terminalId: string;
slot: number;
unsubscribe: () => void;
needsSnapshot: boolean;
};
export type SessionRuntimeMetrics = {
terminalDirectorySubscriptionCount: number;
terminalSubscriptionCount: number;
inflightRequests: number;
peakInflightRequests: number;
};
type FetchAgentsRequestMessage = Extract<SessionInboundMessage, { type: "fetch_agents_request" }>;
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;
type AgentUpdatesSubscriptionState = {
subscriptionId: string;
filter?: AgentUpdatesFilter;
isBootstrapping: boolean;
pendingUpdatesByAgentId: Map<string, AgentUpdatePayload>;
};
type FetchAgentsCursor = {
sort: FetchAgentsRequestSort[];
values: Record<string, string | number | null>;
id: string;
};
type FetchWorkspacesRequestMessage = Extract<
SessionInboundMessage,
{ type: "fetch_workspaces_request" }
>;
type FetchWorkspacesRequestFilter = NonNullable<FetchWorkspacesRequestMessage["filter"]>;
type FetchWorkspacesRequestSort = NonNullable<FetchWorkspacesRequestMessage["sort"]>[number];
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"];
type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter;
type WorkspaceUpdatesSubscriptionState = {
subscriptionId: string;
filter?: WorkspaceUpdatesFilter;
isBootstrapping: boolean;
pendingUpdatesByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
lastEmittedByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
};
type FetchWorkspacesCursor = {
sort: FetchWorkspacesRequestSort[];
values: Record<string, string | number | null>;
id: string;
};
function summarizeFetchWorkspacesEntries(entries: Iterable<FetchWorkspacesResponseEntry>): {
count: number;
projectIds: string[];
statusCounts: Record<string, number>;
workspaces: Array<{
id: string;
projectId: string;
projectDisplayName: string;
name: string;
status: FetchWorkspacesResponseEntry["status"];
workspaceKind: FetchWorkspacesResponseEntry["workspaceKind"];
activityAt: string | null;
}>;
} {
const workspaces = Array.from(entries, (entry) => ({
id: entry.id,
projectId: entry.projectId,
projectDisplayName: entry.projectDisplayName,
name: entry.name,
status: entry.status,
workspaceKind: entry.workspaceKind,
activityAt: entry.activityAt,
}));
const statusCounts = new Map<string, number>();
for (const workspace of workspaces) {
statusCounts.set(workspace.status, (statusCounts.get(workspace.status) ?? 0) + 1);
}
return {
count: workspaces.length,
projectIds: [...new Set(workspaces.map((workspace) => workspace.projectId))],
statusCounts: Object.fromEntries(statusCounts),
workspaces,
};
}
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 VOICE_INTERRUPT_CONFIRMATION_MS = 500;
type 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>;
type VoiceTranscriptionResultPayload = {
text: string;
requestId: string;
language?: string;
duration?: number;
avgLogprob?: number;
isLowConfidence?: boolean;
byteLength?: number;
format?: string;
debugRecordingPath?: string;
};
export type SessionOptions = {
clientId: string;
appVersion?: string | 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>;
tts: Resolvable<TextToSpeechProvider | null>;
terminalManager: TerminalManager | null;
providerSnapshotManager?: ProviderSnapshotManager;
scriptRouteStore?: ScriptRouteStore;
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
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>;
getSpeechReadiness?: () => SpeechReadinessSnapshot;
};
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>;
};
export type SessionLifecycleIntent =
| {
type: "shutdown";
clientId: string;
requestId: string;
}
| {
type: "restart";
clientId: string;
requestId: string;
reason?: string;
};
type VoiceFeatureUnavailableContext = {
reasonCode: SpeechReadinessSnapshot["voiceFeature"]["reasonCode"];
message: string;
retryable: boolean;
missingModelIds: LocalSpeechModelId[];
};
type 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;
}
/**
* 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 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 pendingVoiceSpeechStartAt: number | null = null;
private pendingVoiceSpeechTimer: ReturnType<typeof setTimeout> | null = null;
private readonly dictationStreamManager: DictationStreamManager;
private readonly 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 readonly ttsManager: TTSManager;
private readonly 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 readonly providerRegistry: ReturnType<typeof buildProviderRegistry>;
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 MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000;
private readonly terminalManager: TerminalManager | null;
private readonly providerSnapshotManager: ProviderSnapshotManager | null;
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 subscribedTerminalDirectories = new Set<string>();
private unsubscribeTerminalsChanged: (() => void) | null = null;
private terminalExitSubscriptions: Map<string, () => void> = new Map();
private readonly activeTerminalStreams = new Map<number, ActiveTerminalStream>();
private readonly terminalIdToSlot = new Map<string, number>();
private nextTerminalSlot = 0;
private inflightRequests = 0;
private peakInflightRequests = 0;
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
private readonly workspaceGitWatchTargets = new Map<string, WorkspaceGitWatchTarget>();
private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>();
private readonly workspaceGitFetchSubscriptions = new Map<string, () => void>();
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
private readonly registerVoiceSpeakHandler?: (
agentId: string,
handler: VoiceSpeakHandler,
) => void;
private readonly unregisterVoiceSpeakHandler?: (agentId: string) => void;
private readonly registerVoiceCallerContext?: (
agentId: string,
context: VoiceCallerContext,
) => void;
private readonly unregisterVoiceCallerContext?: (agentId: string) => void;
private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot;
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private readonly providerOverrides: Record<string, ProviderOverride> | undefined;
private voiceModeAgentId: string | null = null;
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
constructor(options: SessionOptions) {
const {
clientId,
appVersion,
onMessage,
onBinaryMessage,
onLifecycleIntent,
logger,
downloadTokenStore,
pushTokenStore,
paseoHome,
agentManager,
agentStorage,
projectRegistry,
workspaceRegistry,
chatService,
scheduleService,
loopService,
checkoutDiffManager,
github,
workspaceGitService,
daemonConfigStore,
mcpBaseUrl,
stt,
tts,
terminalManager,
providerSnapshotManager,
scriptRouteStore,
scriptRuntimeStore,
onBranchChanged,
getDaemonTcpPort,
getDaemonTcpHost,
resolveScriptHealth,
voice,
voiceBridge,
dictation,
agentProviderRuntimeSettings,
providerOverrides,
} = options;
this.clientId = clientId;
this.appVersion = appVersion ?? null;
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.providerSnapshotManager = providerSnapshotManager ?? null;
this.scriptRouteStore = scriptRouteStore ?? null;
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
this.onBranchChanged = onBranchChanged;
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
this.resolveScriptHealth = resolveScriptHealth ?? null;
if (this.terminalManager) {
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
this.handleTerminalsChanged(event),
);
}
if (this.providerSnapshotManager) {
const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd?: string) => {
// COMPAT(providersSnapshot): keep provider visibility gating for older clients.
const visibleEntries = entries.filter((entry) =>
this.isProviderVisibleToClient(entry.provider),
);
this.emit({
type: "providers_snapshot_update",
payload: {
cwd,
entries: visibleEntries,
generatedAt: new Date().toISOString(),
},
});
};
this.providerSnapshotManager.on("change", handleProviderSnapshotChange);
this.unsubscribeProviderSnapshotEvents = () => {
this.providerSnapshotManager?.off("change", handleProviderSnapshotChange);
};
}
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;
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
this.providerOverrides = providerOverrides;
this.abortController = new AbortController();
this.providerRegistry = buildProviderRegistry(this.sessionLogger, {
runtimeSettings: this.agentProviderRuntimeSettings,
providerOverrides: this.providerOverrides,
});
// Initialize per-session managers
this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts);
this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt);
this.dictationStreamManager = new DictationStreamManager({
logger: this.sessionLogger,
sessionId: this.sessionId,
emit: (msg) => this.handleDictationManagerMessage(msg),
stt: dictation?.stt ?? null,
finalTimeoutMs: dictation?.finalTimeoutMs,
});
// Initialize agent MCP client asynchronously
void this.initializeAgentMcp();
this.subscribeToAgentEvents();
this.sessionLogger.trace("Session created");
}
updateAppVersion(appVersion: string | null): void {
if (appVersion && appVersion !== this.appVersion) {
this.appVersion = appVersion;
}
}
async primeWorkspaceGitWatchFingerprintForWorkspace(
workspace: PersistedWorkspaceRecord,
): Promise<void> {
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
await this.primeWorkspaceGitWatchFingerprints([descriptor]);
}
async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void> {
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true });
}
/**
* 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;
}
public getRuntimeMetrics(): SessionRuntimeMetrics {
return {
terminalDirectorySubscriptionCount: this.subscribedTerminalDirectories.size,
terminalSubscriptionCount: this.activeTerminalStreams.size,
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 }, "interruptAgentIfRunning: agent not found");
throw new Error(`Agent ${agentId} not found`);
}
const hasInFlightRun = this.agentManager.hasInFlightRun(agentId);
if (!hasInFlightRun) {
this.sessionLogger.trace(
{ agentId, lifecycle: snapshot.lifecycle, hasInFlightRun },
"interruptAgentIfRunning: skipping because agent is not running",
);
return;
}
this.sessionLogger.debug(
{ agentId, lifecycle: snapshot.lifecycle, hasInFlightRun },
"interruptAgentIfRunning: interrupting",
);
try {
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",
);
}
} catch (error) {
throw error;
}
}
private hasActiveAgentRun(agentId: string | null): boolean {
if (!agentId) {
return false;
}
return this.agentManager.hasInFlightRun(agentId);
}
/**
* Start streaming an agent run and forward results via the websocket broadcast
*/
private startAgentStream(
agentId: string,
prompt: AgentPromptInput,
runOptions?: AgentRunOptions,
): { ok: true } | { ok: false; error: string } {
this.sessionLogger.trace(
{
agentId,
promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(runOptions),
},
"startAgentStream: requested",
);
let iterator: AsyncGenerator<AgentStreamEvent>;
try {
const shouldReplace = this.agentManager.hasInFlightRun(agentId);
iterator = shouldReplace
? this.agentManager.replaceAgentRun(agentId, prompt, runOptions)
: this.agentManager.streamAgent(agentId, prompt, runOptions);
this.sessionLogger.trace(
{ agentId, shouldReplace },
"startAgentStream: agent iterator returned",
);
} catch (error) {
this.handleAgentRunError(agentId, error, "Failed to start agent run");
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error";
return { ok: false, error: message };
}
void (async () => {
try {
for await (const _ of iterator) {
// Events are forwarded via the session's AgentManager subscription.
}
this.sessionLogger.trace({ agentId }, "startAgentStream: iterator drained");
} catch (error) {
this.sessionLogger.trace({ agentId, err: error }, "startAgentStream: iterator threw");
this.handleAgentRunError(agentId, error, "Agent stream failed");
}
})();
return { ok: true };
}
private handleAgentRunError(agentId: string, error: unknown, context: string): void {
const message =
error instanceof Error ? error.message : typeof error === "string" ? error : "Unknown 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 MCP initialized with ${agentToolCount} tools`,
);
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to initialize Agent MCP");
}
}
/**
* Subscribe to AgentManager events and forward them to the client
*/
private subscribeToAgentEvents(): void {
if (this.unsubscribeAgentEvents) {
this.unsubscribeAgentEvents();
}
this.unsubscribeAgentEvents = this.agentManager.subscribe(
(event) => {
if (event.type === "agent_state") {
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",
);
});
}
// Reduce bandwidth/CPU on mobile: only forward high-frequency agent stream events
// for the focused agent, with a short grace window while backgrounded.
// History catch-up is handled via pull-based `fetch_agent_timeline_request`.
const activity = this.clientActivity;
if (activity?.deviceType === "mobile") {
if (!activity.focusedAgentId) {
return;
}
if (activity.focusedAgentId !== event.agentId) {
return;
}
if (!activity.appVisible) {
const hiddenForMs = Date.now() - activity.appVisibilityChangedAt.getTime();
if (hiddenForMs >= this.MOBILE_BACKGROUND_STREAM_GRACE_MS) {
return;
}
}
}
const serializedEvent = serializeAgentStreamEvent(event.event);
if (!serializedEvent) {
return;
}
const payload = {
agentId: event.agentId,
event: serializedEvent,
timestamp: new Date().toISOString(),
...(typeof event.seq === "number" ? { seq: event.seq } : {}),
...(typeof event.epoch === "string" ? { epoch: event.epoch } : {}),
} as const;
this.emit({
type: "agent_stream",
payload,
});
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 async buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload> {
const storedRecord = await this.agentStorage.get(agent.id);
const title = storedRecord?.title ?? storedRecord?.config?.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): AgentSnapshotPayload {
return buildStoredAgentPayload(record, this.providerRegistry, this.sessionLogger);
}
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 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?.thinkingOptionId !== undefined) {
const expectedThinkingOptionId = resolveEffectiveThinkingOptionId({
configuredThinkingOptionId: filter.thinkingOptionId ?? null,
});
const resolvedThinkingOptionId =
agent.effectiveThinkingOptionId ??
resolveEffectiveThinkingOptionId({
runtimeInfo: agent.runtimeInfo,
configuredThinkingOptionId: agent.thinkingOptionId ?? null,
});
if (resolvedThinkingOptionId !== expectedThinkingOptionId) {
return false;
}
}
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 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): Promise<PersistedWorkspaceRecord | null> {
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const workspaces = await this.workspaceRegistry.list();
return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null;
}
private async resolveWorkspaceDirectory(cwd: string): Promise<string> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
try {
const snapshot = await this.workspaceGitService.getSnapshot(normalizedCwd);
return normalizePersistedWorkspaceId(snapshot.git.repoRoot ?? normalizedCwd);
} catch {
return 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 =
project.kind !== "git"
? {
cwd: workspace.cwd,
isGit: false as const,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false as const,
mainRepoRoot: null,
}
: workspace.kind === "worktree"
? {
cwd: workspace.cwd,
isGit: true as const,
currentBranch: workspace.displayName,
remoteUrl: null,
worktreeRoot: workspace.cwd,
isPaseoOwnedWorktree: true as const,
mainRepoRoot: project.rootPath,
}
: {
cwd: workspace.cwd,
isGit: true as const,
currentBranch: workspace.displayName,
remoteUrl: null,
worktreeRoot: workspace.cwd,
isPaseoOwnedWorktree: false as const,
mainRepoRoot: null,
};
return {
projectKey: project.projectId,
projectName: project.displayName,
checkout,
};
}
private async buildProjectPlacementForCwd(cwd: string): Promise<ProjectPlacementPayload | null> {
const workspace = await this.findWorkspaceByDirectory(cwd);
if (!workspace) {
return 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);
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 },
"inbound message",
);
try {
switch (msg.type) {
case "voice_audio_chunk":
await this.handleAudioChunk(msg);
break;
case "abort_request":
await this.handleAbort();
break;
case "audio_played":
this.handleAudioPlayed(msg.id);
break;
case "fetch_agents_request":
await this.handleFetchAgents(msg);
break;
case "fetch_workspaces_request":
await this.handleFetchWorkspacesRequest(msg);
break;
case "fetch_agent_request":
await this.handleFetchAgent(msg.agentId, msg.requestId);
break;
case "delete_agent_request":
await this.handleDeleteAgentRequest(msg.agentId, msg.requestId);
break;
case "archive_agent_request":
await this.handleArchiveAgentRequest(msg.agentId, msg.requestId);
break;
case "close_items_request":
await this.handleCloseItemsRequest(msg);
break;
case "update_agent_request":
await this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId);
break;
case "set_voice_mode":
await this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId);
break;
case "send_agent_message_request":
await this.handleSendAgentMessageRequest(msg);
break;
case "wait_for_finish_request":
await this.handleWaitForFinish(msg.agentId, msg.requestId, msg.timeoutMs);
break;
case "get_daemon_config_request":
this.emit({
type: "get_daemon_config_response",
payload: {
requestId: msg.requestId,
config: this.daemonConfigStore.get(),
},
});
break;
case "set_daemon_config_request":
this.emit({
type: "set_daemon_config_response",
payload: {
requestId: msg.requestId,
config: this.daemonConfigStore.patch(msg.config),
},
});
break;
case "dictation_stream_start":
{
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,
},
});
break;
}
}
await this.dictationStreamManager.handleStart(msg.dictationId, msg.format);
break;
case "dictation_stream_chunk":
await this.dictationStreamManager.handleChunk({
dictationId: msg.dictationId,
seq: msg.seq,
audioBase64: msg.audio,
format: msg.format,
});
break;
case "dictation_stream_finish":
await this.dictationStreamManager.handleFinish(msg.dictationId, msg.finalSeq);
break;
case "dictation_stream_cancel":
this.dictationStreamManager.handleCancel(msg.dictationId);
break;
case "create_agent_request":
await this.handleCreateAgentRequest(msg);
break;
case "resume_agent_request":
await this.handleResumeAgentRequest(msg);
break;
case "refresh_agent_request":
await this.handleRefreshAgentRequest(msg);
break;
case "cancel_agent_request":
await this.handleCancelAgentRequest(msg.agentId, msg.requestId);
break;
case "restart_server_request":
await this.handleRestartServerRequest(msg.requestId, msg.reason);
break;
case "shutdown_server_request":
await this.handleShutdownServerRequest(msg.requestId);
break;
case "fetch_agent_timeline_request":
await this.handleFetchAgentTimelineRequest(msg);
break;
case "set_agent_mode_request":
await this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId);
break;
case "set_agent_model_request":
await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId);
break;
case "set_agent_feature_request":
await this.handleSetAgentFeatureRequest(
msg.agentId,
msg.featureId,
msg.value,
msg.requestId,
);
break;
case "set_agent_thinking_request":
await this.handleSetAgentThinkingRequest(
msg.agentId,
msg.thinkingOptionId,
msg.requestId,
);
break;
case "agent_permission_response":
await this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response);
break;
case "checkout_status_request":
await this.handleCheckoutStatusRequest(msg);
break;
case "validate_branch_request":
await this.handleValidateBranchRequest(msg);
break;
case "branch_suggestions_request":
await this.handleBranchSuggestionsRequest(msg);
break;
case "directory_suggestions_request":
await this.handleDirectorySuggestionsRequest(msg);
break;
case "subscribe_checkout_diff_request":
await this.handleSubscribeCheckoutDiffRequest(msg);
break;
case "unsubscribe_checkout_diff_request":
this.handleUnsubscribeCheckoutDiffRequest(msg);
break;
case "checkout_switch_branch_request":
await this.handleCheckoutSwitchBranchRequest(msg);
break;
case "stash_save_request":
await this.handleStashSaveRequest(msg);
break;
case "stash_pop_request":
await this.handleStashPopRequest(msg);
break;
case "stash_list_request":
await this.handleStashListRequest(msg);
break;
case "checkout_commit_request":
await this.handleCheckoutCommitRequest(msg);
break;
case "checkout_merge_request":
await this.handleCheckoutMergeRequest(msg);
break;
case "checkout_merge_from_base_request":
await this.handleCheckoutMergeFromBaseRequest(msg);
break;
case "checkout_pull_request":
await this.handleCheckoutPullRequest(msg);
break;
case "checkout_push_request":
await this.handleCheckoutPushRequest(msg);
break;
case "checkout_pr_create_request":
await this.handleCheckoutPrCreateRequest(msg);
break;
case "checkout_pr_status_request":
await this.handleCheckoutPrStatusRequest(msg);
break;
case "github_search_request":
await this.handleGitHubSearchRequest(msg);
break;
case "paseo_worktree_list_request":
await this.handlePaseoWorktreeListRequest(msg);
break;
case "paseo_worktree_archive_request":
await this.handlePaseoWorktreeArchiveRequest(msg);
break;
case "create_paseo_worktree_request":
await this.handleCreatePaseoWorktreeRequest(msg);
break;
case "workspace_setup_status_request":
await this.handleWorkspaceSetupStatusRequest(msg);
break;
case "list_available_editors_request":
await this.handleListAvailableEditorsRequest(msg);
break;
case "open_in_editor_request":
await this.handleOpenInEditorRequest(msg);
break;
case "open_project_request":
await this.handleOpenProjectRequest(msg);
break;
case "archive_workspace_request":
await this.handleArchiveWorkspaceRequest(msg);
break;
case "file_explorer_request":
await this.handleFileExplorerRequest(msg);
break;
case "project_icon_request":
await this.handleProjectIconRequest(msg);
break;
case "file_download_token_request":
await this.handleFileDownloadTokenRequest(msg);
break;
case "list_provider_models_request":
await this.handleListProviderModelsRequest(msg);
break;
case "list_provider_modes_request":
await this.handleListProviderModesRequest(msg);
break;
case "list_provider_features_request":
await this.handleListProviderFeaturesRequest(msg);
break;
case "list_available_providers_request":
await this.handleListAvailableProvidersRequest(msg);
break;
case "get_providers_snapshot_request":
await this.handleGetProvidersSnapshotRequest(msg);
break;
case "refresh_providers_snapshot_request":
await this.handleRefreshProvidersSnapshotRequest(msg);
break;
case "provider_diagnostic_request":
await this.handleProviderDiagnosticRequest(msg);
break;
case "clear_agent_attention":
await this.handleClearAgentAttention(msg.agentId, msg.requestId);
break;
case "client_heartbeat":
this.handleClientHeartbeat(msg);
break;
case "ping": {
const now = Date.now();
this.emit({
type: "pong",
payload: {
requestId: msg.requestId,
clientSentAt: msg.clientSentAt,
serverReceivedAt: now,
serverSentAt: now,
},
});
break;
}
case "list_commands_request":
await this.handleListCommandsRequest(msg);
break;
case "register_push_token":
this.handleRegisterPushToken(msg.token);
break;
case "subscribe_terminals_request":
this.handleSubscribeTerminalsRequest(msg);
break;
case "unsubscribe_terminals_request":
this.handleUnsubscribeTerminalsRequest(msg);
break;
case "list_terminals_request":
await this.handleListTerminalsRequest(msg);
break;
case "create_terminal_request":
await this.handleCreateTerminalRequest(msg);
break;
case "start_workspace_script_request":
await this.handleStartWorkspaceScriptRequest(msg);
break;
case "subscribe_terminal_request":
await this.handleSubscribeTerminalRequest(msg);
break;
case "unsubscribe_terminal_request":
this.handleUnsubscribeTerminalRequest(msg);
break;
case "terminal_input":
this.handleTerminalInput(msg);
break;
case "kill_terminal_request":
await this.handleKillTerminalRequest(msg);
break;
case "capture_terminal_request":
await this.handleCaptureTerminalRequest(msg);
break;
case "chat/create":
await this.handleChatCreateRequest(msg);
break;
case "chat/list":
await this.handleChatListRequest(msg);
break;
case "chat/inspect":
await this.handleChatInspectRequest(msg);
break;
case "chat/delete":
await this.handleChatDeleteRequest(msg);
break;
case "chat/post":
await this.handleChatPostRequest(msg);
break;
case "chat/read":
await this.handleChatReadRequest(msg);
break;
case "chat/wait":
await this.handleChatWaitRequest(msg);
break;
case "schedule/create":
await this.handleScheduleCreateRequest(msg);
break;
case "schedule/list":
await this.handleScheduleListRequest(msg);
break;
case "schedule/inspect":
await this.handleScheduleInspectRequest(msg);
break;
case "schedule/logs":
await this.handleScheduleLogsRequest(msg);
break;
case "schedule/pause":
await this.handleSchedulePauseRequest(msg);
break;
case "schedule/resume":
await this.handleScheduleResumeRequest(msg);
break;
case "schedule/delete":
await this.handleScheduleDeleteRequest(msg);
break;
case "loop/run":
await this.handleLoopRunRequest(msg);
break;
case "loop/list":
await this.handleLoopListRequest(msg);
break;
case "loop/inspect":
await this.handleLoopInspectRequest(msg);
break;
case "loop/logs":
await this.handleLoopLogsRequest(msg);
break;
case "loop/stop":
await this.handleLoopStopRequest(msg);
break;
}
} catch (error: any) {
const err = error instanceof Error ? error : new Error(String(error));
this.sessionLogger.error({ err }, "Error handling message");
const requestId = (msg as { requestId?: unknown }).requestId;
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--;
}
}
public resetPeakInflight(): void {
this.peakInflightRequests = this.inflightRequests;
}
public handleBinaryFrame(frame: TerminalStreamFrame): void {
const activeStream = this.activeTerminalStreams.get(frame.slot);
if (!activeStream || !this.terminalManager) {
return;
}
const terminal = this.terminalManager.getTerminal(activeStream.terminalId);
if (!terminal) {
this.detachTerminalStream(activeStream.terminalId, { emitExit: true });
return;
}
switch (frame.opcode) {
case TerminalStreamOpcode.Input: {
if (frame.payload.byteLength === 0) {
return;
}
const text = Buffer.from(frame.payload).toString("utf8");
if (!text) {
return;
}
terminal.send({ type: "input", data: text });
return;
}
case TerminalStreamOpcode.Resize: {
const resize = decodeTerminalResizePayload(frame.payload);
if (!resize) {
return;
}
terminal.send({ type: "resize", rows: resize.rows, cols: resize.cols });
return;
}
default:
return;
}
}
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 this.agentManager.closeAgent(agentId);
} catch (error: any) {
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: any) {
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 archiveStoredAgentForClose(
agentId: string,
): Promise<{ agentId: string; archivedAt: string }> {
const existing = await this.agentStorage.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
if (existing.archivedAt) {
return {
agentId,
archivedAt: existing.archivedAt,
};
}
const archivedAt = new Date().toISOString();
await this.agentManager.archiveSnapshot(agentId, archivedAt);
return { agentId, archivedAt };
}
private async archiveAgentForClose(
agentId: string,
): Promise<{ agentId: string; archivedAt: string }> {
const liveAgent = this.agentManager.getAgent(agentId);
if (liveAgent) {
await this.interruptAgentIfRunning(agentId);
await this.agentManager.clearAgentAttention(agentId).catch(() => undefined);
await this.agentManager.archiveAgent(agentId);
} else {
await this.archiveStoredAgentForClose(agentId);
}
const archivedRecord = await this.agentStorage.get(agentId);
if (!archivedRecord) {
throw new Error(`Agent not found in storage after archive: ${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);
}
if (!archivedRecord.archivedAt) {
throw new Error(`Agent missing archivedAt after archive: ${agentId}`);
}
return { agentId, archivedAt: archivedRecord.archivedAt };
}
private async handleCloseItemsRequest(msg: CloseItemsRequest): Promise<void> {
const agents = [];
for (const agentId of msg.agentIds) {
try {
agents.push(await this.archiveAgentForClose(agentId));
} catch (error: any) {
this.sessionLogger.warn(
{ err: error, agentId, requestId: msg.requestId },
"Failed to archive agent during close_items batch",
);
}
}
const terminals = [];
for (const terminalId of msg.terminalIds) {
try {
terminals.push(this.killTerminalForClose(terminalId));
} catch (error: any) {
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",
);
const normalizedName = name?.trim();
const normalizedLabels = labels && Object.keys(labels).length > 0 ? labels : undefined;
if (!normalizedName && !normalizedLabels) {
this.emit({
type: "update_agent_response",
payload: {
requestId,
agentId,
accepted: false,
error: "Nothing to update (provide name and/or labels)",
},
});
return;
}
try {
await this.agentManager.updateAgentMetadata(agentId, {
...(normalizedName ? { title: normalizedName } : {}),
...(normalizedLabels ? { labels: normalizedLabels } : {}),
});
this.emit({
type: "update_agent_response",
payload: { requestId, agentId, accepted: true, error: null },
});
} catch (error: any) {
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: ${error.message}`,
},
});
this.emit({
type: "update_agent_response",
payload: {
requestId,
agentId,
accepted: false,
error: error?.message ? String(error.message) : "Failed to update agent",
},
});
}
}
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");
}
this.sessionLogger.info(
{ providerId: turnDetection.id },
"startVoiceTurnController creating controller",
);
const controller = createVoiceTurnController({
logger: this.sessionLogger.child({ component: "voice-turn-controller" }),
turnDetection,
utteranceSink: {
submitUtterance: async ({ pcm16, format, sampleRate, startedAt, endedAt }) => {
this.sessionLogger.debug(
{
audioBytes: pcm16.length,
sampleRate,
startedAt,
endedAt,
durationMs: Math.max(0, endedAt - startedAt),
},
"Submitting detected voice utterance",
);
await this.processCompletedAudio(pcm16, format);
},
},
callbacks: {
onSpeechStarted: async () => {
this.handleProvisionalVoiceSpeechStarted();
},
onSpeechStopped: async () => {
this.handleVoiceSpeechStopped();
},
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;
}
this.clearPendingVoiceSpeechStart("turn-controller-stop");
const controller = this.voiceTurnController;
this.voiceTurnController = null;
await controller.stop();
}
private clearPendingVoiceSpeechStart(reason: string): void {
if (this.pendingVoiceSpeechTimer) {
clearTimeout(this.pendingVoiceSpeechTimer);
this.pendingVoiceSpeechTimer = null;
}
if (this.pendingVoiceSpeechStartAt !== null) {
this.sessionLogger.debug({ reason }, "Clearing provisional voice speech start");
this.pendingVoiceSpeechStartAt = null;
}
}
private handleProvisionalVoiceSpeechStarted(): void {
if (this.speechInProgress || this.pendingVoiceSpeechTimer) {
return;
}
const startedAt = Date.now();
this.pendingVoiceSpeechStartAt = startedAt;
this.sessionLogger.info(
{ confirmationMs: VOICE_INTERRUPT_CONFIRMATION_MS },
"Silero VAD provisional speech_started",
);
this.pendingVoiceSpeechTimer = setTimeout(() => {
this.pendingVoiceSpeechTimer = null;
if (this.pendingVoiceSpeechStartAt !== startedAt || this.speechInProgress) {
return;
}
this.pendingVoiceSpeechStartAt = null;
this.sessionLogger.info("voice_input_state emitting isSpeaking=true");
this.emit({
type: "voice_input_state",
payload: {
isSpeaking: true,
},
});
void this.handleVoiceSpeechStart();
}, VOICE_INTERRUPT_CONFIRMATION_MS);
}
private handleVoiceSpeechStopped(): void {
if (this.pendingVoiceSpeechStartAt !== null) {
const durationMs = Date.now() - this.pendingVoiceSpeechStartAt;
this.clearPendingVoiceSpeechStart("speech-stopped-before-confirmation");
this.sessionLogger.info(
{ durationMs, confirmationMs: VOICE_INTERRUPT_CONFIRMATION_MS },
"Ignoring provisional voice speech start that ended before confirmation",
);
return;
}
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,
userMessageText: text,
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,
images,
attachments,
labels,
} = msg;
this.sessionLogger.info(
{ cwd: config.cwd, provider: config.provider, worktreeName },
`Creating agent in ${config.cwd} (${config.provider})${
worktreeName ? ` with worktree ${worktreeName}` : ""
}`,
);
try {
const trimmedPrompt = initialPrompt?.trim();
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
configTitle: config.title,
initialPrompt: trimmedPrompt,
});
const resolvedConfig: AgentSessionConfig = {
...config,
...(provisionalTitle ? { title: provisionalTitle } : {}),
};
const { sessionConfig, worktreeBootstrap } = await this.buildAgentSessionConfig(
resolvedConfig,
git,
worktreeName,
attachments,
);
const resolvedWorkspace = msg.workspaceId
? await this.workspaceRegistry.get(msg.workspaceId)
: ((await this.findWorkspaceByDirectory(sessionConfig.cwd)) ??
(await this.findOrCreateWorkspaceForDirectory(sessionConfig.cwd)));
if (!resolvedWorkspace) {
throw new Error(`Workspace not found: ${msg.workspaceId}`);
}
const snapshot = await this.agentManager.createAgent(
{
...sessionConfig,
cwd: resolvedWorkspace.cwd,
},
undefined,
{
labels,
workspaceId: resolvedWorkspace.workspaceId,
initialPrompt: trimmedPrompt,
},
);
await this.forwardAgentUpdate(snapshot);
if (trimmedPrompt || (images?.length ?? 0) > 0 || (attachments?.length ?? 0) > 0) {
scheduleAgentMetadataGeneration({
agentManager: this.agentManager,
agentId: snapshot.id,
cwd: snapshot.cwd,
initialPrompt: trimmedPrompt,
explicitTitle,
paseoHome: this.paseoHome,
logger: this.sessionLogger,
});
const started = await this.handleSendAgentMessage(
snapshot.id,
trimmedPrompt || "",
resolveClientMessageId(clientMessageId),
images,
attachments,
outputSchema ? { outputSchema } : undefined,
);
if (!started.ok) {
throw new Error(started.error);
}
}
if (requestId) {
const agentPayload = await this.buildAgentPayload(snapshot);
this.emit({
type: "status",
payload: {
status: "agent_created",
agentId: snapshot.id,
requestId,
agent: agentPayload,
},
});
}
if (worktreeBootstrap) {
void runAsyncWorktreeBootstrap({
agentId: snapshot.id,
worktree: worktreeBootstrap.worktree,
shouldBootstrap: worktreeBootstrap.shouldBootstrap,
terminalManager: this.terminalManager,
appendTimelineItem: (item) =>
appendTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId: snapshot.id,
item,
}),
emitLiveTimelineItem: (item) =>
emitLiveTimelineItemIfAgentKnown({
agentManager: this.agentManager,
agentId: snapshot.id,
item,
}),
logger: this.sessionLogger,
});
}
this.sessionLogger.info(
{ agentId: snapshot.id, provider: snapshot.provider },
`Created agent ${snapshot.id} (${snapshot.provider})`,
);
} catch (error: any) {
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 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");
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: any) {
this.sessionLogger.error({ err: error }, "Failed to resume agent");
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to resume agent: ${error.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);
} else {
const record = await this.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const handle = toAgentPersistenceHandle(
this.sessionLogger,
this.providerRegistry,
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: any) {
this.sessionLogger.error({ err: error, agentId }, `Failed to refresh agent ${agentId}`);
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to refresh agent: ${error.message}`,
},
});
}
}
private async handleCancelAgentRequest(agentId: string, requestId?: string): Promise<void> {
this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`);
try {
await this.interruptAgentIfRunning(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 buildAgentSessionConfig(
config: AgentSessionConfig,
gitOptions?: GitSetupOptions,
legacyWorktreeName?: string,
attachments?: AgentAttachment[],
): Promise<{
sessionConfig: AgentSessionConfig;
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
}> {
return buildWorktreeAgentSessionConfig(
{
paseoHome: this.paseoHome,
sessionLogger: this.sessionLogger,
workspaceGitService: this.workspaceGitService,
createPaseoWorktree: (input, serviceOptions) =>
this.createPaseoWorktree(input, serviceOptions),
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
createBranchFromBase: (params) => this.createBranchFromBase(params),
},
config,
gitOptions,
legacyWorktreeName,
attachments,
);
}
private async handleListProviderModelsRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>,
): Promise<void> {
const cwd = msg.cwd ? expandTilde(msg.cwd) : undefined;
const fetchedAt = new Date().toISOString();
const manager = this.providerSnapshotManager;
if (!manager) {
try {
const models = await this.providerRegistry[msg.provider].fetchModels({ cwd });
this.emit({
type: "list_provider_models_response",
payload: {
provider: msg.provider,
models,
error: null,
fetchedAt,
requestId: msg.requestId,
},
});
} catch (error) {
this.sessionLogger.error(
{ err: error, provider: msg.provider },
`Failed to list models for ${msg.provider}`,
);
this.emit({
type: "list_provider_models_response",
payload: {
provider: msg.provider,
error: (error as Error)?.message ?? String(error),
fetchedAt,
requestId: msg.requestId,
},
});
}
return;
}
const findEntry = () =>
manager.getSnapshot(cwd).find((candidate) => candidate.provider === msg.provider);
let entry = findEntry();
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.refresh({ cwd, providers: [msg.provider] });
entry = findEntry();
}
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.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();
try {
const modes = await this.providerRegistry[msg.provider].fetchModes({
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
});
this.emit({
type: "list_provider_modes_response",
payload: {
provider: msg.provider,
modes,
error: null,
fetchedAt,
requestId: msg.requestId,
},
});
} catch (error) {
this.sessionLogger.error(
{ err: error, provider: msg.provider },
`Failed to list modes for ${msg.provider}`,
);
this.emit({
type: "list_provider_modes_response",
payload: {
provider: msg.provider,
error: (error as Error)?.message ?? String(error),
fetchedAt,
requestId: msg.requestId,
},
});
}
}
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: (error as Error)?.message ?? String(error),
fetchedAt,
requestId: msg.requestId,
},
});
}
}
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: (error as Error)?.message ?? String(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
? this.providerSnapshotManager
.getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined)
.filter((entry) => this.isProviderVisibleToClient(entry.provider))
: [];
this.emit({
type: "get_providers_snapshot_response",
payload: {
entries,
generatedAt: new Date().toISOString(),
requestId: msg.requestId,
},
});
}
private async handleRefreshProvidersSnapshotRequest(
msg: Extract<SessionInboundMessage, { type: "refresh_providers_snapshot_request" }>,
): Promise<void> {
await this.providerSnapshotManager?.refresh({
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
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 client = this.providerRegistry[msg.provider].createClient(this.sessionLogger);
const diagnostic = client.getDiagnostic
? (await client.getDiagnostic()).diagnostic
: "No diagnostic available for this 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 getCheckoutDiff(
cwd,
{ mode: "uncommitted", includeStructured: true },
{ paseoHome: this.paseoHome },
);
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 = file.isNew ? "A" : file.isDeleted ? "D" : "M";
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 = [
"Write a concise git commit message for the changes below.",
"Return JSON only with a single field 'message'.",
"",
fileList,
"",
patch.length > 0 ? patch : "(No diff available)",
].join("\n");
try {
const result = await generateStructuredAgentResponseWithFallback({
manager: this.agentManager,
cwd,
prompt,
schema,
schemaName: "CommitMessage",
maxRetries: 2,
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
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 getCheckoutDiff(
cwd,
{
mode: "base",
baseRef,
includeStructured: true,
},
{ paseoHome: this.paseoHome },
);
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 = file.isNew ? "A" : file.isDeleted ? "D" : "M";
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 = [
"Write a pull request title and body for the changes below.",
"Return JSON only with fields 'title' and 'body'.",
"",
fileList,
"",
patch.length > 0 ? patch : "(No diff available)",
].join("\n");
try {
return await generateStructuredAgentResponseWithFallback({
manager: this.agentManager,
cwd,
prompt,
schema,
schemaName: "PullRequest",
maxRetries: 2,
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
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 { stdout } = await execAsync("git status --porcelain", {
cwd,
env: READ_ONLY_GIT_ENV,
});
return stdout.trim().length > 0;
} catch (error) {
throw new Error(`Unable to inspect git status for ${cwd}: ${(error as Error).message}`);
}
}
private async checkoutExistingBranch(cwd: string, branch: string): Promise<void> {
this.assertSafeGitRef(branch, "branch");
try {
await execCommand("git", ["rev-parse", "--verify", branch], { cwd });
} catch (error) {
throw new Error(`Branch not found: ${branch}`);
}
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", {
cwd,
});
const current = stdout.trim();
if (current === branch) {
return;
}
await this.ensureCleanWorkingTree(cwd);
await execCommand("git", ["checkout", branch], { cwd });
}
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");
try {
await execCommand("git", ["rev-parse", "--verify", baseBranch], { cwd });
} catch (error) {
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,
});
}
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
this.assertSafeGitRef(branch, "branch");
try {
await execCommand("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], {
cwd,
});
return true;
} catch (error: any) {
return false;
}
}
/**
* 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 this.agentManager.setAgentMode(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: any) {
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: ${error.message}`,
},
});
this.emit({
type: "set_agent_mode_response",
payload: {
requestId,
agentId,
accepted: false,
error: error?.message ? String(error.message) : "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: any) {
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: ${error.message}`,
},
});
this.emit({
type: "set_agent_model_response",
payload: {
requestId,
agentId,
accepted: false,
error: error?.message ? String(error.message) : "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: any) {
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: ${error.message}`,
},
});
this.emit({
type: "set_agent_feature_response",
payload: {
requestId,
agentId,
accepted: false,
error: error?.message ? String(error.message) : "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: any) {
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: ${error.message}`,
},
});
this.emit({
type: "set_agent_thinking_response",
payload: {
requestId,
agentId,
accepted: false,
error: error?.message ? String(error.message) : "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: any) {
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: any) {
this.sessionLogger.error({ err: error, agentId, draftConfig }, "Failed to list commands");
this.emit({
type: "list_commands_response",
payload: {
agentId,
commands: [],
error: error.message,
requestId,
},
});
}
}
/**
* Handle agent permission response from user
*/
private async handleAgentPermissionResponse(
agentId: string,
requestId: string,
response: AgentPermissionResponse,
): Promise<void> {
this.sessionLogger.debug(
{ agentId, requestId },
`Handling permission response for agent ${agentId}, request ${requestId}`,
);
try {
const result = await this.agentManager.respondToPermission(agentId, requestId, response);
this.sessionLogger.debug({ agentId }, `Permission response forwarded to agent ${agentId}`);
if (result?.followUpPrompt) {
this.sessionLogger.debug(
{ agentId },
"Permission response requires follow-up turn, starting agent stream",
);
this.startAgentStream(agentId, result.followUpPrompt);
}
} catch (error: any) {
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: ${error.message}`,
},
});
throw error;
}
}
private async handleCheckoutStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>,
): Promise<void> {
const { cwd, requestId } = msg;
const resolvedCwd = expandTilde(cwd);
try {
const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome });
if (!status.isGit) {
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: null,
requestId,
},
});
return;
}
if (status.isPaseoOwnedWorktree) {
this.emit({
type: "checkout_status_response",
payload: {
cwd,
isGit: true,
repoRoot: status.repoRoot ?? null,
mainRepoRoot: status.mainRepoRoot,
currentBranch: status.currentBranch ?? null,
isDirty: status.isDirty ?? null,
baseRef: status.baseRef,
aheadBehind: status.aheadBehind ?? null,
aheadOfOrigin: status.aheadOfOrigin ?? null,
behindOfOrigin: status.behindOfOrigin ?? null,
hasRemote: status.hasRemote,
remoteUrl: status.remoteUrl,
isPaseoOwnedWorktree: true,
error: null,
requestId,
},
});
return;
}
this.emit({
type: "checkout_status_response",
payload: {
cwd,
isGit: true,
repoRoot: status.repoRoot ?? null,
currentBranch: status.currentBranch ?? null,
isDirty: status.isDirty ?? null,
baseRef: status.baseRef ?? null,
aheadBehind: status.aheadBehind ?? null,
aheadOfOrigin: status.aheadOfOrigin ?? null,
behindOfOrigin: status.behindOfOrigin ?? null,
hasRemote: status.hasRemote,
remoteUrl: status.remoteUrl,
isPaseoOwnedWorktree: false,
error: null,
requestId,
},
});
} 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");
// Try local branch first
try {
await execCommand("git", ["rev-parse", "--verify", branchName], {
cwd: resolvedCwd,
env: READ_ONLY_GIT_ENV,
});
this.emit({
type: "validate_branch_response",
payload: {
exists: true,
resolvedRef: branchName,
isRemote: false,
error: null,
requestId,
},
});
return;
} catch {
// Local branch doesn't exist, try remote
}
// Try remote branch (origin/{branchName})
try {
await execCommand("git", ["rev-parse", "--verify", `origin/${branchName}`], {
cwd: resolvedCwd,
env: READ_ONLY_GIT_ENV,
});
this.emit({
type: "validate_branch_response",
payload: {
exists: true,
resolvedRef: `origin/${branchName}`,
isRemote: true,
error: null,
requestId,
},
});
return;
} catch {
// Remote branch doesn't exist either
}
// Branch not found anywhere
this.emit({
type: "validate_branch_response",
payload: {
exists: false,
resolvedRef: null,
isRemote: false,
error: null,
requestId,
},
});
} 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 branches = await listBranchSuggestions(resolvedCwd, { query, limit });
this.emit({
type: "branch_suggestions_response",
payload: {
branches,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "branch_suggestions_response",
payload: {
branches: [],
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 searchGitHubIssuesAndPrs(resolvedCwd, query, limit, this.github, {
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 } = msg;
try {
const workspaceCwd = cwd?.trim();
const entries = workspaceCwd
? await searchWorkspaceEntries({
cwd: expandTilde(workspaceCwd),
query,
limit,
includeFiles,
includeDirectories,
})
: (
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 workspaceGitDescriptorFingerprint(workspace: WorkspaceDescriptorPayload | null): string {
if (!workspace) {
return WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT;
}
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 nextFingerprint = this.workspaceGitDescriptorFingerprint(workspace);
if (target.latestFingerprint === nextFingerprint) {
return true;
}
target.latestFingerprint = nextFingerprint;
return false;
}
private rememberWorkspaceGitWatchFingerprint(
workspaceId: string,
workspace: WorkspaceDescriptorPayload | null,
): void {
const target = this.workspaceGitWatchTargets.get(workspaceId);
if (!target) {
return;
}
target.latestFingerprint = this.workspaceGitDescriptorFingerprint(workspace);
target.lastBranchName = workspace?.name ?? null;
}
private async primeWorkspaceGitWatchFingerprints(
workspaces: Iterable<WorkspaceDescriptorPayload>,
): Promise<void> {
for (const workspace of workspaces) {
const persistedWorkspace = await this.workspaceRegistry.get(workspace.id);
if (!persistedWorkspace) {
continue;
}
await this.syncWorkspaceGitWatchTarget(persistedWorkspace.cwd, {
isGit: workspace.projectKind === "git",
});
this.rememberWorkspaceGitWatchFingerprint(persistedWorkspace.cwd, workspace);
}
}
private async syncWorkspaceGitWatchTarget(
cwd: string,
options: { isGit: boolean },
): Promise<void> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
if (!options.isGit) {
this.removeWorkspaceGitSubscription(normalizedCwd);
return;
}
if (this.workspaceGitSubscriptions.has(normalizedCwd)) {
return;
}
const subscription = await this.workspaceGitService.subscribe({ cwd: normalizedCwd }, () => {
void this.emitWorkspaceUpdateForCwd(normalizedCwd);
});
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 async handleCheckoutSwitchBranchRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
): Promise<void> {
const { cwd, branch, requestId } = msg;
try {
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,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_switch_branch_response",
payload: {
cwd,
success: false,
branch,
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 });
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 });
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 { stdout } = await execAsync("git stash list --format=%gd%x00%s", {
cwd,
env: READ_ONLY_GIT_ENV,
});
const lines = stdout.trim().split("\n").filter(Boolean);
const entries: Array<{
index: number;
message: string;
branch: string | null;
isPaseo: boolean;
}> = [];
for (const line of lines) {
const sepIdx = line.indexOf("\0");
if (sepIdx < 0) continue;
const refPart = line.slice(0, sepIdx);
const subject = line.slice(sepIdx + 1);
const indexMatch = refPart.match(/\{(\d+)\}/);
if (!indexMatch) continue;
const index = Number(indexMatch[1]);
const prefixIdx = subject.indexOf(Session.PASEO_STASH_PREFIX);
const isPaseo = prefixIdx >= 0;
const branch = isPaseo
? subject.slice(prefixIdx + Session.PASEO_STASH_PREFIX.length).trim() || null
: null;
if (paseoOnly && !isPaseo) continue;
entries.push({ index, message: subject, branch, isPaseo });
}
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,
});
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 status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome });
if (!status.isGit) {
try {
await execAsync("git rev-parse --is-inside-work-tree", {
cwd,
env: READ_ONLY_GIT_ENV,
});
} catch (error) {
const details =
typeof (error as any)?.stderr === "string"
? String((error as any).stderr).trim()
: error instanceof Error
? error.message
: String(error);
throw new Error(`Not a git repository: ${cwd}\n${details}`.trim());
}
}
if (msg.requireCleanTarget) {
const { stdout } = await execAsync("git status --porcelain", {
cwd,
env: READ_ONLY_GIT_ENV,
});
if (stdout.trim().length > 0) {
throw new Error("Working directory has uncommitted changes.");
}
}
let baseRef = msg.baseRef ?? (status.isGit ? status.baseRef : null);
if (!baseRef) {
throw new Error("Base branch is required for merge");
}
if (baseRef.startsWith("origin/")) {
baseRef = baseRef.slice("origin/".length);
}
await mergeToBase(
cwd,
{
baseRef,
mode: msg.strategy === "squash" ? "squash" : "merge",
},
{ paseoHome: this.paseoHome },
);
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 { stdout } = await execAsync("git status --porcelain", {
cwd,
env: READ_ONLY_GIT_ENV,
});
if (stdout.trim().length > 0) {
throw new Error("Working directory has uncommitted changes.");
}
}
await mergeFromBase(cwd, {
baseRef: msg.baseRef,
requireCleanTarget: msg.requireCleanTarget ?? 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);
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);
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 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,
);
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 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: {
cwd,
status: snapshot.github.pullRequest,
githubFeaturesEnabled: snapshot.github.featuresEnabled,
error: snapshot.github.error
? {
code: "UNKNOWN",
message: snapshot.github.error.message,
}
: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout_pr_status_response",
payload: {
cwd,
status: null,
githubFeaturesEnabled: true,
error: toCheckoutError(error),
requestId,
},
});
}
}
private async handlePaseoWorktreeListRequest(
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
): Promise<void> {
return handleWorktreeListRequest(
{
emit: (message) => this.emit(message),
paseoHome: this.paseoHome,
},
msg,
);
}
private async handlePaseoWorktreeArchiveRequest(
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
): Promise<void> {
return handleWorktreeArchiveRequest(
{
paseoHome: this.paseoHome,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
archiveWorkspaceRecord: async (workspaceDirectory) => {
const workspace = await this.findWorkspaceByDirectory(workspaceDirectory);
if (workspace) {
await this.archiveWorkspaceRecord(workspace.workspaceId);
}
},
emit: (message) => this.emit(message),
emitWorkspaceUpdatesForCwds: (cwds) => this.emitWorkspaceUpdatesForCwds(cwds),
isPathWithinRoot: (rootPath, candidatePath) =>
this.isPathWithinRoot(rootPath, candidatePath),
killTerminalsUnderPath: (rootPath) => this.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 {
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: any) {
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: error.message,
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: any) {
this.emit({
type: "project_icon_response",
payload: {
cwd,
icon: null,
error: error.message,
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: any) {
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: error.message,
requestId,
},
});
}
}
/**
* Build the current agent list payload (live + persisted), optionally filtered by labels.
*/
private async listAgentPayloads(filter?: {
labels?: Record<string, string>;
}): 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 persistedAgents = registryRecords
.filter((record) => !liveIds.has(record.id) && !record.internal)
.map((record) => this.buildStoredAgentPayload(record));
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 normalizeFetchAgentsSort(
sort: FetchAgentsRequestSort[] | undefined,
): FetchAgentsRequestSort[] {
const fallback: FetchAgentsRequestSort[] = [{ key: "updated_at", direction: "desc" }];
if (!sort || sort.length === 0) {
return fallback;
}
const deduped: FetchAgentsRequestSort[] = [];
const seen = new Set<string>();
for (const entry of sort) {
if (seen.has(entry.key)) {
continue;
}
seen.add(entry.key);
deduped.push(entry);
}
return deduped.length > 0 ? deduped : fallback;
}
private getStatusPriority(agent: AgentSnapshotPayload): number {
const attentionReason = agent.attentionReason ?? null;
const hasPendingPermission = (agent.pendingPermissions?.length ?? 0) > 0;
if (hasPendingPermission || attentionReason === "permission") {
return 0;
}
if (agent.status === "error" || attentionReason === "error") {
return 1;
}
if (agent.status === "running") {
return 2;
}
if (agent.status === "initializing") {
return 3;
}
return 4;
}
private getFetchAgentsSortValue(
entry: FetchAgentsResponseEntry,
key: FetchAgentsRequestSort["key"],
): string | number | null {
switch (key) {
case "status_priority":
return this.getStatusPriority(entry.agent);
case "created_at":
return Date.parse(entry.agent.createdAt);
case "updated_at":
return Date.parse(entry.agent.updatedAt);
case "title":
return entry.agent.title?.toLocaleLowerCase() ?? "";
}
}
private getFetchAgentsSortValueFromAgent(
agent: AgentSnapshotPayload,
key: FetchAgentsRequestSort["key"],
): string | number | null {
switch (key) {
case "status_priority":
return this.getStatusPriority(agent);
case "created_at":
return Date.parse(agent.createdAt);
case "updated_at":
return Date.parse(agent.updatedAt);
case "title":
return agent.title?.toLocaleLowerCase() ?? "";
}
}
private compareSortValues(left: string | number | null, right: string | number | null): number {
if (left === right) {
return 0;
}
if (left === null) {
return -1;
}
if (right === null) {
return 1;
}
if (typeof left === "number" && typeof right === "number") {
return left < right ? -1 : 1;
}
return String(left).localeCompare(String(right));
}
private compareFetchAgentsAgents(
left: AgentSnapshotPayload,
right: AgentSnapshotPayload,
sort: FetchAgentsRequestSort[],
): number {
for (const spec of sort) {
const leftValue = this.getFetchAgentsSortValueFromAgent(left, spec.key);
const rightValue = this.getFetchAgentsSortValueFromAgent(right, spec.key);
const base = this.compareSortValues(leftValue, rightValue);
if (base === 0) {
continue;
}
return spec.direction === "asc" ? base : -base;
}
return left.id.localeCompare(right.id);
}
private encodeFetchAgentsCursor(
entry: FetchAgentsResponseEntry,
sort: FetchAgentsRequestSort[],
): string {
const values: Record<string, string | number | null> = {};
for (const spec of sort) {
values[spec.key] = this.getFetchAgentsSortValue(entry, spec.key);
}
return Buffer.from(
JSON.stringify({
sort,
values,
id: entry.agent.id,
}),
"utf8",
).toString("base64url");
}
private decodeFetchAgentsCursor(
cursor: string,
sort: FetchAgentsRequestSort[],
): FetchAgentsCursor {
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
} catch {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
}
if (!parsed || typeof parsed !== "object") {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
}
const payload = parsed as {
sort?: unknown;
values?: unknown;
id?: unknown;
};
if (!Array.isArray(payload.sort) || typeof payload.id !== "string") {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
}
if (!payload.values || typeof payload.values !== "object") {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
}
const cursorSort: FetchAgentsRequestSort[] = [];
for (const item of payload.sort) {
if (
!item ||
typeof item !== "object" ||
typeof (item as { key?: unknown }).key !== "string" ||
typeof (item as { direction?: unknown }).direction !== "string"
) {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
}
const key = (item as { key: string }).key;
const direction = (item as { direction: string }).direction;
if (
(key !== "status_priority" &&
key !== "created_at" &&
key !== "updated_at" &&
key !== "title") ||
(direction !== "asc" && direction !== "desc")
) {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor");
}
cursorSort.push({ key, direction });
}
if (
cursorSort.length !== sort.length ||
cursorSort.some(
(entry, index) =>
entry.key !== sort[index]?.key || entry.direction !== sort[index]?.direction,
)
) {
throw new SessionRequestError(
"invalid_cursor",
"fetch_agents cursor does not match current sort",
);
}
return {
sort: cursorSort,
values: payload.values as Record<string, string | number | null>,
id: payload.id,
};
}
private compareAgentWithCursor(
agent: AgentSnapshotPayload,
cursor: FetchAgentsCursor,
sort: FetchAgentsRequestSort[],
): number {
for (const spec of sort) {
const leftValue = this.getFetchAgentsSortValueFromAgent(agent, spec.key);
const rightValue =
cursor.values[spec.key] !== undefined ? (cursor.values[spec.key] ?? null) : null;
const base = this.compareSortValues(leftValue, rightValue);
if (base === 0) {
continue;
}
return spec.direction === "asc" ? base : -base;
}
return agent.id.localeCompare(cursor.id);
}
private async listFetchAgentsEntries(
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
): Promise<{
entries: FetchAgentsResponseEntry[];
pageInfo: FetchAgentsResponsePageInfo;
}> {
const filter = request.filter;
const sort = this.normalizeFetchAgentsSort(request.sort);
const agents = await this.listAgentPayloads({
labels: filter?.labels,
});
const placementByCwd = new Map<string, Promise<ProjectPlacementPayload | null>>();
const getPlacement = (cwd: string): Promise<ProjectPlacementPayload | 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.compareFetchAgentsAgents(left, right, sort));
const cursorToken = request.page?.cursor;
if (cursorToken) {
const cursor = this.decodeFetchAgentsCursor(cursorToken, sort);
candidates = candidates.filter(
(agent) => this.compareAgentWithCursor(agent, cursor, sort) > 0,
);
}
const limit = request.page?.limit ?? 200;
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;
}
}
}
const pagedEntries = matchedEntries.slice(0, limit);
const hasMore = matchedEntries.length > limit;
const nextCursor =
hasMore && pagedEntries.length > 0
? this.encodeFetchAgentsCursor(pagedEntries[pagedEntries.length - 1], sort)
: null;
return {
entries: pagedEntries,
pageInfo: {
nextCursor,
prevCursor: request.page?.cursor ?? null,
hasMore,
},
};
}
private readonly workspaceStatePriority: Record<WorkspaceStateBucket, number> = {
needs_input: 0,
failed: 1,
running: 2,
attention: 3,
done: 4,
};
private deriveWorkspaceStateBucket(agent: AgentSnapshotPayload): WorkspaceStateBucket {
const pendingPermissionCount = agent.pendingPermissions?.length ?? 0;
if (pendingPermissionCount > 0 || agent.attentionReason === "permission") {
return "needs_input";
}
if (agent.status === "error" || agent.attentionReason === "error") {
return "failed";
}
if (agent.status === "running") {
return "running";
}
if (agent.requiresAttention) {
return "attention";
}
return "done";
}
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 cachedShortstat = getCachedCheckoutShortstat(workspace.cwd);
if (cachedShortstat !== undefined) {
diffStat = cachedShortstat;
} else {
warmCheckoutShortstatInBackground(workspace.cwd, undefined, () => {
void this.emitWorkspaceUpdateForCwd(workspace.cwd);
});
}
return {
id: workspace.workspaceId,
projectId: workspace.projectId,
projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId),
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
workspaceDirectory: workspace.cwd,
projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git",
workspaceKind: workspace.kind,
name: workspace.displayName,
status: "done",
activityAt: null,
diffStat,
scripts:
this.scriptRouteStore && this.scriptRuntimeStore
? buildWorkspaceScriptPayloads({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.cwd,
routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
resolveHealth: this.resolveScriptHealth ?? undefined,
})
: [],
};
}
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,
refreshedAt: snapshot.github.refreshedAt,
};
}
private async describeWorkspaceRecordWithGitData(
workspace: PersistedWorkspaceRecord,
projectRecord?: PersistedProjectRecord | null,
): Promise<WorkspaceDescriptorPayload> {
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
let snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
if (!snapshot) {
try {
snapshot = await this.workspaceGitService.getSnapshot(workspace.cwd);
} catch (error) {
this.sessionLogger.warn(
{ err: error, cwd: workspace.cwd },
"Failed to load git snapshot for workspace",
);
return base;
}
}
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 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);
}
private async buildWorkspaceDescriptorMap(options: {
includeGitData: boolean;
workspaceIds?: Iterable<string>;
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([
this.listAgentPayloads(),
this.workspaceRegistry.list(),
this.projectRegistry.list(),
]);
const activeRecords = persistedWorkspaces.filter((workspace) => !workspace.archivedAt);
const activeProjects = new Map(
persistedProjects
.filter((project) => !project.archivedAt)
.map((project) => [project.projectId, project] as const),
);
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
const workspaceIdsByDirectory = new Map(
activeRecords.map((workspace) => [workspace.cwd, workspace.workspaceId] as const),
);
for (const workspace of activeRecords) {
if (workspaceIds && !workspaceIds.has(workspace.workspaceId)) {
continue;
}
const projectRecord = activeProjects.get(workspace.projectId) ?? null;
descriptorsByWorkspaceId.set(
workspace.workspaceId,
await this.buildWorkspaceDescriptor({
workspace,
projectRecord,
includeGitData: options.includeGitData,
}),
);
}
for (const agent of agents) {
if (agent.archivedAt) {
continue;
}
if (!this.isProviderVisibleToClient(agent.provider)) {
continue;
}
const workspaceId = workspaceIdsByDirectory.get(normalizePersistedWorkspaceId(agent.cwd));
if (workspaceId === undefined) {
continue;
}
const existing = descriptorsByWorkspaceId.get(workspaceId);
if (!existing) {
continue;
}
const bucket = this.deriveWorkspaceStateBucket(agent);
if (this.workspaceStatePriority[bucket] < this.workspaceStatePriority[existing.status]) {
existing.status = bucket;
}
}
return descriptorsByWorkspaceId;
}
private resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd);
if (exact) {
return exact.workspaceId;
}
let bestMatch: PersistedWorkspaceRecord | null = null;
for (const workspace of workspaces) {
const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`;
if (!normalizedCwd.startsWith(prefix)) {
continue;
}
if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) {
bestMatch = workspace;
}
}
return bestMatch?.workspaceId ?? normalizedCwd;
}
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
return Array.from(
(
await this.buildWorkspaceDescriptorMap({
includeGitData: true,
})
).values(),
);
}
private normalizeFetchWorkspacesSort(
sort: FetchWorkspacesRequestSort[] | undefined,
): FetchWorkspacesRequestSort[] {
const fallback: FetchWorkspacesRequestSort[] = [{ key: "activity_at", direction: "desc" }];
if (!sort || sort.length === 0) {
return fallback;
}
const deduped: FetchWorkspacesRequestSort[] = [];
const seen = new Set<string>();
for (const entry of sort) {
if (seen.has(entry.key)) {
continue;
}
seen.add(entry.key);
deduped.push(entry);
}
return deduped.length > 0 ? deduped : fallback;
}
private getFetchWorkspacesSortValue(
workspace: WorkspaceDescriptorPayload,
key: FetchWorkspacesRequestSort["key"],
): string | number | null {
switch (key) {
case "status_priority":
return this.workspaceStatePriority[workspace.status];
case "activity_at":
return workspace.activityAt ? Date.parse(workspace.activityAt) : null;
case "name":
return workspace.name.toLocaleLowerCase();
case "project_id":
return workspace.projectId.toLocaleLowerCase();
}
}
private compareFetchWorkspacesEntries(
left: WorkspaceDescriptorPayload,
right: WorkspaceDescriptorPayload,
sort: FetchWorkspacesRequestSort[],
): number {
for (const spec of sort) {
const leftValue = this.getFetchWorkspacesSortValue(left, spec.key);
const rightValue = this.getFetchWorkspacesSortValue(right, spec.key);
const base = this.compareSortValues(leftValue, rightValue);
if (base === 0) {
continue;
}
return spec.direction === "asc" ? base : -base;
}
return left.id.localeCompare(right.id);
}
private encodeFetchWorkspacesCursor(
entry: FetchWorkspacesResponseEntry,
sort: FetchWorkspacesRequestSort[],
): string {
const values: Record<string, string | number | null> = {};
for (const spec of sort) {
values[spec.key] = this.getFetchWorkspacesSortValue(entry, spec.key);
}
return Buffer.from(
JSON.stringify({
sort,
values,
id: entry.id,
}),
"utf8",
).toString("base64url");
}
private decodeFetchWorkspacesCursor(
cursor: string,
sort: FetchWorkspacesRequestSort[],
): FetchWorkspacesCursor {
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
} catch {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor");
}
if (!parsed || typeof parsed !== "object") {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor");
}
const payload = parsed as {
sort?: unknown;
values?: unknown;
id?: unknown;
};
if (!Array.isArray(payload.sort) || typeof payload.id !== "string") {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor");
}
if (!payload.values || typeof payload.values !== "object") {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor");
}
const cursorSort: FetchWorkspacesRequestSort[] = [];
for (const item of payload.sort) {
if (
!item ||
typeof item !== "object" ||
typeof (item as { key?: unknown }).key !== "string" ||
typeof (item as { direction?: unknown }).direction !== "string"
) {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor");
}
const key = (item as { key: string }).key;
const direction = (item as { direction: string }).direction;
if (
(key !== "status_priority" &&
key !== "activity_at" &&
key !== "name" &&
key !== "project_id") ||
(direction !== "asc" && direction !== "desc")
) {
throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor");
}
cursorSort.push({ key, direction });
}
if (
cursorSort.length !== sort.length ||
cursorSort.some(
(entry, index) =>
entry.key !== sort[index]?.key || entry.direction !== sort[index]?.direction,
)
) {
throw new SessionRequestError(
"invalid_cursor",
"fetch_workspaces cursor does not match current sort",
);
}
return {
sort: cursorSort,
values: payload.values as Record<string, string | number | null>,
id: String(payload.id),
};
}
private compareWorkspaceWithCursor(
workspace: WorkspaceDescriptorPayload,
cursor: FetchWorkspacesCursor,
sort: FetchWorkspacesRequestSort[],
): number {
for (const spec of sort) {
const leftValue = this.getFetchWorkspacesSortValue(workspace, spec.key);
const rightValue =
cursor.values[spec.key] !== undefined ? (cursor.values[spec.key] ?? null) : null;
const base = this.compareSortValues(leftValue, rightValue);
if (base === 0) {
continue;
}
return spec.direction === "asc" ? base : -base;
}
return workspace.id.localeCompare(cursor.id);
}
private matchesWorkspaceFilter(input: {
workspace: WorkspaceDescriptorPayload;
filter: FetchWorkspacesRequestFilter | undefined;
}): boolean {
const { workspace, filter } = input;
if (!filter) {
return true;
}
if (filter.projectId && filter.projectId.trim().length > 0) {
if (workspace.projectId !== filter.projectId.trim()) {
return false;
}
}
if (filter.idPrefix && filter.idPrefix.trim().length > 0) {
if (!String(workspace.id).startsWith(filter.idPrefix.trim())) {
return false;
}
}
if (filter.query && filter.query.trim().length > 0) {
const query = filter.query.trim().toLocaleLowerCase();
const haystacks = [workspace.name, String(workspace.projectId), String(workspace.id)];
if (!haystacks.some((value) => value.toLocaleLowerCase().includes(query))) {
return false;
}
}
return true;
}
private async listFetchWorkspacesEntries(
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
): Promise<{
entries: FetchWorkspacesResponseEntry[];
pageInfo: FetchWorkspacesResponsePageInfo;
}> {
const filter = request.filter;
const sort = this.normalizeFetchWorkspacesSort(request.sort);
let entries = await this.listWorkspaceDescriptors();
const listedCount = entries.length;
entries = entries.filter((workspace) => this.matchesWorkspaceFilter({ workspace, filter }));
const filteredCount = entries.length;
entries.sort((left, right) => this.compareFetchWorkspacesEntries(left, right, sort));
const cursorToken = request.page?.cursor;
if (cursorToken) {
const cursor = this.decodeFetchWorkspacesCursor(cursorToken, sort);
entries = entries.filter(
(workspace) => this.compareWorkspaceWithCursor(workspace, cursor, sort) > 0,
);
}
const limit = request.page?.limit ?? 200;
const pagedEntries = entries.slice(0, limit);
const hasMore = entries.length > limit;
const nextCursor =
hasMore && pagedEntries.length > 0
? this.encodeFetchWorkspacesCursor(pagedEntries[pagedEntries.length - 1], sort)
: null;
this.sessionLogger.debug(
{
requestId: request.requestId,
filter: request.filter ?? null,
sort,
page: request.page ?? null,
listedCount,
filteredCount,
returnedCount: pagedEntries.length,
hasMore,
nextCursor,
},
"fetch_workspaces_entries_listed",
);
return {
entries: pagedEntries,
pageInfo: {
nextCursor,
prevCursor: request.page?.cursor ?? null,
hasMore,
},
};
}
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 normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const existingWorkspace = await this.findWorkspaceByDirectory(normalizedCwd);
if (existingWorkspace) {
return this.ensureWorkspaceRecordUnarchived(existingWorkspace);
}
const placement = await buildProjectPlacementForCwdStandalone({
cwd: normalizedCwd,
workspaceGitService: this.workspaceGitService,
});
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
const timestamp = new Date().toISOString();
const projectRecord = createPersistedProjectRecord({
projectId: placement.projectKey,
rootPath: deriveProjectRootPath({ cwd: normalizedCwd, checkout: placement.checkout }),
kind: deriveProjectKind(placement.checkout),
displayName: placement.projectName,
createdAt: timestamp,
updatedAt: timestamp,
});
await this.projectRegistry.upsert(projectRecord);
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId,
projectId: placement.projectKey,
cwd: normalizedCwd,
kind: deriveWorkspaceKind(placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: normalizedCwd,
checkout: placement.checkout,
}),
createdAt: timestamp,
updatedAt: timestamp,
});
await this.workspaceRegistry.upsert(workspaceRecord);
return workspaceRecord;
}
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?: {
resolveRepositoryDefaultBranch?: (repoRoot: string) => Promise<string>;
},
): Promise<CreatePaseoWorktreeResult> {
const coreDeps = createWorktreeCoreDeps(this.github);
return createPaseoWorktree(input, {
...coreDeps,
...(options?.resolveRepositoryDefaultBranch
? { resolveRepositoryDefaultBranch: options.resolveRepositoryDefaultBranch }
: {}),
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
workspaceGitService: this.workspaceGitService,
primeWorkspaceGitWatchFingerprints: (workspace) =>
this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace),
broadcastWorkspaceUpdate: (workspaceId) =>
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
});
}
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
if (!existingWorkspace || existingWorkspace.archivedAt) {
this.removeWorkspaceGitSubscription(workspaceId);
return;
}
const nextArchivedAt = archivedAt ?? new Date().toISOString();
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd);
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd);
this.removeWorkspaceGitSubscription(workspaceId);
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(existingWorkspace.projectId, nextArchivedAt);
}
}
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>> {
return new Set();
}
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.rememberWorkspaceGitWatchFingerprint(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 emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
const workspaces = await this.workspaceRegistry.list();
const uniqueWorkspaceIds = new Set<string>();
for (const cwd of cwds) {
uniqueWorkspaceIds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces));
}
await this.emitWorkspaceUpdatesForWorkspaceIds(uniqueWorkspaceIds);
}
private async handleFetchAgents(
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
): Promise<void> {
const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim();
const subscriptionId = request.subscribe
? requestedSubscriptionId && requestedSubscriptionId.length > 0
? requestedSubscriptionId
: uuidv4()
: null;
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 handleFetchWorkspacesRequest(
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
): Promise<void> {
const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim();
const subscriptionId = request.subscribe
? requestedSubscriptionId && requestedSubscriptionId.length > 0
? requestedSubscriptionId
: uuidv4()
: null;
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);
await this.primeWorkspaceGitWatchFingerprints(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 handleOpenProjectRequest(
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
): Promise<void> {
try {
const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd);
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
this.emit({
type: "open_project_response",
payload: {
requestId: request.requestId,
workspace: descriptor,
error: null,
},
});
} 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,
routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
resolveHealth: this.resolveScriptHealth ?? undefined,
});
}
private emitWorkspaceScriptStatusUpdate(workspaceId: string, workspaceDirectory: string): void {
this.emit({
type: "script_status_update",
payload: {
workspaceId,
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspaceId, workspaceDirectory),
},
});
}
async getAvailableEditorTargets() {
return this.filterEditorsForClient(await listAvailableEditorTargets());
}
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 serviceResult = await spawnWorkspaceScript({
repoRoot: workspace.cwd,
workspaceId: workspace.workspaceId,
projectSlug: deriveProjectSlug(workspace.cwd),
branchName: readGitCommand(workspace.cwd, "git symbolic-ref --short HEAD"),
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: (workspace) => this.describeWorkspaceRecordWithGitData(workspace),
emit: (message) => this.emit(message),
createPaseoWorktree: (input) => this.createPaseoWorktree(input),
sessionLogger: this.sessionLogger,
runWorktreeSetupInBackground: (options) => this.runWorktreeSetupInBackground(options),
},
request,
);
}
private async runWorktreeSetupInBackground(options: {
requestCwd: string;
repoRoot: string;
workspaceId: string;
worktree: { branchName: string; worktreePath: string };
shouldBootstrap: boolean;
slug: string;
worktreePath: string;
}): Promise<void> {
return runWorktreeSetupInBackgroundSession(
{
paseoHome: this.paseoHome,
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);
},
},
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 async handleFetchAgentTimelineRequest(
msg: Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>,
): Promise<void> {
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
const projection: TimelineProjectionMode = msg.projection ?? "projected";
const requestedLimit = msg.limit;
const limit = requestedLimit ?? (direction === "after" ? 0 : undefined);
const shouldLimitByProjectedWindow =
projection === "canonical" &&
direction === "tail" &&
typeof requestedLimit === "number" &&
requestedLimit > 0;
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);
let timeline = this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit:
shouldLimitByProjectedWindow && typeof requestedLimit === "number"
? Math.max(1, Math.floor(requestedLimit))
: limit,
});
let hasOlder = timeline.hasOlder;
let hasNewer = timeline.hasNewer;
let startCursor: { epoch: string; seq: number } | null = null;
let endCursor: { epoch: string; seq: number } | null = null;
let entries: ReturnType<typeof projectTimelineRows>;
if (shouldLimitByProjectedWindow) {
const projectedLimit = Math.max(1, Math.floor(requestedLimit));
let fetchLimit = projectedLimit;
let projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
provider: snapshot.provider,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
while (timeline.hasOlder) {
const needsMoreProjectedEntries =
projectedWindow.projectedEntries.length < projectedLimit;
const firstLoadedRow = timeline.rows[0];
const firstSelectedRow = projectedWindow.selectedRows[0];
const startsAtLoadedBoundary =
firstLoadedRow != null &&
firstSelectedRow != null &&
firstSelectedRow.seq === firstLoadedRow.seq;
const boundaryIsAssistantChunk =
startsAtLoadedBoundary && firstLoadedRow.item.type === "assistant_message";
if (!needsMoreProjectedEntries && !boundaryIsAssistantChunk) {
break;
}
const maxRows = Math.max(0, timeline.window.maxSeq - timeline.window.minSeq + 1);
const nextFetchLimit = Math.min(maxRows, fetchLimit * 2);
if (nextFetchLimit <= fetchLimit) {
break;
}
fetchLimit = nextFetchLimit;
timeline = this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit: fetchLimit,
});
projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
provider: snapshot.provider,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
}
const selectedRows = projectedWindow.selectedRows;
entries = projectTimelineRows(selectedRows, snapshot.provider, projection);
if (projectedWindow.minSeq !== null && projectedWindow.maxSeq !== null) {
startCursor = { epoch: timeline.epoch, seq: projectedWindow.minSeq };
endCursor = { epoch: timeline.epoch, seq: projectedWindow.maxSeq };
hasOlder = projectedWindow.minSeq > timeline.window.minSeq;
hasNewer = false;
}
} else {
const firstRow = timeline.rows[0];
const lastRow = timeline.rows[timeline.rows.length - 1];
startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null;
endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null;
entries = projectTimelineRows(timeline.rows, snapshot.provider, projection);
}
this.emit({
type: "fetch_agent_timeline_response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
agent: agentPayload,
direction,
projection,
epoch: timeline.epoch,
reset: timeline.reset,
staleCursor: timeline.staleCursor,
gap: timeline.gap,
window: timeline.window,
startCursor,
endCursor,
hasOlder,
hasNewer,
entries,
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) },
"send_agent_message_request: dispatching shared sendPromptToAgent",
);
try {
await sendPromptToAgent({
agentManager: this.agentManager,
agentStorage: this.agentStorage,
agentId,
userMessageText: msg.text,
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;
}
const startAbort = new AbortController();
const startTimeoutMs = 15_000;
const startTimeout = setTimeout(() => startAbort.abort("timeout"), startTimeoutMs);
try {
await this.agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal });
} catch (error) {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error";
this.emit({
type: "send_agent_message_response",
payload: {
requestId: msg.requestId,
agentId,
accepted: false,
error: message,
},
});
return;
} finally {
clearTimeout(startTimeout);
}
this.emit({
type: "send_agent_message_response",
payload: {
requestId: msg.requestId,
agentId,
accepted: true,
error: null,
},
});
} catch (error) {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error";
this.emit({
type: "send_agent_message_response",
payload: {
requestId: msg.requestId,
agentId: resolved.agentId,
accepted: false,
error: message,
},
});
}
}
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);
const status =
record.attentionReason === "permission"
? "permission"
: record.lastStatus === "error"
? "error"
: "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" = result.permission
? "permission"
: result.status === "error"
? "error"
: "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 =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown 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`);
}
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 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) {
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;
if (this.voiceInputChunkCount === 1) {
this.sessionLogger.info(
{
format: chunkFormat,
audioBytes: chunkBytes,
},
"Received first voice_audio_chunk for active voice mode",
);
}
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,
});
return;
}
const chunkBuffer = Buffer.from(msg.audio, "base64");
const isPCMChunk = chunkFormat.toLowerCase().includes("pcm");
if (!this.audioBuffer) {
this.audioBuffer = {
chunks: [],
format: chunkFormat,
isPCM: isPCMChunk,
totalPCMBytes: 0,
};
}
// If the format changes mid-stream, flush what we have first
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,
};
} else if (!this.audioBuffer.isPCM) {
// Keep latest format info for non-PCM blobs
this.audioBuffer.format = chunkFormat;
}
this.audioBuffer.chunks.push(chunkBuffer);
if (this.audioBuffer.isPCM) {
this.audioBuffer.totalPCMBytes += chunkBuffer.length;
}
// In non-voice mode, use streaming threshold to process chunks
const reachedStreamingThreshold =
!this.isVoiceMode &&
this.audioBuffer.isPCM &&
this.audioBuffer.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: any) {
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: ${error.message}`,
},
});
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 {
this.clearPendingVoiceSpeechStart(`clear-speech-in-progress:${reason}`);
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 },
"outbound message",
);
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("Cleaning up");
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;
// Unsubscribe from all terminals
if (this.unsubscribeTerminalsChanged) {
this.unsubscribeTerminalsChanged();
this.unsubscribeTerminalsChanged = null;
}
this.subscribedTerminalDirectories.clear();
for (const unsubscribeExit of this.terminalExitSubscriptions.values()) {
unsubscribeExit();
}
this.terminalExitSubscriptions.clear();
this.disposeTerminalSubscriptions();
for (const unsubscribe of this.checkoutDiffSubscriptions.values()) {
unsubscribe();
}
this.checkoutDiffSubscriptions.clear();
for (const unsubscribe of this.workspaceGitSubscriptions.values()) {
unsubscribe();
}
this.workspaceGitSubscriptions.clear();
}
// ----------------------------------------------------------------------------
// Terminal Handlers
// ----------------------------------------------------------------------------
private ensureTerminalExitSubscription(terminal: TerminalSession): void {
if (this.terminalExitSubscriptions.has(terminal.id)) {
return;
}
const unsubscribeExit = terminal.onExit(() => {
this.handleTerminalExited(terminal.id);
});
this.terminalExitSubscriptions.set(terminal.id, unsubscribeExit);
}
private handleTerminalExited(terminalId: string): void {
const unsubscribeExit = this.terminalExitSubscriptions.get(terminalId);
if (unsubscribeExit) {
unsubscribeExit();
this.terminalExitSubscriptions.delete(terminalId);
}
this.detachTerminalStream(terminalId, { emitExit: true });
}
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 message = await this.chatService.postMessage({
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,
listStoredAgents: () => this.agentStorage.list(),
listLiveAgents: () => this.agentManager.listAgents(),
resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier),
sendAgentMessage: async (agentId, text) => {
await this.handleSendAgentMessage(agentId, text);
},
});
} 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";
}
>,
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,
});
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 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,
workerProvider: request.workerProvider,
workerModel: request.workerModel,
verifierProvider: request.verifierProvider,
verifierModel: request.verifierModel,
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);
}
}
private emitTerminalsChangedSnapshot(input: {
cwd: string;
terminals: Array<{ id: string; name: string; title?: string }>;
}): void {
this.emit({
type: "terminals_changed",
payload: {
cwd: input.cwd,
terminals: input.terminals,
},
});
}
private filterStandaloneTerminals<T extends { id: string }>(terminals: T[]): T[] {
return terminals;
}
private toTerminalInfo(terminal: Pick<TerminalSession, "id" | "name" | "getTitle">): {
id: string;
name: string;
title?: string;
} {
const title = terminal.getTitle();
return {
id: terminal.id,
name: terminal.name,
...(title ? { title } : {}),
};
}
private handleTerminalsChanged(event: TerminalsChangedEvent): void {
if (!this.subscribedTerminalDirectories.has(event.cwd)) {
return;
}
this.emitTerminalsChangedSnapshot({
cwd: event.cwd,
terminals: this.filterStandaloneTerminals(event.terminals).map((terminal) => ({
id: terminal.id,
name: terminal.name,
...(terminal.title ? { title: terminal.title } : {}),
})),
});
}
private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void {
this.subscribedTerminalDirectories.add(msg.cwd);
void this.emitInitialTerminalsChangedSnapshot(msg.cwd);
}
private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void {
this.subscribedTerminalDirectories.delete(msg.cwd);
}
private async emitInitialTerminalsChangedSnapshot(cwd: string): Promise<void> {
if (!this.terminalManager || !this.subscribedTerminalDirectories.has(cwd)) {
return;
}
try {
const terminals = this.filterStandaloneTerminals(
await this.terminalManager.getTerminals(cwd),
);
for (const terminal of terminals) {
this.ensureTerminalExitSubscription(terminal);
}
if (!this.subscribedTerminalDirectories.has(cwd)) {
return;
}
this.emitTerminalsChangedSnapshot({
cwd,
terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)),
});
} catch (error) {
this.sessionLogger.warn({ err: error, cwd }, "Failed to emit initial terminal snapshot");
}
}
private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise<void> {
if (!this.terminalManager) {
this.emit({
type: "list_terminals_response",
payload: {
...(msg.cwd ? { cwd: msg.cwd } : {}),
terminals: [],
requestId: msg.requestId,
},
});
return;
}
try {
const terminals = this.filterStandaloneTerminals(
typeof msg.cwd === "string"
? await this.terminalManager.getTerminals(msg.cwd)
: await this.getAllTerminalSessions(),
);
for (const terminal of terminals) {
this.ensureTerminalExitSubscription(terminal);
}
this.emit({
type: "list_terminals_response",
payload: {
...(msg.cwd ? { cwd: msg.cwd } : {}),
terminals: terminals.map((terminal) => this.toTerminalInfo(terminal)),
requestId: msg.requestId,
},
});
} catch (error: any) {
this.sessionLogger.error({ err: error, cwd: msg.cwd }, "Failed to list terminals");
this.emit({
type: "list_terminals_response",
payload: {
...(msg.cwd ? { cwd: msg.cwd } : {}),
terminals: [],
requestId: msg.requestId,
},
});
}
}
private async getAllTerminalSessions(): Promise<TerminalSession[]> {
if (!this.terminalManager) {
return [];
}
const directories = this.terminalManager.listDirectories();
const terminalsByDirectory = await Promise.all(
directories.map((cwd) => this.terminalManager!.getTerminals(cwd)),
);
return terminalsByDirectory.flat();
}
private async handleCreateTerminalRequest(msg: CreateTerminalRequest): Promise<void> {
if (!this.terminalManager) {
this.emit({
type: "create_terminal_response",
payload: {
terminal: null,
error: "Terminal manager not available",
requestId: msg.requestId,
},
});
return;
}
try {
if (msg.agentId) {
this.emit({
type: "create_terminal_response",
payload: {
terminal: null,
error: `Agent-backed terminals are no longer supported for agent ${msg.agentId}`,
requestId: msg.requestId,
},
});
return;
}
const session = await this.terminalManager.createTerminal({
cwd: msg.cwd,
name: msg.name,
command: msg.command,
args: msg.args,
});
this.ensureTerminalExitSubscription(session);
this.emit({
type: "create_terminal_response",
payload: {
terminal: {
id: session.id,
name: session.name,
cwd: session.cwd,
...(session.getTitle() ? { title: session.getTitle() } : {}),
},
error: null,
requestId: msg.requestId,
},
});
} catch (error: any) {
this.sessionLogger.error({ err: error, cwd: msg.cwd }, "Failed to create terminal");
this.emit({
type: "create_terminal_response",
payload: {
terminal: null,
error: error.message,
requestId: msg.requestId,
},
});
}
}
private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise<void> {
if (!this.terminalManager) {
this.emit({
type: "subscribe_terminal_response",
payload: {
terminalId: msg.terminalId,
error: "Terminal manager not available",
requestId: msg.requestId,
},
});
return;
}
const session = this.terminalManager.getTerminal(msg.terminalId);
if (!session) {
this.emit({
type: "subscribe_terminal_response",
payload: {
terminalId: msg.terminalId,
error: "Terminal not found",
requestId: msg.requestId,
},
});
return;
}
this.ensureTerminalExitSubscription(session);
const slot = this.bindActiveTerminalStream(session);
if (slot === null) {
this.sessionLogger.warn(
{
terminalId: msg.terminalId,
activeTerminalStreamCount: this.activeTerminalStreams.size,
},
"Terminal stream slot exhaustion",
);
this.emit({
type: "subscribe_terminal_response",
payload: {
terminalId: msg.terminalId,
error: "No terminal stream slots available",
requestId: msg.requestId,
},
});
return;
}
this.emit({
type: "subscribe_terminal_response",
payload: {
terminalId: msg.terminalId,
slot,
error: null,
requestId: msg.requestId,
},
});
const activeStream = this.activeTerminalStreams.get(slot);
if (activeStream) {
this.trySendTerminalSnapshot(activeStream);
}
}
private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void {
this.detachTerminalStream(msg.terminalId, { emitExit: false });
}
private handleTerminalInput(msg: TerminalInput): void {
if (!this.terminalManager) {
return;
}
const session = this.terminalManager.getTerminal(msg.terminalId);
if (!session) {
this.sessionLogger.warn({ terminalId: msg.terminalId }, "Terminal not found for input");
return;
}
this.ensureTerminalExitSubscription(session);
if (msg.message.type === "resize") {
const currentSize = session.getSize();
if (currentSize.rows === msg.message.rows && currentSize.cols === msg.message.cols) {
return;
}
}
session.send(msg.message);
}
private killTrackedTerminal(terminalId: string, options?: { emitExit: boolean }): void {
this.detachTerminalStream(terminalId, { emitExit: options?.emitExit ?? true });
this.terminalManager?.killTerminal(terminalId);
}
private async killTerminalsUnderPath(rootPath: string): Promise<void> {
return killWorktreeTerminalsUnderPath(
{
isPathWithinRoot: (pathRoot, candidatePath) =>
this.isPathWithinRoot(pathRoot, candidatePath),
killTrackedTerminal: (terminalId, options) => this.killTrackedTerminal(terminalId, options),
detachTerminalStream: (terminalId, options) =>
void this.detachTerminalStream(terminalId, options),
sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager,
},
rootPath,
);
}
private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise<void> {
const result = this.killTerminalForClose(msg.terminalId);
this.emit({
type: "kill_terminal_response",
payload: {
terminalId: result.terminalId,
success: result.success,
requestId: msg.requestId,
},
});
}
private killTerminalForClose(terminalId: string): { terminalId: string; success: boolean } {
if (!this.terminalManager) {
return {
terminalId,
success: false,
};
}
this.killTrackedTerminal(terminalId, { emitExit: true });
return {
terminalId,
success: true,
};
}
private async handleCaptureTerminalRequest(msg: CaptureTerminalRequest): Promise<void> {
if (!this.terminalManager) {
this.emit({
type: "capture_terminal_response",
payload: {
terminalId: msg.terminalId,
lines: [],
totalLines: 0,
requestId: msg.requestId,
},
});
return;
}
const session = this.terminalManager.getTerminal(msg.terminalId);
if (!session) {
this.emit({
type: "capture_terminal_response",
payload: {
terminalId: msg.terminalId,
lines: [],
totalLines: 0,
requestId: msg.requestId,
},
});
return;
}
this.ensureTerminalExitSubscription(session);
try {
const capture = captureTerminalLines(session, {
start: msg.start,
end: msg.end,
stripAnsi: msg.stripAnsi,
});
this.emit({
type: "capture_terminal_response",
payload: {
terminalId: msg.terminalId,
lines: capture.lines,
totalLines: capture.totalLines,
requestId: msg.requestId,
},
});
} catch (error: any) {
this.sessionLogger.error(
{ err: error, terminalId: msg.terminalId },
"Failed to capture terminal",
);
this.emit({
type: "capture_terminal_response",
payload: {
terminalId: msg.terminalId,
lines: [],
totalLines: 0,
requestId: msg.requestId,
},
});
}
}
private bindActiveTerminalStream(terminal: TerminalSession): number | null {
if (!this.onBinaryMessage) {
return null;
}
const existingSlot = this.terminalIdToSlot.get(terminal.id);
if (typeof existingSlot === "number") {
const existingStream = this.activeTerminalStreams.get(existingSlot);
if (existingStream) {
existingStream.needsSnapshot = true;
return existingSlot;
}
this.terminalIdToSlot.delete(terminal.id);
}
const slot = this.allocateTerminalSlot();
if (slot === null) {
return null;
}
const activeStream: ActiveTerminalStream = {
terminalId: terminal.id,
slot,
unsubscribe: () => {},
needsSnapshot: true,
};
this.activeTerminalStreams.set(slot, activeStream);
this.terminalIdToSlot.set(terminal.id, slot);
activeStream.unsubscribe = terminal.subscribe((message) => {
if (this.activeTerminalStreams.get(slot) !== activeStream) {
return;
}
if (message.type === "snapshot" || message.type === "titleChange") {
activeStream.needsSnapshot = true;
this.trySendTerminalSnapshot(activeStream);
return;
}
if (activeStream.needsSnapshot || message.data.length === 0) {
return;
}
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Output,
slot,
payload: new Uint8Array(Buffer.from(message.data, "utf8")),
}),
);
});
return slot;
}
private trySendTerminalSnapshot(activeStream: ActiveTerminalStream): void {
if (
this.activeTerminalStreams.get(activeStream.slot) !== activeStream ||
!activeStream.needsSnapshot
) {
return;
}
const terminal = this.terminalManager?.getTerminal(activeStream.terminalId);
if (!terminal) {
this.detachTerminalStream(activeStream.terminalId, { emitExit: true });
return;
}
activeStream.needsSnapshot = false;
this.emitBinary(
encodeTerminalStreamFrame({
opcode: TerminalStreamOpcode.Snapshot,
slot: activeStream.slot,
payload: encodeTerminalSnapshotPayload(terminal.getState()),
}),
);
}
private allocateTerminalSlot(): number | null {
for (let attempt = 0; attempt < MAX_TERMINAL_STREAM_SLOTS; attempt += 1) {
const slot = (this.nextTerminalSlot + attempt) % MAX_TERMINAL_STREAM_SLOTS;
if (this.activeTerminalStreams.has(slot)) {
continue;
}
this.nextTerminalSlot = (slot + 1) % MAX_TERMINAL_STREAM_SLOTS;
return slot;
}
return null;
}
private detachTerminalStream(terminalId: string, options?: { emitExit: boolean }): boolean {
const slot = this.terminalIdToSlot.get(terminalId);
if (typeof slot !== "number") {
return false;
}
const activeStream = this.activeTerminalStreams.get(slot);
if (!activeStream) {
this.terminalIdToSlot.delete(terminalId);
return false;
}
this.activeTerminalStreams.delete(slot);
this.terminalIdToSlot.delete(terminalId);
try {
activeStream.unsubscribe();
} catch (error) {
this.sessionLogger.warn({ err: error }, "Failed to unsubscribe terminal stream");
}
if (options?.emitExit) {
this.emit({
type: "terminal_stream_exit",
payload: {
terminalId: activeStream.terminalId,
},
});
}
return true;
}
private disposeTerminalSubscriptions(): void {
for (const terminalId of [...this.terminalIdToSlot.keys()]) {
this.detachTerminalStream(terminalId, { emitExit: false });
}
}
}