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