Restores all features dropped when merging main into dev: - Keyboard shortcuts (send, dictation-confirm) with full e2e chain - Feature toggles (plan/fast mode) in agent status bar - Sidebar kebab menu (Remove Project) on project rows - Combined model selector (sticky header, sizing, favorites sort, search) - Question form Enter key submit with guard - Input focus restoration (composer + AgentStatusBar onDropdownClose) - Draft-agent feature toggles and focus restoration - Provider diagnostics UI (settings section, diagnostic sheet, status badge) - Provider diagnostics server (snapshot manager, diagnostic utils, RPC) Fixes dev-branch regressions: - New workspace button now opens composer in setup dialog via beginWorkspaceSetup - Setup tab auto-opens when workspace setup is running
8100 lines
245 KiB
TypeScript
8100 lines
245 KiB
TypeScript
import { v4 as uuidv4 } from "uuid";
|
||
import { watch, type FSWatcher } from "node:fs";
|
||
import { readFile } from "fs/promises";
|
||
import { exec, execFile } from "node:child_process";
|
||
import { promisify } from "util";
|
||
import { join, resolve, sep } from "path";
|
||
import { homedir } from "node:os";
|
||
import { z } from "zod";
|
||
import type { ToolSet } from "ai";
|
||
import {
|
||
serializeAgentStreamEvent,
|
||
type AgentSnapshotPayload,
|
||
type SessionInboundMessage,
|
||
type SessionOutboundMessage,
|
||
type FileExplorerRequest,
|
||
type FileDownloadTokenRequest,
|
||
type GitSetupOptions,
|
||
type ListTerminalsRequest,
|
||
type SubscribeTerminalsRequest,
|
||
type UnsubscribeTerminalsRequest,
|
||
type CreateTerminalRequest,
|
||
type SubscribeTerminalRequest,
|
||
type UnsubscribeTerminalRequest,
|
||
type TerminalInput,
|
||
type CloseItemsRequest,
|
||
type KillTerminalRequest,
|
||
type CaptureTerminalRequest,
|
||
type SubscribeCheckoutDiffRequest,
|
||
type UnsubscribeCheckoutDiffRequest,
|
||
type DirectorySuggestionsRequest,
|
||
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 {
|
||
DictationStreamManager,
|
||
type DictationStreamOutboundMessage,
|
||
} from "./dictation/dictation-stream-manager.js";
|
||
import {
|
||
createVoiceTurnController,
|
||
type VoiceTurnController,
|
||
} from "./voice/voice-turn-controller.js";
|
||
import {
|
||
toAgentPersistenceHandle,
|
||
} from "./persistence-hooks.js";
|
||
import { experimental_createMCPClient } from "ai";
|
||
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from "./voice-types.js";
|
||
import { buildWorkspaceServicePayloads } from "./service-status-projection.js";
|
||
|
||
export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||
import type { AgentProviderRuntimeSettingsMap } 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 { resolveEffectiveThinkingOptionId, 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 {
|
||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||
StructuredAgentFallbackError,
|
||
StructuredAgentResponseError,
|
||
generateStructuredAgentResponseWithFallback,
|
||
} from "./agent/agent-response-loop.js";
|
||
import type {
|
||
AgentPermissionResponse,
|
||
AgentPromptContentBlock,
|
||
AgentPromptInput,
|
||
AgentRunOptions,
|
||
McpServerConfig,
|
||
AgentSessionConfig,
|
||
AgentStreamEvent,
|
||
AgentProvider,
|
||
AgentPersistenceHandle,
|
||
ProviderSnapshotEntry,
|
||
} from "./agent/agent-sdk-types.js";
|
||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
|
||
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
||
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
||
import type {
|
||
PersistedProjectRecord,
|
||
PersistedWorkspaceRecord,
|
||
ProjectRegistry,
|
||
WorkspaceRegistry,
|
||
} from "./workspace-registry.js";
|
||
import { AgentLoadingService } from "./agent-loading-service.js";
|
||
import {
|
||
buildVoiceAgentMcpServerConfig,
|
||
buildVoiceModeSystemPrompt,
|
||
stripVoiceModeSystemPrompt,
|
||
} 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 { ServiceRouteStore } from "./service-proxy.js";
|
||
import {
|
||
getCheckoutDiff,
|
||
getCheckoutShortstat,
|
||
getCheckoutStatus,
|
||
getCheckoutStatusLite,
|
||
listBranchSuggestions,
|
||
commitChanges,
|
||
mergeToBase,
|
||
mergeFromBase,
|
||
pushCurrentBranch,
|
||
createPullRequest,
|
||
getPullRequestStatus,
|
||
} 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,
|
||
resolveCheckoutGitDir,
|
||
toCheckoutError,
|
||
} from "./checkout-git-utils.js";
|
||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||
import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.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 {
|
||
assertSafeGitRef as assertWorktreeSafeGitRef,
|
||
buildAgentSessionConfig as buildWorktreeAgentSessionConfig,
|
||
createPaseoWorktreeInBackground as createWorktreeInBackgroundSession,
|
||
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
|
||
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
|
||
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
|
||
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
|
||
killTerminalsUnderPath as killWorktreeTerminalsUnderPath,
|
||
} from "./worktree-session.js";
|
||
|
||
const execAsync = promisify(exec);
|
||
const execFileAsync = promisify(execFile);
|
||
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
||
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
|
||
const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]);
|
||
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
|
||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
||
const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__";
|
||
const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
|
||
const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024;
|
||
const MAX_TERMINAL_STREAM_SLOTS = 256;
|
||
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
||
|
||
type DeleteFencedAgentSnapshotStore = AgentSnapshotStore & {
|
||
beginDelete(agentId: string): void;
|
||
};
|
||
|
||
|
||
function beginAgentDeleteIfSupported(agentStorage: AgentSnapshotStore, agentId: string): void {
|
||
if ("beginDelete" in agentStorage && typeof agentStorage.beginDelete === "function") {
|
||
(agentStorage as DeleteFencedAgentSnapshotStore).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;
|
||
}
|
||
|
||
function clientSupportsAllProviders(appVersion: string | null): boolean {
|
||
if (!appVersion) return false;
|
||
const base = appVersion.replace(/-.*$/, "");
|
||
const parts = base.split(".").map(Number);
|
||
const minParts = MIN_VERSION_ALL_PROVIDERS.split(".").map(Number);
|
||
for (let i = 0; i < minParts.length; i++) {
|
||
const current = parts[i] ?? 0;
|
||
const minimum = minParts[i] ?? 0;
|
||
if (current > minimum) return true;
|
||
if (current < minimum) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
type ProcessingPhase = "idle" | "transcribing";
|
||
|
||
type WorkspaceGitWatchTarget = {
|
||
cwd: string;
|
||
watchers: FSWatcher[];
|
||
debounceTimer: NodeJS.Timeout | null;
|
||
refreshPromise: Promise<void> | null;
|
||
refreshQueued: boolean;
|
||
latestFingerprint: string | null;
|
||
};
|
||
|
||
type ActiveTerminalStream = {
|
||
terminalId: string;
|
||
slot: number;
|
||
unsubscribe: () => void;
|
||
needsSnapshot: boolean;
|
||
snapshotRetryTimer: ReturnType<typeof setTimeout> | null;
|
||
};
|
||
|
||
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<number, WorkspaceUpdatePayload>;
|
||
};
|
||
type FetchWorkspacesCursor = {
|
||
sort: FetchWorkspacesRequestSort[];
|
||
values: Record<string, string | number | null>;
|
||
id: number;
|
||
};
|
||
|
||
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_MCP_SERVER_NAME = "paseo_voice";
|
||
const VOICE_INTERRUPT_CONFIRMATION_MS = 500;
|
||
|
||
type VoiceModeBaseConfig = {
|
||
systemPrompt?: string;
|
||
mcpServers?: Record<string, McpServerConfig>;
|
||
};
|
||
|
||
interface AudioBufferState {
|
||
chunks: Buffer[];
|
||
format: string;
|
||
isPCM: boolean;
|
||
totalPCMBytes: number;
|
||
}
|
||
|
||
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;
|
||
getBinaryBufferedAmount?: () => number;
|
||
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
|
||
logger: pino.Logger;
|
||
downloadTokenStore: DownloadTokenStore;
|
||
pushTokenStore: PushTokenStore;
|
||
paseoHome: string;
|
||
agentManager: AgentManager;
|
||
agentStorage: AgentSnapshotStore;
|
||
projectRegistry: ProjectRegistry;
|
||
workspaceRegistry: WorkspaceRegistry;
|
||
chatService: FileBackedChatService;
|
||
scheduleService: ScheduleService;
|
||
loopService: LoopService;
|
||
checkoutDiffManager: CheckoutDiffManager;
|
||
agentLoadingService?: AgentLoadingService;
|
||
createAgentMcpTransport: AgentMcpTransportFactory;
|
||
stt: Resolvable<SpeechToTextProvider | null>;
|
||
tts: Resolvable<TextToSpeechProvider | null>;
|
||
terminalManager: TerminalManager | null;
|
||
providerSnapshotManager?: ProviderSnapshotManager;
|
||
serviceRouteStore?: ServiceRouteStore;
|
||
getDaemonTcpPort?: () => number | null;
|
||
resolveServiceStatus?: (hostname: string) => "running" | "stopped" | null;
|
||
voice?: {
|
||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null;
|
||
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;
|
||
ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>;
|
||
removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>;
|
||
};
|
||
dictation?: {
|
||
finalTimeoutMs?: number;
|
||
stt?: Resolvable<SpeechToTextProvider | null>;
|
||
getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||
};
|
||
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap;
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
function coerceAgentProvider(logger: pino.Logger, value: string, agentId?: string): AgentProvider {
|
||
if (isValidAgentProvider(value)) {
|
||
return value;
|
||
}
|
||
logger.warn(
|
||
{ value, agentId, defaultProvider: DEFAULT_AGENT_PROVIDER },
|
||
`Unknown provider '${value}' for agent ${agentId ?? "unknown"}; defaulting to '${DEFAULT_AGENT_PROVIDER}'`,
|
||
);
|
||
return DEFAULT_AGENT_PROVIDER;
|
||
}
|
||
|
||
/**
|
||
* 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 getBinaryBufferedAmount: (() => number) | 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: NodeJS.Timeout | 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: NodeJS.Timeout | 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: AgentSnapshotStore;
|
||
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 agentLoadingService: AgentLoadingService;
|
||
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
|
||
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 serviceRouteStore: ServiceRouteStore | null;
|
||
private readonly getDaemonTcpPort: (() => number | null) | null;
|
||
private readonly resolveServiceStatus: ((hostname: string) => "running" | "stopped" | 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 voiceAgentMcpStdio: VoiceMcpStdioConfig | null;
|
||
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 ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>;
|
||
private readonly removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>;
|
||
private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot;
|
||
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
|
||
private voiceModeAgentId: string | null = null;
|
||
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
|
||
|
||
constructor(options: SessionOptions) {
|
||
const {
|
||
clientId,
|
||
appVersion,
|
||
onMessage,
|
||
onBinaryMessage,
|
||
getBinaryBufferedAmount,
|
||
onLifecycleIntent,
|
||
logger,
|
||
downloadTokenStore,
|
||
pushTokenStore,
|
||
paseoHome,
|
||
agentManager,
|
||
agentStorage,
|
||
projectRegistry,
|
||
workspaceRegistry,
|
||
chatService,
|
||
scheduleService,
|
||
loopService,
|
||
checkoutDiffManager,
|
||
agentLoadingService,
|
||
createAgentMcpTransport,
|
||
stt,
|
||
tts,
|
||
terminalManager,
|
||
providerSnapshotManager,
|
||
serviceRouteStore,
|
||
getDaemonTcpPort,
|
||
resolveServiceStatus,
|
||
voice,
|
||
voiceBridge,
|
||
dictation,
|
||
agentProviderRuntimeSettings,
|
||
} = options;
|
||
this.clientId = clientId;
|
||
this.appVersion = appVersion ?? null;
|
||
this.sessionId = uuidv4();
|
||
this.onMessage = onMessage;
|
||
this.onBinaryMessage = onBinaryMessage ?? null;
|
||
this.getBinaryBufferedAmount = getBinaryBufferedAmount ?? 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.agentLoadingService =
|
||
agentLoadingService ??
|
||
new AgentLoadingService({
|
||
agentManager: this.agentManager,
|
||
agentStorage: this.agentStorage,
|
||
logger: this.sessionLogger,
|
||
});
|
||
this.createAgentMcpTransport = createAgentMcpTransport;
|
||
this.terminalManager = terminalManager;
|
||
this.providerSnapshotManager = providerSnapshotManager ?? null;
|
||
this.serviceRouteStore = serviceRouteStore ?? null;
|
||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||
this.resolveServiceStatus = resolveServiceStatus ?? null;
|
||
if (this.terminalManager) {
|
||
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
|
||
this.handleTerminalsChanged(event),
|
||
);
|
||
}
|
||
if (this.providerSnapshotManager) {
|
||
const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd?: string) => {
|
||
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.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null;
|
||
this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null);
|
||
this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler;
|
||
this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler;
|
||
this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext;
|
||
this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext;
|
||
this.ensureVoiceMcpSocketForAgent = voiceBridge?.ensureVoiceMcpSocketForAgent;
|
||
this.removeVoiceMcpSocketForAgent = voiceBridge?.removeVoiceMcpSocketForAgent;
|
||
this.getSpeechReadiness = dictation?.getSpeechReadiness;
|
||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
|
||
this.abortController = new AbortController();
|
||
this.providerRegistry = buildProviderRegistry(this.sessionLogger, {
|
||
runtimeSettings: this.agentProviderRuntimeSettings,
|
||
});
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 }>,
|
||
): AgentPromptInput {
|
||
const normalized = text?.trim() ?? "";
|
||
if (!images || images.length === 0) {
|
||
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 });
|
||
}
|
||
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 in-memory transport
|
||
*/
|
||
private async initializeAgentMcp(): Promise<void> {
|
||
try {
|
||
// Create an in-memory transport connected to the Agent MCP server
|
||
const transport = await this.createAgentMcpTransport();
|
||
|
||
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 } : {}),
|
||
} 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
|
||
? this.resolveStoredAgentPayloadUpdatedAt(storedRecord)
|
||
: null;
|
||
if (storedUpdatedAt) {
|
||
const liveUpdatedAt = Date.parse(payload.updatedAt);
|
||
const persistedUpdatedAt = Date.parse(storedUpdatedAt);
|
||
if (!Number.isNaN(persistedUpdatedAt) && (Number.isNaN(liveUpdatedAt) || persistedUpdatedAt > liveUpdatedAt)) {
|
||
payload.updatedAt = storedUpdatedAt;
|
||
}
|
||
}
|
||
payload.archivedAt = storedRecord?.archivedAt ?? null;
|
||
return payload;
|
||
}
|
||
|
||
private buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload {
|
||
const defaultCapabilities = {
|
||
supportsStreaming: false,
|
||
supportsSessionPersistence: true,
|
||
supportsDynamicModes: false,
|
||
supportsMcpServers: false,
|
||
supportsReasoningStream: false,
|
||
supportsToolInvocations: true,
|
||
} as const;
|
||
|
||
const createdAt = new Date(record.createdAt);
|
||
const updatedAt = new Date(this.resolveStoredAgentPayloadUpdatedAt(record));
|
||
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
|
||
|
||
const provider = coerceAgentProvider(this.sessionLogger, record.provider, record.id);
|
||
const runtimeInfo = record.runtimeInfo
|
||
? {
|
||
provider: coerceAgentProvider(this.sessionLogger, record.runtimeInfo.provider, record.id),
|
||
sessionId: record.runtimeInfo.sessionId,
|
||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "model")
|
||
? { model: record.runtimeInfo.model ?? null }
|
||
: {}),
|
||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "thinkingOptionId")
|
||
? { thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null }
|
||
: {}),
|
||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "modeId")
|
||
? { modeId: record.runtimeInfo.modeId ?? null }
|
||
: {}),
|
||
...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}),
|
||
}
|
||
: undefined;
|
||
return {
|
||
id: record.id,
|
||
provider,
|
||
cwd: record.cwd,
|
||
model: record.config?.model ?? null,
|
||
thinkingOptionId: record.config?.thinkingOptionId ?? null,
|
||
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
|
||
runtimeInfo,
|
||
configuredThinkingOptionId: record.config?.thinkingOptionId ?? null,
|
||
}),
|
||
...(runtimeInfo ? { runtimeInfo } : {}),
|
||
createdAt: createdAt.toISOString(),
|
||
updatedAt: updatedAt.toISOString(),
|
||
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
|
||
status: record.lastStatus,
|
||
capabilities: defaultCapabilities,
|
||
currentModeId: record.lastModeId ?? null,
|
||
availableModes: [],
|
||
pendingPermissions: [],
|
||
persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence),
|
||
lastUsage: undefined,
|
||
lastError: record.lastError ?? undefined,
|
||
title: record.title ?? record.config?.title ?? null,
|
||
requiresAttention: record.requiresAttention ?? false,
|
||
attentionReason: record.attentionReason ?? null,
|
||
attentionTimestamp: record.attentionTimestamp ?? null,
|
||
archivedAt: record.archivedAt ?? null,
|
||
labels: record.labels,
|
||
};
|
||
}
|
||
|
||
private resolveStoredAgentPayloadUpdatedAt(record: StoredAgentRecord): string {
|
||
const timestamps = [record.updatedAt, record.lastActivityAt]
|
||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||
.map((value) => ({
|
||
raw: value,
|
||
parsed: Date.parse(value),
|
||
}))
|
||
.filter((value) => !Number.isNaN(value.parsed));
|
||
|
||
if (timestamps.length === 0) {
|
||
return record.updatedAt;
|
||
}
|
||
|
||
timestamps.sort((a, b) => b.parsed - a.parsed);
|
||
return timestamps[0].raw;
|
||
}
|
||
|
||
private async ensureAgentLoaded(agentId: string): Promise<ManagedAgent> {
|
||
const existing = this.agentManager.getAgent(agentId);
|
||
if (existing) {
|
||
return existing;
|
||
}
|
||
|
||
const inflight = pendingAgentInitializations.get(agentId);
|
||
if (inflight) {
|
||
return inflight;
|
||
}
|
||
|
||
const initPromise = (async () => {
|
||
await this.requireStoredAgentRecord(agentId);
|
||
return this.agentLoadingService.ensureAgentLoaded({ agentId });
|
||
})();
|
||
|
||
pendingAgentInitializations.set(agentId, initPromise);
|
||
|
||
try {
|
||
return await initPromise;
|
||
} finally {
|
||
const current = pendingAgentInitializations.get(agentId);
|
||
if (current === initPromise) {
|
||
pendingAgentInitializations.delete(agentId);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async requireStoredAgentRecord(agentId: string): Promise<StoredAgentRecord> {
|
||
const record = await this.agentStorage.get(agentId);
|
||
if (!record) {
|
||
throw new Error(`Agent not found: ${agentId}`);
|
||
}
|
||
return record;
|
||
}
|
||
|
||
private isProviderVisibleToClient(provider: string): boolean {
|
||
if (clientSupportsAllProviders(this.appVersion)) {
|
||
return true;
|
||
}
|
||
return LEGACY_PROVIDER_IDS.has(provider);
|
||
}
|
||
|
||
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.directory === normalizedCwd) ?? null;
|
||
}
|
||
|
||
private async resolveWorkspaceDirectory(cwd: string): Promise<string> {
|
||
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
||
try {
|
||
const checkout = await getCheckoutStatusLite(normalizedCwd, {
|
||
paseoHome: this.paseoHome,
|
||
});
|
||
return normalizePersistedWorkspaceId(checkout.worktreeRoot ?? 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.id}`);
|
||
}
|
||
const checkout =
|
||
project.kind !== "git"
|
||
? {
|
||
cwd: workspace.directory,
|
||
isGit: false as const,
|
||
currentBranch: null,
|
||
remoteUrl: null,
|
||
worktreeRoot: null,
|
||
isPaseoOwnedWorktree: false as const,
|
||
mainRepoRoot: null,
|
||
}
|
||
: workspace.kind === "worktree"
|
||
? {
|
||
cwd: workspace.directory,
|
||
isGit: true as const,
|
||
currentBranch: workspace.displayName,
|
||
remoteUrl: project.gitRemote,
|
||
worktreeRoot: workspace.directory,
|
||
isPaseoOwnedWorktree: true as const,
|
||
mainRepoRoot: project.directory,
|
||
}
|
||
: {
|
||
cwd: workspace.directory,
|
||
isGit: true as const,
|
||
currentBranch: workspace.displayName,
|
||
remoteUrl: project.gitRemote,
|
||
worktreeRoot: workspace.directory,
|
||
isPaseoOwnedWorktree: false as const,
|
||
mainRepoRoot: null,
|
||
};
|
||
return {
|
||
projectKey: String(project.id),
|
||
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 "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);
|
||
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_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_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 "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 "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);
|
||
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 "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`,
|
||
);
|
||
}
|
||
|
||
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();
|
||
const normalizedStatus =
|
||
existing.lastStatus === "running" || existing.lastStatus === "initializing"
|
||
? "idle"
|
||
: existing.lastStatus;
|
||
|
||
await this.agentStorage.upsert({
|
||
...existing,
|
||
archivedAt,
|
||
updatedAt: archivedAt,
|
||
lastStatus: normalizedStatus,
|
||
requiresAttention: false,
|
||
attentionReason: null,
|
||
attentionTimestamp: null,
|
||
});
|
||
|
||
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 unarchiveAgentState(agentId: string): Promise<boolean> {
|
||
return this.agentManager.unarchiveSnapshot(agentId);
|
||
}
|
||
|
||
private async unarchiveAgentByHandle(handle: AgentPersistenceHandle): Promise<void> {
|
||
await this.agentManager.unarchiveSnapshotByHandle(handle);
|
||
}
|
||
|
||
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 cloneMcpServers(
|
||
servers: Record<string, McpServerConfig> | undefined,
|
||
): Record<string, McpServerConfig> | undefined {
|
||
if (!servers) {
|
||
return undefined;
|
||
}
|
||
return JSON.parse(JSON.stringify(servers)) as Record<string, McpServerConfig>;
|
||
}
|
||
|
||
private buildVoiceModeMcpServers(
|
||
existing: Record<string, McpServerConfig> | undefined,
|
||
socketPath: string,
|
||
): Record<string, McpServerConfig> {
|
||
const mcpStdio = this.voiceAgentMcpStdio;
|
||
if (!mcpStdio) {
|
||
throw new Error("Voice MCP stdio bridge is not configured");
|
||
}
|
||
return {
|
||
...(existing ?? {}),
|
||
[VOICE_MCP_SERVER_NAME]: buildVoiceAgentMcpServerConfig({
|
||
command: mcpStdio.command,
|
||
baseArgs: mcpStdio.baseArgs,
|
||
socketPath,
|
||
env: mcpStdio.env,
|
||
}),
|
||
};
|
||
}
|
||
|
||
private async enableVoiceModeForAgent(agentId: string): Promise<string> {
|
||
const startedAt = Date.now();
|
||
const ensureVoiceSocket = this.ensureVoiceMcpSocketForAgent;
|
||
if (!ensureVoiceSocket) {
|
||
throw new Error("Voice MCP socket bridge is not configured");
|
||
}
|
||
|
||
this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureAgentLoaded.start");
|
||
const existing = await this.ensureAgentLoaded(agentId);
|
||
this.sessionLogger.info(
|
||
{ agentId, elapsedMs: Date.now() - startedAt },
|
||
"enableVoiceModeForAgent.ensureAgentLoaded.done",
|
||
);
|
||
|
||
this.sessionLogger.info({ agentId }, "enableVoiceModeForAgent.ensureVoiceSocket.start");
|
||
const socketPath = await ensureVoiceSocket(agentId);
|
||
this.sessionLogger.info(
|
||
{ agentId, socketPath, elapsedMs: Date.now() - startedAt },
|
||
"enableVoiceModeForAgent.ensureVoiceSocket.done",
|
||
);
|
||
this.registerVoiceBridgeForAgent(agentId);
|
||
|
||
const baseConfig: VoiceModeBaseConfig = {
|
||
systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt),
|
||
mcpServers: this.cloneMcpServers(existing.config.mcpServers),
|
||
};
|
||
this.voiceModeBaseConfig = baseConfig;
|
||
const refreshOverrides: Partial<AgentSessionConfig> = {
|
||
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true),
|
||
mcpServers: this.buildVoiceModeMcpServers(baseConfig.mcpServers, socketPath),
|
||
};
|
||
|
||
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);
|
||
await this.removeVoiceMcpSocketForAgent?.(agentId).catch(() => undefined);
|
||
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);
|
||
await this.removeVoiceMcpSocketForAgent?.(agentId).catch((error) => {
|
||
this.sessionLogger.warn(
|
||
{ err: error, agentId },
|
||
"Failed to remove voice MCP socket bridge on disable",
|
||
);
|
||
});
|
||
|
||
if (restoreAgentConfig && this.voiceModeBaseConfig) {
|
||
const baseConfig = this.voiceModeBaseConfig;
|
||
try {
|
||
await this.agentManager.reloadAgentSession(agentId, {
|
||
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false),
|
||
mcpServers: this.cloneMcpServers(baseConfig.mcpServers),
|
||
});
|
||
} 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 }>,
|
||
runOptions?: AgentRunOptions,
|
||
): Promise<void> {
|
||
this.sessionLogger.info(
|
||
{ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 },
|
||
`Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ""}`,
|
||
);
|
||
|
||
await this.unarchiveAgentState(agentId);
|
||
|
||
try {
|
||
await this.ensureAgentLoaded(agentId);
|
||
} catch (error) {
|
||
this.handleAgentRunError(agentId, error, "Failed to initialize agent before sending prompt");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
this.agentManager.recordUserMessage(agentId, text, {
|
||
messageId,
|
||
emitState: false,
|
||
});
|
||
} catch (error) {
|
||
this.sessionLogger.error(
|
||
{ err: error, agentId },
|
||
`Failed to record user message for agent ${agentId}`,
|
||
);
|
||
}
|
||
|
||
const prompt = this.buildAgentPrompt(text, images);
|
||
|
||
this.startAgentStream(agentId, prompt, runOptions);
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
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,
|
||
labels,
|
||
);
|
||
const resolvedWorkspace =
|
||
typeof msg.workspaceId === "number"
|
||
? 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.directory,
|
||
},
|
||
undefined,
|
||
{
|
||
labels,
|
||
workspaceId: resolvedWorkspace.id,
|
||
initialPrompt: trimmedPrompt,
|
||
},
|
||
);
|
||
await this.forwardAgentUpdate(snapshot);
|
||
|
||
if (requestId) {
|
||
const agentPayload = await this.getAgentPayloadById(snapshot.id);
|
||
if (!agentPayload) {
|
||
throw new Error(`Agent ${snapshot.id} not found after creation`);
|
||
}
|
||
this.emit({
|
||
type: "status",
|
||
payload: {
|
||
status: "agent_created",
|
||
agentId: snapshot.id,
|
||
requestId,
|
||
agent: agentPayload,
|
||
},
|
||
});
|
||
}
|
||
|
||
if (trimmedPrompt) {
|
||
scheduleAgentMetadataGeneration({
|
||
agentManager: this.agentManager,
|
||
agentId: snapshot.id,
|
||
cwd: snapshot.cwd,
|
||
initialPrompt: trimmedPrompt,
|
||
explicitTitle,
|
||
paseoHome: this.paseoHome,
|
||
logger: this.sessionLogger,
|
||
});
|
||
|
||
void this.handleSendAgentMessage(
|
||
snapshot.id,
|
||
trimmedPrompt,
|
||
resolveClientMessageId(clientMessageId),
|
||
images,
|
||
outputSchema ? { outputSchema } : undefined,
|
||
).catch((promptError) => {
|
||
this.sessionLogger.error(
|
||
{ err: promptError, agentId: snapshot.id },
|
||
`Failed to run initial prompt for agent ${snapshot.id}`,
|
||
);
|
||
this.emit({
|
||
type: "activity_log",
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: "error",
|
||
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
if (worktreeBootstrap) {
|
||
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,
|
||
}),
|
||
serviceRouteStore: this.serviceRouteStore ?? undefined,
|
||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||
logger: this.sessionLogger,
|
||
});
|
||
}
|
||
|
||
this.sessionLogger.info(
|
||
{ agentId: snapshot.id, provider: snapshot.provider },
|
||
`Created agent ${snapshot.id} (${snapshot.provider})`,
|
||
);
|
||
} catch (error: any) {
|
||
this.sessionLogger.error({ err: error }, "Failed to create agent");
|
||
if (requestId) {
|
||
this.emit({
|
||
type: "status",
|
||
payload: {
|
||
status: "agent_create_failed",
|
||
requestId,
|
||
error: (error as Error)?.message ?? String(error),
|
||
},
|
||
});
|
||
}
|
||
this.emit({
|
||
type: "activity_log",
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: "error",
|
||
content: `Failed to create agent: ${error.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.agentLoadingService.resumeAgent({
|
||
handle,
|
||
overrides,
|
||
});
|
||
await this.unarchiveAgentState(snapshot.id);
|
||
await this.forwardAgentUpdate(snapshot);
|
||
const timelineSize = this.agentManager.getTimeline(snapshot.id).length;
|
||
if (requestId) {
|
||
const agentPayload = await this.getAgentPayloadById(snapshot.id);
|
||
if (!agentPayload) {
|
||
throw new Error(`Agent ${snapshot.id} not found after resume`);
|
||
}
|
||
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 this.unarchiveAgentState(agentId);
|
||
if (this.agentManager.getAgent(agentId)) {
|
||
await this.interruptAgentIfRunning(agentId);
|
||
}
|
||
const snapshot = await this.agentLoadingService.refreshAgent({ 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): Promise<void> {
|
||
this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`);
|
||
|
||
try {
|
||
await this.interruptAgentIfRunning(agentId);
|
||
} catch (error) {
|
||
this.handleAgentRunError(agentId, error, "Failed to cancel running agent on request");
|
||
}
|
||
}
|
||
|
||
private async buildAgentSessionConfig(
|
||
config: AgentSessionConfig,
|
||
gitOptions?: GitSetupOptions,
|
||
legacyWorktreeName?: string,
|
||
_labels?: Record<string, string>,
|
||
): Promise<{
|
||
sessionConfig: AgentSessionConfig;
|
||
worktreeBootstrap?: { worktree: WorktreeConfig; shouldBootstrap: boolean };
|
||
}> {
|
||
return buildWorktreeAgentSessionConfig(
|
||
{
|
||
paseoHome: this.paseoHome,
|
||
sessionLogger: this.sessionLogger,
|
||
checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch),
|
||
createBranchFromBase: (params) => this.createBranchFromBase(params),
|
||
},
|
||
config,
|
||
gitOptions,
|
||
legacyWorktreeName,
|
||
_labels,
|
||
);
|
||
}
|
||
|
||
private async handleListProviderModelsRequest(
|
||
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>,
|
||
): Promise<void> {
|
||
const fetchedAt = new Date().toISOString();
|
||
try {
|
||
const models = await this.providerRegistry[msg.provider].fetchModels({
|
||
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
|
||
});
|
||
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,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
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> {
|
||
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> {
|
||
this.providerSnapshotManager?.refresh(msg.cwd ? expandTilde(msg.cwd) : undefined);
|
||
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 execFileAsync("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 execFileAsync("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 execFileAsync("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 execFileAsync("git", ["checkout", "-b", newBranchName, baseBranch], {
|
||
cwd,
|
||
});
|
||
}
|
||
|
||
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
|
||
this.assertSafeGitRef(branch, "branch");
|
||
try {
|
||
await execFileAsync("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[]): Promise<void> {
|
||
const agentIds = Array.isArray(agentId) ? agentId : [agentId];
|
||
|
||
try {
|
||
await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id)));
|
||
} 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 {
|
||
await this.agentManager.respondToPermission(agentId, requestId, response);
|
||
this.sessionLogger.debug({ agentId }, `Permission response forwarded to agent ${agentId}`);
|
||
} 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 execFileAsync("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 execFileAsync("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 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 async resolveWorkspaceGitRefsRoot(gitDir: string): Promise<string> {
|
||
try {
|
||
const commonDir = (await readFile(join(gitDir, "commondir"), "utf8")).trim();
|
||
if (commonDir.length > 0) {
|
||
return resolve(gitDir, commonDir);
|
||
}
|
||
} catch {
|
||
// Regular repos do not have a commondir file.
|
||
}
|
||
return gitDir;
|
||
}
|
||
|
||
private closeWorkspaceGitWatchTarget(target: WorkspaceGitWatchTarget): void {
|
||
if (target.debounceTimer) {
|
||
clearTimeout(target.debounceTimer);
|
||
target.debounceTimer = null;
|
||
}
|
||
for (const watcher of target.watchers) {
|
||
watcher.close();
|
||
}
|
||
target.watchers = [];
|
||
}
|
||
|
||
private removeWorkspaceGitWatchTarget(cwd: string): void {
|
||
const workspaceId = normalizePersistedWorkspaceId(cwd);
|
||
const target = this.workspaceGitWatchTargets.get(workspaceId);
|
||
if (!target) {
|
||
return;
|
||
}
|
||
this.closeWorkspaceGitWatchTarget(target);
|
||
this.workspaceGitWatchTargets.delete(workspaceId);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
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.directory, {
|
||
isGit: workspace.projectKind === "git",
|
||
});
|
||
this.rememberWorkspaceGitWatchFingerprint(persistedWorkspace.directory, workspace);
|
||
}
|
||
}
|
||
|
||
private scheduleWorkspaceGitWatchRefresh(target: WorkspaceGitWatchTarget): void {
|
||
if (target.debounceTimer) {
|
||
clearTimeout(target.debounceTimer);
|
||
}
|
||
target.debounceTimer = setTimeout(() => {
|
||
target.debounceTimer = null;
|
||
void this.refreshWorkspaceGitWatchTarget(target);
|
||
}, WORKSPACE_GIT_WATCH_DEBOUNCE_MS);
|
||
}
|
||
|
||
private async refreshWorkspaceGitWatchTarget(target: WorkspaceGitWatchTarget): Promise<void> {
|
||
if (target.refreshPromise) {
|
||
target.refreshQueued = true;
|
||
return;
|
||
}
|
||
|
||
target.refreshPromise = (async () => {
|
||
do {
|
||
target.refreshQueued = false;
|
||
await this.emitWorkspaceUpdateForCwd(target.cwd, {
|
||
dedupeGitState: true,
|
||
});
|
||
} while (target.refreshQueued);
|
||
})();
|
||
|
||
try {
|
||
await target.refreshPromise;
|
||
} finally {
|
||
target.refreshPromise = null;
|
||
}
|
||
}
|
||
|
||
private async ensureWorkspaceGitWatchTarget(cwd: string): Promise<void> {
|
||
const workspaceId = normalizePersistedWorkspaceId(cwd);
|
||
if (this.workspaceGitWatchTargets.has(workspaceId)) {
|
||
return;
|
||
}
|
||
|
||
const gitDir = await resolveCheckoutGitDir(cwd);
|
||
if (!gitDir) {
|
||
return;
|
||
}
|
||
|
||
const refsRoot = await this.resolveWorkspaceGitRefsRoot(gitDir);
|
||
const target: WorkspaceGitWatchTarget = {
|
||
cwd: workspaceId,
|
||
watchers: [],
|
||
debounceTimer: null,
|
||
refreshPromise: null,
|
||
refreshQueued: false,
|
||
latestFingerprint: null,
|
||
};
|
||
|
||
for (const watchPath of new Set([join(gitDir, "HEAD"), join(refsRoot, "refs", "heads")])) {
|
||
let watcher: FSWatcher | null = null;
|
||
try {
|
||
watcher = watch(watchPath, { recursive: false }, () => {
|
||
this.scheduleWorkspaceGitWatchRefresh(target);
|
||
});
|
||
} catch (error) {
|
||
this.sessionLogger.warn(
|
||
{ err: error, cwd, watchPath },
|
||
"Failed to start workspace git watcher",
|
||
);
|
||
}
|
||
|
||
if (!watcher) {
|
||
continue;
|
||
}
|
||
|
||
watcher.on("error", (error) => {
|
||
this.sessionLogger.warn({ err: error, cwd, watchPath }, "Workspace git watcher error");
|
||
});
|
||
target.watchers.push(watcher);
|
||
}
|
||
|
||
if (target.watchers.length === 0) {
|
||
return;
|
||
}
|
||
|
||
this.workspaceGitWatchTargets.set(workspaceId, target);
|
||
}
|
||
|
||
private async syncWorkspaceGitWatchTarget(
|
||
cwd: string,
|
||
options: { isGit: boolean },
|
||
): Promise<void> {
|
||
if (!options.isGit) {
|
||
this.removeWorkspaceGitWatchTarget(cwd);
|
||
return;
|
||
}
|
||
|
||
await this.ensureWorkspaceGitWatchTarget(cwd);
|
||
}
|
||
|
||
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 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 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.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 prStatus = await getPullRequestStatus(cwd);
|
||
this.emit({
|
||
type: "checkout_pr_status_response",
|
||
payload: {
|
||
cwd,
|
||
status: prStatus.status,
|
||
githubFeaturesEnabled: prStatus.githubFeaturesEnabled,
|
||
error: 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.id);
|
||
}
|
||
},
|
||
emit: (message) => this.emit(message),
|
||
emitWorkspaceUpdatesForCwds: (cwds) => this.emitWorkspaceUpdatesForCwds(cwds),
|
||
isPathWithinRoot: (rootPath, candidatePath) =>
|
||
this.isPathWithinRoot(rootPath, candidatePath),
|
||
killTerminalsUnderPath: (rootPath) => this.killTerminalsUnderPath(rootPath),
|
||
},
|
||
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" || agent.status === "initializing") {
|
||
return "running";
|
||
}
|
||
if (agent.requiresAttention) {
|
||
return "attention";
|
||
}
|
||
return "done";
|
||
}
|
||
|
||
private accumulateLatestActivityAt(
|
||
current: string | null,
|
||
agent: AgentSnapshotPayload,
|
||
): string | null {
|
||
const candidateRaw = agent.lastUserMessageAt ?? agent.updatedAt;
|
||
const candidateMs = Date.parse(candidateRaw);
|
||
if (Number.isNaN(candidateMs)) {
|
||
return current;
|
||
}
|
||
if (!current) {
|
||
return new Date(candidateMs).toISOString();
|
||
}
|
||
const currentMs = Date.parse(current);
|
||
if (Number.isNaN(currentMs) || candidateMs > currentMs) {
|
||
return new Date(candidateMs).toISOString();
|
||
}
|
||
return current;
|
||
}
|
||
|
||
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;
|
||
try {
|
||
diffStat = await getCheckoutShortstat(workspace.directory);
|
||
} catch {
|
||
// Non-critical — leave null on failure.
|
||
}
|
||
|
||
return {
|
||
id: workspace.id,
|
||
projectId: workspace.projectId,
|
||
projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId),
|
||
projectRootPath: resolvedProjectRecord?.directory ?? workspace.directory,
|
||
workspaceDirectory: workspace.directory,
|
||
projectKind: resolvedProjectRecord?.kind ?? "directory",
|
||
workspaceKind: workspace.kind,
|
||
name: workspace.displayName,
|
||
status: "done",
|
||
activityAt: null,
|
||
diffStat,
|
||
services: this.serviceRouteStore
|
||
? buildWorkspaceServicePayloads(
|
||
this.serviceRouteStore,
|
||
workspace.directory,
|
||
this.getDaemonTcpPort?.() ?? null,
|
||
this.resolveServiceStatus ?? undefined,
|
||
)
|
||
: [],
|
||
};
|
||
}
|
||
|
||
private async listWorkspaceDescriptorsSnapshot(): Promise<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.id, project] as const),
|
||
);
|
||
const descriptorsByWorkspaceId = new Map<number, WorkspaceDescriptorPayload>();
|
||
const workspaceIdsByDirectory = new Map(activeRecords.map((workspace) => [workspace.directory, workspace.id]));
|
||
|
||
for (const workspace of activeRecords) {
|
||
descriptorsByWorkspaceId.set(
|
||
workspace.id,
|
||
await this.describeWorkspaceRecord(
|
||
workspace,
|
||
activeProjects.get(workspace.projectId) ?? null,
|
||
),
|
||
);
|
||
}
|
||
|
||
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;
|
||
}
|
||
existing.activityAt = this.accumulateLatestActivityAt(existing.activityAt, agent);
|
||
}
|
||
|
||
return Array.from(descriptorsByWorkspaceId.values());
|
||
}
|
||
|
||
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
||
return this.listWorkspaceDescriptorsSnapshot();
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
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 - 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 !== "number") {
|
||
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: 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 - cursor.id;
|
||
}
|
||
|
||
private matchesWorkspaceFilter(input: {
|
||
workspace: WorkspaceDescriptorPayload;
|
||
filter: FetchWorkspacesRequestFilter | undefined;
|
||
}): boolean {
|
||
const { workspace, filter } = input;
|
||
if (!filter) {
|
||
return true;
|
||
}
|
||
|
||
if (typeof filter.projectId === "number") {
|
||
if (workspace.projectId !== filter.projectId) {
|
||
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();
|
||
entries = entries.filter((workspace) => this.matchesWorkspaceFilter({ workspace, filter }));
|
||
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;
|
||
|
||
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;
|
||
}
|
||
this.emit({
|
||
type: "workspace_update",
|
||
payload,
|
||
});
|
||
}
|
||
|
||
private flushBootstrappedWorkspaceUpdates(options?: {
|
||
snapshotLatestActivityByWorkspaceId?: Map<number, 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 existingWorkspace;
|
||
}
|
||
|
||
const timestamp = new Date().toISOString();
|
||
const directoryName = normalizedCwd.split(/[\\/]/).filter(Boolean).at(-1) ?? normalizedCwd;
|
||
const gitMetadata = detectWorkspaceGitMetadata(normalizedCwd, directoryName);
|
||
|
||
let projectId: number | null = null;
|
||
if (gitMetadata.gitRemote) {
|
||
const existingProjects = await this.projectRegistry.list();
|
||
const matchingProject = existingProjects.find(
|
||
(p) => p.gitRemote === gitMetadata.gitRemote && !p.archivedAt,
|
||
);
|
||
if (matchingProject) {
|
||
projectId = matchingProject.id;
|
||
}
|
||
}
|
||
|
||
if (projectId === null) {
|
||
projectId = await this.projectRegistry.insert({
|
||
directory: normalizedCwd,
|
||
displayName: gitMetadata.projectDisplayName,
|
||
kind: gitMetadata.projectKind,
|
||
gitRemote: gitMetadata.gitRemote,
|
||
createdAt: timestamp,
|
||
updatedAt: timestamp,
|
||
archivedAt: null,
|
||
});
|
||
}
|
||
|
||
const workspaceId = await this.workspaceRegistry.insert({
|
||
projectId,
|
||
directory: normalizedCwd,
|
||
displayName: gitMetadata.workspaceDisplayName,
|
||
kind: gitMetadata.isWorktree ? "worktree" : "checkout",
|
||
createdAt: timestamp,
|
||
updatedAt: timestamp,
|
||
archivedAt: null,
|
||
});
|
||
return (await this.workspaceRegistry.get(workspaceId))!;
|
||
}
|
||
|
||
private async registerPendingWorktreeWorkspace(options: {
|
||
repoRoot: string;
|
||
worktreePath: string;
|
||
branchName: string;
|
||
}): Promise<PersistedWorkspaceRecord> {
|
||
await this.findOrCreateWorkspaceForDirectory(options.repoRoot);
|
||
const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath);
|
||
const basePlacement = await this.buildProjectPlacementForCwd(options.repoRoot);
|
||
if (!basePlacement) {
|
||
throw new Error(`Workspace not found for repo root ${options.repoRoot}`);
|
||
}
|
||
|
||
const projectId = Number(basePlacement.projectKey);
|
||
if (!Number.isInteger(projectId)) {
|
||
throw new Error(`Invalid project id for repo root ${options.repoRoot}`);
|
||
}
|
||
|
||
const now = new Date().toISOString();
|
||
const existingWorkspace = await this.findWorkspaceByDirectory(workspaceDirectory);
|
||
if (!existingWorkspace) {
|
||
const workspaceId = await this.workspaceRegistry.insert({
|
||
projectId,
|
||
directory: workspaceDirectory,
|
||
displayName: options.branchName,
|
||
kind: "worktree",
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
archivedAt: null,
|
||
});
|
||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||
if (!workspace) {
|
||
throw new Error(`Workspace not found after insert: ${workspaceId}`);
|
||
}
|
||
await this.syncWorkspaceGitWatchTarget(workspace.directory, { isGit: true });
|
||
return workspace;
|
||
}
|
||
|
||
await this.workspaceRegistry.upsert({
|
||
id: existingWorkspace.id,
|
||
projectId,
|
||
directory: workspaceDirectory,
|
||
displayName: options.branchName,
|
||
kind: "worktree",
|
||
createdAt: existingWorkspace.createdAt,
|
||
updatedAt: now,
|
||
archivedAt: null,
|
||
});
|
||
await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
|
||
|
||
if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) {
|
||
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||
(workspace) =>
|
||
workspace.projectId === existingWorkspace.projectId &&
|
||
workspace.id !== existingWorkspace.id &&
|
||
!workspace.archivedAt,
|
||
);
|
||
if (siblingWorkspaces.length === 0) {
|
||
await this.projectRegistry.archive(existingWorkspace.projectId, now);
|
||
}
|
||
}
|
||
|
||
return (await this.workspaceRegistry.get(existingWorkspace.id))!;
|
||
}
|
||
|
||
private async archiveWorkspaceRecord(workspaceId: number, archivedAt?: string): Promise<void> {
|
||
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
|
||
if (!existingWorkspace || existingWorkspace.archivedAt) {
|
||
return;
|
||
}
|
||
|
||
const nextArchivedAt = archivedAt ?? new Date().toISOString();
|
||
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
|
||
await this.removeWorkspaceGitWatchTarget(existingWorkspace.directory);
|
||
|
||
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 emitWorkspaceUpdateForCwd(
|
||
cwd: string,
|
||
options?: { dedupeGitState?: boolean },
|
||
): Promise<void> {
|
||
const subscription = this.workspaceUpdatesSubscription;
|
||
if (!subscription) {
|
||
return;
|
||
}
|
||
|
||
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
|
||
const persistedWorkspace = await this.findWorkspaceByDirectory(normalizedCwd);
|
||
const all = await this.listWorkspaceDescriptorsSnapshot();
|
||
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
|
||
const workspaceIdsToEmit = persistedWorkspace ? [persistedWorkspace.id] : [];
|
||
|
||
for (const nextWorkspaceId of workspaceIdsToEmit) {
|
||
const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId);
|
||
const nextWorkspace =
|
||
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
|
||
? workspace
|
||
: null;
|
||
if (
|
||
options?.dedupeGitState &&
|
||
this.shouldSkipWorkspaceGitWatchUpdate(normalizedCwd, nextWorkspace)
|
||
) {
|
||
continue;
|
||
}
|
||
this.rememberWorkspaceGitWatchFingerprint(normalizedCwd, nextWorkspace);
|
||
|
||
if (!nextWorkspace) {
|
||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||
kind: "remove",
|
||
id: nextWorkspaceId,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||
kind: "upsert",
|
||
workspace: nextWorkspace,
|
||
});
|
||
}
|
||
}
|
||
|
||
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
|
||
if (!this.workspaceUpdatesSubscription) {
|
||
return;
|
||
}
|
||
|
||
const uniqueWorkspaceCwds = new Set<string>();
|
||
for (const cwd of cwds) {
|
||
const normalized = await this.resolveWorkspaceDirectory(cwd);
|
||
if (!normalized) {
|
||
continue;
|
||
}
|
||
uniqueWorkspaceCwds.add(normalized);
|
||
}
|
||
|
||
const subscription = this.workspaceUpdatesSubscription;
|
||
const all = await this.listWorkspaceDescriptorsSnapshot();
|
||
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const));
|
||
|
||
for (const workspaceCwd of uniqueWorkspaceCwds) {
|
||
const persistedWorkspace = await this.findWorkspaceByDirectory(workspaceCwd);
|
||
const workspace = persistedWorkspace ? descriptorsByWorkspaceId.get(persistedWorkspace.id) : null;
|
||
const nextWorkspace =
|
||
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
|
||
? workspace
|
||
: null;
|
||
this.rememberWorkspaceGitWatchFingerprint(workspaceCwd, nextWorkspace);
|
||
|
||
if (!nextWorkspace) {
|
||
if (persistedWorkspace) {
|
||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||
kind: "remove",
|
||
id: persistedWorkspace.id,
|
||
});
|
||
}
|
||
continue;
|
||
}
|
||
|
||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||
kind: "upsert",
|
||
workspace: nextWorkspace,
|
||
});
|
||
}
|
||
}
|
||
|
||
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 {
|
||
if (subscriptionId) {
|
||
this.workspaceUpdatesSubscription = {
|
||
subscriptionId,
|
||
filter: request.filter,
|
||
isBootstrapping: true,
|
||
pendingUpdatesByWorkspaceId: new Map(),
|
||
};
|
||
}
|
||
|
||
const payload = await this.listFetchWorkspacesEntries(request);
|
||
await this.primeWorkspaceGitWatchFingerprints(payload.entries);
|
||
const snapshotLatestActivityByWorkspaceId = new Map<number, 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 });
|
||
}
|
||
} 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.directory);
|
||
const descriptor = await this.describeWorkspaceRecord(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 async handleCreatePaseoWorktreeRequest(
|
||
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
||
): Promise<void> {
|
||
return handleCreateWorktreeRequest(
|
||
{
|
||
paseoHome: this.paseoHome,
|
||
describeWorkspaceRecord: (workspace) => this.describeWorkspaceRecord(workspace),
|
||
emit: (message) => this.emit(message),
|
||
registerPendingWorktreeWorkspace: (options) =>
|
||
this.registerPendingWorktreeWorkspace(options),
|
||
sessionLogger: this.sessionLogger,
|
||
createPaseoWorktreeInBackground: (options) => this.createPaseoWorktreeInBackground(options),
|
||
},
|
||
request,
|
||
);
|
||
}
|
||
|
||
private async createPaseoWorktreeInBackground(options: {
|
||
requestCwd: string;
|
||
repoRoot: string;
|
||
workspaceId: number;
|
||
worktree: { branchName: string; worktreePath: string };
|
||
shouldBootstrap: boolean;
|
||
}): Promise<void> {
|
||
return createWorktreeInBackgroundSession(
|
||
{
|
||
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),
|
||
serviceRouteStore: this.serviceRouteStore,
|
||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||
},
|
||
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(request.workspaceId, archivedAt);
|
||
await this.emitWorkspaceUpdateForCwd(existing.directory);
|
||
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 requestedLimit = msg.limit;
|
||
const limit = requestedLimit ?? (direction === "after" ? 0 : undefined);
|
||
const cursor: AgentTimelineCursor | undefined = msg.cursor
|
||
? {
|
||
seq: msg.cursor.seq,
|
||
}
|
||
: undefined;
|
||
|
||
try {
|
||
const snapshot = await this.ensureAgentLoaded(msg.agentId);
|
||
const agentPayload = await this.buildAgentPayload(snapshot);
|
||
const timeline = await this.agentManager.fetchTimeline(msg.agentId, {
|
||
direction,
|
||
cursor,
|
||
limit,
|
||
});
|
||
const firstRow = timeline.rows[0];
|
||
const lastRow = timeline.rows[timeline.rows.length - 1];
|
||
const entries = timeline.rows.map((row) => ({
|
||
provider: snapshot.provider,
|
||
item: row.item,
|
||
timestamp: row.timestamp,
|
||
seq: row.seq,
|
||
}));
|
||
|
||
this.emit({
|
||
type: "fetch_agent_timeline_response",
|
||
payload: {
|
||
requestId: msg.requestId,
|
||
agentId: msg.agentId,
|
||
agent: agentPayload,
|
||
direction,
|
||
startSeq: firstRow?.seq ?? null,
|
||
endSeq: lastRow?.seq ?? null,
|
||
hasOlder: timeline.hasOlder,
|
||
hasNewer: timeline.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,
|
||
startSeq: null,
|
||
endSeq: 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;
|
||
await this.unarchiveAgentState(agentId);
|
||
|
||
await this.ensureAgentLoaded(agentId);
|
||
|
||
this.sessionLogger.trace(
|
||
{ agentId, messageId: msg.messageId, textPrefix: msg.text.slice(0, 80) },
|
||
"send_agent_message_request: recording user message",
|
||
);
|
||
try {
|
||
this.agentManager.recordUserMessage(agentId, msg.text, {
|
||
messageId: msg.messageId,
|
||
emitState: false,
|
||
});
|
||
} catch (error) {
|
||
this.sessionLogger.error(
|
||
{ err: error, agentId },
|
||
"Failed to record user message for send_agent_message_request",
|
||
);
|
||
}
|
||
|
||
const prompt = this.buildAgentPrompt(msg.text, msg.images);
|
||
this.sessionLogger.trace(
|
||
{ agentId, messageId: msg.messageId },
|
||
"send_agent_message_request: starting agent stream",
|
||
);
|
||
const started = this.startAgentStream(agentId, prompt);
|
||
if (!started.ok) {
|
||
this.emit({
|
||
type: "send_agent_message_response",
|
||
payload: {
|
||
requestId: msg.requestId,
|
||
agentId,
|
||
accepted: false,
|
||
error: started.error,
|
||
},
|
||
});
|
||
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";
|
||
this.emit({
|
||
type: "wait_for_finish_response",
|
||
payload: { requestId, status, final, error: null, 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,
|
||
});
|
||
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";
|
||
|
||
this.emit({
|
||
type: "wait_for_finish_response",
|
||
payload: { requestId, status, final, error: null, 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;
|
||
}
|
||
|
||
// Route voice utterances through the same send path as regular text input:
|
||
// interrupt-if-running, record message, then start a new stream.
|
||
await this.handleSendAgentMessage(agentId, result.text);
|
||
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;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
if (this.unsubscribeProviderSnapshotEvents) {
|
||
this.unsubscribeProviderSnapshotEvents();
|
||
this.unsubscribeProviderSnapshotEvents = 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 target of this.workspaceGitWatchTargets.values()) {
|
||
this.closeWorkspaceGitWatchTarget(target);
|
||
}
|
||
this.workspaceGitWatchTargets.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: (agentId, text) => 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),
|
||
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,
|
||
snapshotRetryTimer: null,
|
||
};
|
||
|
||
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;
|
||
}
|
||
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
|
||
this.markAllActiveTerminalStreamsForSnapshot();
|
||
return;
|
||
}
|
||
this.emitBinary(
|
||
encodeTerminalStreamFrame({
|
||
opcode: TerminalStreamOpcode.Output,
|
||
slot,
|
||
payload: new Uint8Array(Buffer.from(message.data, "utf8")),
|
||
}),
|
||
);
|
||
if (this.getCurrentBinaryBufferedAmount() >= TERMINAL_STREAM_HIGH_WATER_BYTES) {
|
||
this.markAllActiveTerminalStreamsForSnapshot();
|
||
}
|
||
});
|
||
return slot;
|
||
}
|
||
|
||
private trySendTerminalSnapshot(activeStream: ActiveTerminalStream): void {
|
||
if (
|
||
this.activeTerminalStreams.get(activeStream.slot) !== activeStream ||
|
||
!activeStream.needsSnapshot
|
||
) {
|
||
return;
|
||
}
|
||
|
||
if (this.getCurrentBinaryBufferedAmount() > TERMINAL_STREAM_LOW_WATER_BYTES) {
|
||
if (!activeStream.snapshotRetryTimer) {
|
||
activeStream.snapshotRetryTimer = setTimeout(() => {
|
||
activeStream.snapshotRetryTimer = null;
|
||
this.trySendTerminalSnapshot(activeStream);
|
||
}, 33);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (activeStream.snapshotRetryTimer) {
|
||
clearTimeout(activeStream.snapshotRetryTimer);
|
||
activeStream.snapshotRetryTimer = null;
|
||
}
|
||
|
||
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 markAllActiveTerminalStreamsForSnapshot(): void {
|
||
for (const activeStream of this.activeTerminalStreams.values()) {
|
||
activeStream.needsSnapshot = true;
|
||
this.trySendTerminalSnapshot(activeStream);
|
||
}
|
||
}
|
||
|
||
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);
|
||
if (activeStream.snapshotRetryTimer) {
|
||
clearTimeout(activeStream.snapshotRetryTimer);
|
||
activeStream.snapshotRetryTimer = null;
|
||
}
|
||
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 });
|
||
}
|
||
}
|
||
|
||
private getCurrentBinaryBufferedAmount(): number {
|
||
const bufferedAmount = this.getBinaryBufferedAmount?.() ?? 0;
|
||
if (!Number.isFinite(bufferedAmount) || bufferedAmount < 0) {
|
||
return 0;
|
||
}
|
||
return Math.floor(bufferedAmount);
|
||
}
|
||
}
|