* fix: resume collected agents before pane actions * fix(server): keep archived agents closed during pane actions An archive could win while a collected agent was resuming, then lose when the provider runtime registered. Recheck persisted archive state after registration and close the resumed runtime before any mutation runs. * fix(server): fence archived agents after shared resume Protected pane actions could join a resume started by an ordinary loader and skip the archive fence. Recheck persisted lifecycle state for every protected caller so archive always wins before mutation.
6363 lines
217 KiB
TypeScript
6363 lines
217 KiB
TypeScript
import equal from "fast-deep-equal";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
|
import { resolve, sep } from "path";
|
|
import { homedir } from "node:os";
|
|
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
|
import {
|
|
serializeAgentStreamEvent,
|
|
type AgentSnapshotPayload,
|
|
type AgentAttachment,
|
|
type FirstAgentContext,
|
|
type SessionInboundMessage,
|
|
type SessionOutboundMessage,
|
|
type GitSetupOptions,
|
|
type StartWorkspaceScriptRequest,
|
|
type CloseItemsRequest,
|
|
type DirectorySuggestionsRequest,
|
|
type ProjectPlacementPayload,
|
|
type WorkspaceSetupSnapshot,
|
|
type WorkspaceDescriptorPayload,
|
|
} from "./messages.js";
|
|
import type {
|
|
TerminalManager,
|
|
TerminalWorkspaceContributionChangedEvent,
|
|
} from "../terminal/terminal-manager.js";
|
|
import { TerminalSessionController } from "../terminal/terminal-session-controller.js";
|
|
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
|
import type { BinaryFrame } from "@getpaseo/protocol/binary-frames/index";
|
|
import { CursorError } from "./pagination/cursor.js";
|
|
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
|
|
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
|
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
|
|
import {
|
|
buildConfigOverrides,
|
|
extractTimestamps,
|
|
isStoredAgentProviderAvailable,
|
|
toAgentPersistenceHandle,
|
|
} from "./persistence-hooks.js";
|
|
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent/agent-loading.js";
|
|
import {
|
|
formatSystemNotificationPrompt,
|
|
sendPromptToAgent,
|
|
waitForAgentRunStartWithTimeout,
|
|
unarchiveAgentState,
|
|
} from "./agent/agent-prompt.js";
|
|
import {
|
|
resolveCreateAgentTitles,
|
|
resolveFirstAgentPromptTitle,
|
|
} from "./agent/create-agent-title.js";
|
|
import { respondToAgentPermission } from "./agent/permission-response.js";
|
|
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.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 {
|
|
createWorkspaceScriptsService,
|
|
type WorkspaceScriptsService,
|
|
} from "./session/workspace-scripts/workspace-scripts-service.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 { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
|
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
|
|
import type { ProjectUpdate } from "./workspace-reconciliation-service.js";
|
|
import {
|
|
CLIENT_SHUTDOWN_RPC_REASON,
|
|
normalizeClientRestartRpcReason,
|
|
} from "./lifecycle-reasons.js";
|
|
|
|
import { AgentManager, AgentRunCancellationError } from "./agent/agent-manager.js";
|
|
import { ProviderSnapshotManager } 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 { resolveCreateAgentIntent, type CreateAgentIntent } from "./agent/create-agent/intent.js";
|
|
import {
|
|
archiveAgentCommand,
|
|
cancelAgentRunCommand,
|
|
closeAgentCommand,
|
|
detachAgentCommand,
|
|
setAgentModeCommand,
|
|
updateAgentCommand,
|
|
} from "./agent/lifecycle-command.js";
|
|
import {
|
|
buildStoredAgentPayload,
|
|
resolveStoredAgentPayloadUpdatedAt,
|
|
toAgentPayload,
|
|
} from "./agent/agent-projections.js";
|
|
import {
|
|
appendTimelineItemIfAgentKnown,
|
|
emitLiveTimelineItemIfAgentKnown,
|
|
} from "./agent/timeline-append.js";
|
|
import {
|
|
projectTimelineRows,
|
|
selectProjectedTimelinePage,
|
|
type TimelineProjectionEntry,
|
|
type TimelineProjectionMode,
|
|
} from "./agent/timeline-projection.js";
|
|
import { buildAgentForkContextAttachment } from "./agent/activity-curator.js";
|
|
import { buildAgentPrompt } from "./agent/prompt-attachments.js";
|
|
import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js";
|
|
import {
|
|
getAgentStreamEventTurnId,
|
|
type AgentPersistenceHandle,
|
|
type AgentPermissionResponse,
|
|
type AgentRunOptions,
|
|
type AgentSessionConfig,
|
|
} 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,
|
|
checkoutFromPersistedWorkspacePlacement,
|
|
deriveWorkspaceDisplayName,
|
|
} from "./workspace-registry-model.js";
|
|
import { resolveWorkspaceIdForPath } from "./resolve-workspace-id-for-path.js";
|
|
import {
|
|
resolveProjectDisplayName,
|
|
resolveWorkspaceDisplayName,
|
|
resolveWorkspaceName,
|
|
type PersistedProjectRecord,
|
|
type PersistedWorkspaceRecord,
|
|
type ProjectRegistry,
|
|
type WorkspaceRegistry,
|
|
} from "./workspace-registry.js";
|
|
import { wrapSpokenInput } from "./voice-config.js";
|
|
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
|
import { VoiceSession } from "./session/voice/voice-session.js";
|
|
import { CheckoutSession } from "./session/checkout/checkout-session.js";
|
|
import {
|
|
createWorkspaceGitObserverService,
|
|
type WorkspaceGitObserverService,
|
|
} from "./session/workspace-git-observer/workspace-git-observer-service.js";
|
|
import {
|
|
createAgentStructuredTextGeneration,
|
|
createGitMetadataGenerator,
|
|
} from "./session/checkout/git-metadata-generator.js";
|
|
import { ChatScheduleLoopSession } from "./session/chat/chat-schedule-loop-session.js";
|
|
import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js";
|
|
import { WorkspaceFilesSession } from "./session/files/workspace-files-session.js";
|
|
import { AgentConfigSession } from "./session/agent-config/agent-config-session.js";
|
|
import { ProjectConfigSession } from "./session/project-config/project-config-session.js";
|
|
import { DaemonSession, type DaemonRuntimeConfig } from "./session/daemon/daemon-session.js";
|
|
import type { DaemonWebSocketRuntimeDiagnosticSnapshot } from "./session/daemon/diagnostics.js";
|
|
import type { HubRelationshipManagement } from "./hub/relationship-controller.js";
|
|
import { HubExecutionController } from "./hub/execution-controller.js";
|
|
import type { HubExecutionAgents } from "./hub/daemon-executions.js";
|
|
import { DownloadTokenStore } from "./file-download/token-store.js";
|
|
import { PushTokenStore } from "./push/token-store.js";
|
|
import {
|
|
archivePersistedWorkspaceRecord,
|
|
archiveWorkspaceContents,
|
|
requireActiveWorkspaceForArchive,
|
|
} from "./workspace-archive-service.js";
|
|
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
|
import type { ServiceProxySubsystem } from "./service-proxy.js";
|
|
import { renameCurrentBranch as renameCurrentBranchDefault } from "../utils/checkout-git.js";
|
|
import {
|
|
createGitMutationService,
|
|
type GitMutationService,
|
|
} from "./session/git-mutation/git-mutation-service.js";
|
|
import {
|
|
createWorkspaceProvisioningService,
|
|
WorkspaceProvisioningError,
|
|
type WorkspaceProvisioningService,
|
|
} from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
|
import {
|
|
createWorkspaceRecoveryService,
|
|
type WorkspaceRecoveryService,
|
|
} from "./session/workspace-recovery/workspace-recovery-service.js";
|
|
import {
|
|
createAgentUpdatesService,
|
|
matchesAgentUpdatesFilter,
|
|
type AgentUpdatesService,
|
|
} from "./session/agent-updates/agent-updates-service.js";
|
|
import { expandTilde } from "../utils/path.js";
|
|
import {
|
|
searchDirectoryEntries,
|
|
WORKSPACE_SEARCH_HIDDEN_DIRECTORIES,
|
|
} from "../utils/directory-suggestions.js";
|
|
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
|
import type { Resolvable } from "./speech/provider-resolver.js";
|
|
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
|
|
import type pino from "pino";
|
|
import { FileBackedChatService } from "./chat/chat-service.js";
|
|
import { LoopService } from "./loop-service.js";
|
|
import { ScheduleService } from "./schedule/service.js";
|
|
import {
|
|
createGitHubService,
|
|
GitHubAuthenticationError,
|
|
GitHubCliMissingError,
|
|
GitHubCommandError,
|
|
type GitHubService,
|
|
} from "../services/github-service.js";
|
|
import type { ForgeService } from "../services/forge-service.js";
|
|
import type { ProviderUsageService } from "../services/quota-fetcher/service.js";
|
|
import {
|
|
summarizeFetchWorkspacesEntries,
|
|
workspaceIdsForProjects,
|
|
workspaceIdsOnCheckout,
|
|
WorkspaceDirectory,
|
|
type WorkspaceUpdatesFilter,
|
|
} from "./workspace-directory.js";
|
|
import { shouldEmitPendingBootstrapUpdate } from "./workspace-bootstrap-dedupe.js";
|
|
import {
|
|
createPaseoWorktree,
|
|
type CreatePaseoWorktreeInput,
|
|
type CreatePaseoWorktreeResult,
|
|
} from "./paseo-worktree-service.js";
|
|
import { WorkspaceAutoName } from "./workspace-auto-name.js";
|
|
import {
|
|
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 { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
|
|
import { WorktreeRequestError, toWorktreeWireError } from "./worktree-errors.js";
|
|
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
|
import {
|
|
createProjectDirectory,
|
|
ProjectDirectoryRequestError,
|
|
} from "./project-directory-service.js";
|
|
import { runGitCommand } from "../utils/run-git-command.js";
|
|
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
|
|
import { resolveWorktreeSourceCwd } from "./workspace-source.js";
|
|
|
|
// TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings.
|
|
// Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject
|
|
// the entire session message if they encounter an unknown provider.
|
|
const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]);
|
|
const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
|
|
const MIN_VERSION_EXPLICIT_WORKSPACE_RECOVERY = "0.1.105";
|
|
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 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 clientUsesLegacyWorkspaceRestore(appVersion: string | null): boolean {
|
|
return (
|
|
appVersion !== null && !isAppVersionAtLeast(appVersion, MIN_VERSION_EXPLICIT_WORKSPACE_RECOVERY)
|
|
);
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
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 AgentUpdatesFilter = FetchAgentsRequestFilter;
|
|
type CreateAgentRequestMessage = Extract<SessionInboundMessage, { type: "create_agent_request" }>;
|
|
|
|
interface ResolvedSessionCreateAgentIntent {
|
|
config: AgentSessionConfig;
|
|
intent: CreateAgentIntent;
|
|
createdDirectoryWorkspace: boolean;
|
|
}
|
|
|
|
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 WorkspaceProjectDescriptorPayload = FetchWorkspacesResponsePayload["emptyProjects"][number];
|
|
type WorkspaceGithubSearchRepositoriesResponsePayload = Extract<
|
|
SessionOutboundMessage,
|
|
{ type: "workspace.github.search_repositories.response" }
|
|
>["payload"];
|
|
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";
|
|
}
|
|
}
|
|
|
|
export interface SessionFileSystem {
|
|
isDirectory(path: string): Promise<boolean>;
|
|
}
|
|
|
|
const nodeSessionFileSystem: SessionFileSystem = {
|
|
async isDirectory(path) {
|
|
const stats = await stat(path).catch(() => null);
|
|
return stats?.isDirectory() ?? false;
|
|
},
|
|
};
|
|
|
|
// Stub types for features under development (modules not yet available)
|
|
type AgentMcpTransportFactory = () => Promise<unknown>;
|
|
|
|
export interface SessionOptions {
|
|
clientId: string;
|
|
scopes: readonly string[];
|
|
appVersion?: string | null;
|
|
clientCapabilities?: Record<string, unknown> | null;
|
|
onMessage: (msg: SessionOutboundMessage) => void;
|
|
onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void;
|
|
onBinaryMessage?: (frame: Uint8Array) => void;
|
|
getTransportBufferedAmount?: () => number | null;
|
|
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
|
|
onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
|
logger: pino.Logger;
|
|
downloadTokenStore: DownloadTokenStore;
|
|
pushTokenStore: PushTokenStore;
|
|
paseoHome: string;
|
|
worktreesRoot?: string;
|
|
agentManager: AgentManager;
|
|
agentStorage: AgentStorage;
|
|
projectRegistry: ProjectRegistry;
|
|
workspaceRegistry: WorkspaceRegistry;
|
|
filesystem?: SessionFileSystem;
|
|
chatService: FileBackedChatService;
|
|
scheduleService: ScheduleService;
|
|
loopService: LoopService;
|
|
checkoutDiffManager: CheckoutDiffManager;
|
|
github?: ForgeService;
|
|
createAgentMcpTransport?: AgentMcpTransportFactory;
|
|
// Injected so tests can substitute the git branch rename without module mocks;
|
|
// defaults to the real checkout-git implementation.
|
|
renameCurrentBranch?: typeof renameCurrentBranchDefault;
|
|
workspaceGitService: WorkspaceGitService;
|
|
workspaceAutoName: WorkspaceAutoName;
|
|
daemonConfigStore: DaemonConfigStore;
|
|
mcpBaseUrl?: string | null;
|
|
stt: Resolvable<SpeechToTextProvider | null>;
|
|
sttLanguage?: string;
|
|
tts: Resolvable<TextToSpeechProvider | null>;
|
|
terminalManager: TerminalManager | null;
|
|
providerSnapshotManager: ProviderSnapshotManager;
|
|
providerUsageService: ProviderUsageService;
|
|
hubExecutionAgents?: HubExecutionAgents;
|
|
hubRelationships?: HubRelationshipManagement;
|
|
serviceProxy?: ServiceProxySubsystem;
|
|
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
|
|
workspaceSetupSnapshots?: Map<string, WorkspaceSetupSnapshot>;
|
|
onBranchChanged?: (
|
|
workspaceId: string,
|
|
oldBranch: string | null,
|
|
newBranch: string | null,
|
|
) => void;
|
|
getDaemonTcpPort?: () => number | null;
|
|
getDaemonTcpHost?: () => string | null;
|
|
serviceProxyPublicBaseUrl?: 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?: DaemonRuntimeConfig;
|
|
getWebSocketRuntimeMetrics?: () => DaemonWebSocketRuntimeDiagnosticSnapshot | null;
|
|
}
|
|
|
|
export type SessionLifecycleIntent =
|
|
| {
|
|
type: "shutdown";
|
|
clientId: string;
|
|
requestId: string;
|
|
reason: string;
|
|
}
|
|
| {
|
|
type: "restart";
|
|
clientId: string;
|
|
requestId: string;
|
|
reason: string;
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
export function isSessionRpcAllowed(scopes: readonly string[], rpcName: string): boolean {
|
|
return scopes.some((scope) => {
|
|
if (scope === "*" || scope === rpcName) {
|
|
return true;
|
|
}
|
|
if (!scope.endsWith(".*")) {
|
|
return false;
|
|
}
|
|
return rpcName.startsWith(scope.slice(0, -1));
|
|
});
|
|
}
|
|
|
|
function sessionRequestId(message: SessionInboundMessage): string | null {
|
|
if ("requestId" in message && typeof message.requestId === "string") {
|
|
return message.requestId;
|
|
}
|
|
if (
|
|
"payload" in message &&
|
|
typeof message.payload === "object" &&
|
|
message.payload !== null &&
|
|
"requestId" in message.payload &&
|
|
typeof message.payload.requestId === "string"
|
|
) {
|
|
return message.payload.requestId;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
interface AgentTimelineProjectionSelection {
|
|
timeline: AgentTimelineFetchResult;
|
|
entries: TimelineProjectionEntry[];
|
|
startSeq: number | null;
|
|
endSeq: number | null;
|
|
hasOlder: boolean;
|
|
hasNewer: boolean;
|
|
}
|
|
|
|
type RegistryTransition = "created" | "unarchived" | "existing";
|
|
|
|
interface ArchivedRecordSnapshot {
|
|
archivedAt?: string | null;
|
|
}
|
|
|
|
function describeRegistryTransition(record: ArchivedRecordSnapshot | null): RegistryTransition {
|
|
if (!record) {
|
|
return "created";
|
|
}
|
|
return record.archivedAt ? "unarchived" : "existing";
|
|
}
|
|
|
|
/**
|
|
* 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 scopes: readonly string[];
|
|
private appVersion: string | null;
|
|
private clientCapabilities: ReadonlySet<ClientCapability>;
|
|
private readonly sessionId: string;
|
|
private readonly onMessage: (msg: SessionOutboundMessage) => void;
|
|
private readonly onMessageToSource:
|
|
| ((source: object, msg: SessionOutboundMessage) => void)
|
|
| null;
|
|
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
|
|
private readonly getTransportBufferedAmount: () => number | null;
|
|
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
|
|
private readonly onWorkspaceRecovered:
|
|
| ((workspace: PersistedWorkspaceRecord) => Promise<void>)
|
|
| null;
|
|
private readonly sessionLogger: pino.Logger;
|
|
private readonly paseoHome: string;
|
|
private readonly worktreesRoot: string | undefined;
|
|
|
|
private agentManager: AgentManager;
|
|
private readonly agentStorage: AgentStorage;
|
|
private readonly projectRegistry: ProjectRegistry;
|
|
private readonly workspaceRegistry: WorkspaceRegistry;
|
|
private readonly filesystem: SessionFileSystem;
|
|
private readonly github: ForgeService;
|
|
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
|
|
private readonly workspaceGitService: WorkspaceGitService;
|
|
private readonly workspaceAutoName: WorkspaceAutoName;
|
|
private readonly gitMutation: GitMutationService;
|
|
private readonly workspaceProvisioning: WorkspaceProvisioningService;
|
|
private readonly workspaceRecovery: WorkspaceRecoveryService;
|
|
private readonly daemonConfigStore: DaemonConfigStore;
|
|
private readonly pushTokenStore: PushTokenStore;
|
|
private unsubscribeAgentEvents: (() => void) | null = null;
|
|
private viewedTimelineAgentIds = new Set<string>();
|
|
private readonly viewedTimelineAgentIdsBySource = new Map<object, Set<string>>();
|
|
private readonly clientCapabilitiesBySource = new Map<object, ReadonlySet<ClientCapability>>();
|
|
private readonly defaultTimelineSubscriptionSource = {};
|
|
private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null;
|
|
private readonly agentUpdates: AgentUpdatesService;
|
|
private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null;
|
|
private clientActivity: {
|
|
deviceType: "web" | "mobile";
|
|
focusedAgentId: string | null;
|
|
focusedTerminalId: string | null;
|
|
lastActivityAt: Date;
|
|
appVisible: boolean;
|
|
appVisibilityChangedAt: Date;
|
|
} | null = null;
|
|
private readonly terminalManager: TerminalManager | null;
|
|
private readonly providerSnapshotManager: ProviderSnapshotManager;
|
|
private readonly serviceProxy: ServiceProxySubsystem | null;
|
|
private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null;
|
|
private readonly getDaemonTcpPort: (() => number | null) | null;
|
|
private readonly getDaemonTcpHost: (() => string | null) | null;
|
|
private readonly serviceProxyPublicBaseUrl: string | null;
|
|
private readonly resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null;
|
|
private readonly terminalController: TerminalSessionController;
|
|
private inflightRequests = 0;
|
|
private peakInflightRequests = 0;
|
|
private readonly workspaceSetupSnapshots: Map<string, WorkspaceSetupSnapshot>;
|
|
private readonly workspaceGitObserver: WorkspaceGitObserverService;
|
|
private readonly workspaceDirectory: WorkspaceDirectory;
|
|
private readonly voiceSession: VoiceSession;
|
|
private readonly checkoutSession: CheckoutSession;
|
|
private readonly chatScheduleLoopSession: ChatScheduleLoopSession;
|
|
private readonly providerCatalogSession: ProviderCatalogSession;
|
|
private readonly workspaceFilesSession: WorkspaceFilesSession;
|
|
private readonly agentConfigSession: AgentConfigSession;
|
|
private readonly projectConfigSession: ProjectConfigSession;
|
|
private readonly daemonSession: DaemonSession;
|
|
private readonly hubExecutionController: HubExecutionController | null;
|
|
private readonly workspaceScripts: WorkspaceScriptsService;
|
|
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
|
|
|
|
constructor(options: SessionOptions) {
|
|
const {
|
|
clientId,
|
|
scopes,
|
|
appVersion,
|
|
clientCapabilities,
|
|
onMessage,
|
|
onMessageToSource,
|
|
onBinaryMessage,
|
|
getTransportBufferedAmount,
|
|
onLifecycleIntent,
|
|
onWorkspaceRecovered,
|
|
logger,
|
|
downloadTokenStore,
|
|
pushTokenStore,
|
|
paseoHome,
|
|
worktreesRoot,
|
|
agentManager,
|
|
agentStorage,
|
|
projectRegistry,
|
|
workspaceRegistry,
|
|
filesystem,
|
|
chatService,
|
|
scheduleService,
|
|
loopService,
|
|
checkoutDiffManager,
|
|
github,
|
|
renameCurrentBranch,
|
|
workspaceGitService,
|
|
workspaceAutoName,
|
|
daemonConfigStore,
|
|
stt,
|
|
sttLanguage,
|
|
tts,
|
|
terminalManager,
|
|
providerSnapshotManager,
|
|
providerUsageService,
|
|
serviceProxy,
|
|
scriptRuntimeStore,
|
|
workspaceSetupSnapshots,
|
|
onBranchChanged,
|
|
getDaemonTcpPort,
|
|
getDaemonTcpHost,
|
|
serviceProxyPublicBaseUrl,
|
|
resolveScriptHealth,
|
|
voice,
|
|
voiceBridge,
|
|
dictation,
|
|
serverId,
|
|
daemonVersion,
|
|
daemonRuntimeConfig,
|
|
getWebSocketRuntimeMetrics,
|
|
} = options;
|
|
this.clientId = clientId;
|
|
this.scopes = [...scopes];
|
|
this.appVersion = appVersion ?? null;
|
|
this.clientCapabilities = parseClientCapabilities(clientCapabilities);
|
|
this.sessionId = uuidv4();
|
|
this.onMessage = onMessage;
|
|
this.onMessageToSource = onMessageToSource ?? null;
|
|
this.onBinaryMessage = onBinaryMessage ?? null;
|
|
this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0);
|
|
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
|
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
|
|
this.pushTokenStore = pushTokenStore;
|
|
this.paseoHome = paseoHome;
|
|
this.worktreesRoot = worktreesRoot;
|
|
this.sessionLogger = logger.child({
|
|
module: "session",
|
|
clientId: this.clientId,
|
|
sessionId: this.sessionId,
|
|
});
|
|
this.workspaceFilesSession = new WorkspaceFilesSession({
|
|
host: {
|
|
emit: (msg) => this.emit(msg),
|
|
emitBinary: (frame) => this.emitBinary(frame),
|
|
hasBinaryChannel: () => this.onBinaryMessage !== null,
|
|
},
|
|
downloadTokenStore,
|
|
paseoHome,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.agentManager = agentManager;
|
|
this.agentStorage = agentStorage;
|
|
this.projectRegistry = projectRegistry;
|
|
this.workspaceRegistry = workspaceRegistry;
|
|
this.filesystem = filesystem ?? nodeSessionFileSystem;
|
|
this.github = github ?? createGitHubService();
|
|
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
|
|
this.workspaceGitService = workspaceGitService;
|
|
this.gitMutation = createGitMutationService({
|
|
workspaceGitService: this.workspaceGitService,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.workspaceAutoName = workspaceAutoName;
|
|
this.workspaceProvisioning = createWorkspaceProvisioningService({
|
|
workspaceRegistry: this.workspaceRegistry,
|
|
projectRegistry: this.projectRegistry,
|
|
workspaceGitService: this.workspaceGitService,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.workspaceRecovery = createWorkspaceRecoveryService({
|
|
paseoHome: this.paseoHome,
|
|
worktreesRoot: this.worktreesRoot,
|
|
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
|
getProject: (projectId) => this.projectRegistry.get(projectId),
|
|
isDirectory: (path) => this.filesystem.isDirectory(path),
|
|
unarchiveWorkspace: async (workspace) => {
|
|
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
|
|
},
|
|
});
|
|
this.checkoutSession = new CheckoutSession({
|
|
host: {
|
|
emit: (msg) => this.emit(msg),
|
|
emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd),
|
|
handleWorkspaceGitBranchSnapshot: (cwd, branchName) =>
|
|
this.workspaceGitObserver.handleBranchSnapshot(cwd, branchName),
|
|
renameCurrentBranch: (cwd, branch) => this.renameCurrentBranch(cwd, branch),
|
|
},
|
|
gitMutation: this.gitMutation,
|
|
workspaceGitService: this.workspaceGitService,
|
|
github: this.github,
|
|
checkoutDiffManager,
|
|
gitMetadataGenerator: createGitMetadataGenerator({
|
|
workspaceGitService: this.workspaceGitService,
|
|
generation: createAgentStructuredTextGeneration({
|
|
agentManager: this.agentManager,
|
|
providerSnapshotManager,
|
|
readDaemonConfig: () => this.readStructuredGenerationDaemonConfig(),
|
|
getFocusedSelection: (cwd) => this.getFocusedAgentSelectionForCwd(cwd),
|
|
}),
|
|
}),
|
|
paseoHome: this.paseoHome,
|
|
worktreesRoot: this.worktreesRoot,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.workspaceGitObserver = createWorkspaceGitObserverService({
|
|
workspaceGitService: this.workspaceGitService,
|
|
describeWorkspaceRecordWithGitData: (workspace) =>
|
|
this.describeWorkspaceRecordWithGitData(workspace),
|
|
emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd),
|
|
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
|
|
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
|
|
emitStatusUpdate: (cwd, snapshot) => this.checkoutSession.emitStatusUpdate(cwd, snapshot),
|
|
onBranchChanged,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.chatScheduleLoopSession = new ChatScheduleLoopSession({
|
|
host: {
|
|
emit: (msg) => this.emit(msg),
|
|
listStoredAgents: () => this.agentStorage.list(),
|
|
listLiveAgents: () => this.agentManager.listAgents(),
|
|
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,
|
|
});
|
|
},
|
|
},
|
|
chatService,
|
|
scheduleService,
|
|
loopService,
|
|
clientId: this.clientId,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.providerCatalogSession = new ProviderCatalogSession({
|
|
host: {
|
|
emit: (msg) => this.emit(msg),
|
|
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
|
|
supportsCustomModeIcons: () => this.supports(CLIENT_CAPS.customModeIcons),
|
|
listProviderAvailability: () => this.agentManager.listProviderAvailability(),
|
|
listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config),
|
|
},
|
|
providerSnapshotManager,
|
|
providerUsageService,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.agentConfigSession = new AgentConfigSession({
|
|
host: {
|
|
emit: (msg) => this.emit(msg),
|
|
},
|
|
operations: {
|
|
ensureLoaded: async (agentId) => {
|
|
await ensureUnarchivedAgentLoaded(agentId, {
|
|
agentManager,
|
|
agentStorage,
|
|
logger: this.sessionLogger,
|
|
});
|
|
},
|
|
setMode: async (agentId, modeId) =>
|
|
(await setAgentModeCommand({ agentManager }, { agentId, modeId })).notice,
|
|
setModel: (agentId, modelId) => agentManager.setAgentModel(agentId, modelId),
|
|
setFeature: (agentId, featureId, value) =>
|
|
agentManager.setAgentFeature(agentId, featureId, value),
|
|
setThinking: (agentId, thinkingOptionId) =>
|
|
agentManager.setAgentThinkingOption(agentId, thinkingOptionId),
|
|
},
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.projectConfigSession = new ProjectConfigSession({
|
|
host: {
|
|
emit: (msg) => this.emit(msg),
|
|
},
|
|
projectRegistry: this.projectRegistry,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.daemonSession = new DaemonSession({
|
|
host: {
|
|
emit: (msg) => this.emit(msg),
|
|
emitLifecycleIntent: (intent) => this.emitLifecycleIntent(intent),
|
|
},
|
|
clientId: this.clientId,
|
|
paseoHome: this.paseoHome,
|
|
serverId,
|
|
daemonVersion,
|
|
daemonRuntimeConfig,
|
|
getWebSocketRuntimeMetrics,
|
|
listProviderAvailability: () => this.agentManager.listProviderAvailability(),
|
|
listAgents: () => this.agentManager.listAgents(),
|
|
listProjects: () => this.projectRegistry.list(),
|
|
listWorkspaces: () => this.workspaceRegistry.list(),
|
|
logger: this.sessionLogger,
|
|
hubRelationships: options.hubRelationships,
|
|
});
|
|
this.hubExecutionController = options.hubExecutionAgents
|
|
? new HubExecutionController({
|
|
agents: options.hubExecutionAgents,
|
|
send: (message) => this.emit(message),
|
|
})
|
|
: null;
|
|
this.daemonConfigStore = daemonConfigStore;
|
|
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,
|
|
listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(),
|
|
clientSupportsWrapReflow: () =>
|
|
this.clientCapabilities.has(CLIENT_CAPS.terminalReflowableSnapshot),
|
|
getClientBufferedAmount: () => this.getTransportBufferedAmount(),
|
|
});
|
|
this.agentUpdates = createAgentUpdatesService({
|
|
emit: (message) => this.emit(message),
|
|
buildAgentPayload: (agent) => this.buildAgentPayload(agent),
|
|
buildStoredAgentPayload: (record) => this.buildStoredAgentPayload(record),
|
|
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
|
|
buildProjectPlacementForWorkspaceId: (workspaceId) =>
|
|
this.buildProjectPlacementForWorkspaceId(workspaceId),
|
|
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
|
|
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
|
|
paseoHome: this.paseoHome,
|
|
worktreesRoot: this.worktreesRoot,
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
github: this.github,
|
|
workspaceGitService: this.workspaceGitService,
|
|
createPaseoWorktreeWorkflow: (input, workflowOptions) =>
|
|
this.createPaseoWorktreeWorkflow(input, workflowOptions),
|
|
archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId),
|
|
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
|
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
|
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
|
emit: (message) => this.emit(message),
|
|
emitAgentRemove: (agentId) => this.agentUpdates.removeAgent(agentId),
|
|
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
|
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
|
|
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
|
|
this.markWorkspaceArchiving(workspaceIds, archivingAt),
|
|
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
|
|
killTerminalsForWorkspace: (workspaceId) =>
|
|
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.providerSnapshotManager = providerSnapshotManager;
|
|
this.serviceProxy = serviceProxy ?? null;
|
|
this.scriptRuntimeStore = scriptRuntimeStore ?? null;
|
|
this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map();
|
|
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
|
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
|
this.serviceProxyPublicBaseUrl = serviceProxyPublicBaseUrl ?? null;
|
|
this.resolveScriptHealth = resolveScriptHealth ?? null;
|
|
this.workspaceScripts = createWorkspaceScriptsService({
|
|
serviceProxy: this.serviceProxy,
|
|
scriptRuntimeStore: this.scriptRuntimeStore,
|
|
terminalManager: this.terminalManager,
|
|
workspaceRegistry: this.workspaceRegistry,
|
|
projectRegistry: this.projectRegistry,
|
|
workspaceGitService: this.workspaceGitService,
|
|
getDaemonTcpPort: this.getDaemonTcpPort,
|
|
getDaemonTcpHost: this.getDaemonTcpHost,
|
|
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
|
resolveScriptHealth: this.resolveScriptHealth,
|
|
logger: this.sessionLogger,
|
|
emit: (message) => this.emit(message),
|
|
spawnWorkspaceScript,
|
|
});
|
|
this.subscribeToOptionalManagers();
|
|
this.workspaceDirectory = new WorkspaceDirectory({
|
|
logger: this.sessionLogger,
|
|
projectRegistry: this.projectRegistry,
|
|
workspaceRegistry: this.workspaceRegistry,
|
|
listAgentPayloads: () => this.listAgentPayloads(),
|
|
listTerminalActivityContributions: () => this.listTerminalActivityContributions(),
|
|
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
|
|
buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
|
|
});
|
|
|
|
this.voiceSession = new VoiceSession({
|
|
host: {
|
|
emit: (msg) => this.emit(msg),
|
|
loadAgent: (agentId) =>
|
|
ensureAgentLoaded(agentId, {
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
logger: this.sessionLogger,
|
|
}),
|
|
reloadAgentSession: (agentId, overrides) =>
|
|
this.agentManager.reloadAgentSession(agentId, overrides),
|
|
sendSpokenInput: async (agentId, text) => {
|
|
await this.handleSendAgentMessage(
|
|
agentId,
|
|
text,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
{ spokenInput: true },
|
|
);
|
|
},
|
|
interruptAgentIfRunning: (agentId) => this.interruptAgentIfRunning(agentId),
|
|
hasActiveAgentRun: (agentId) => this.hasActiveAgentRun(agentId),
|
|
},
|
|
logger: this.sessionLogger,
|
|
sessionId: this.sessionId,
|
|
sttLanguage,
|
|
tts,
|
|
stt,
|
|
voice,
|
|
voiceBridge,
|
|
dictation,
|
|
});
|
|
|
|
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, source?: object): void {
|
|
this.clientCapabilities = parseClientCapabilities(capabilities);
|
|
if (source) {
|
|
this.clientCapabilitiesBySource.set(source, this.clientCapabilities);
|
|
}
|
|
if (!source && !this.supports(CLIENT_CAPS.selectiveAgentTimeline)) {
|
|
this.viewedTimelineAgentIdsBySource.clear();
|
|
this.viewedTimelineAgentIds.clear();
|
|
}
|
|
}
|
|
|
|
clearAgentTimelineSubscription(source: object): void {
|
|
this.clientCapabilitiesBySource.delete(source);
|
|
if (this.viewedTimelineAgentIdsBySource.delete(source)) {
|
|
this.rebuildViewedTimelineAgentIds();
|
|
}
|
|
}
|
|
|
|
private replaceAgentTimelineSubscription(source: object | undefined, agentIds: string[]): void {
|
|
const subscriptionSource = source ?? this.defaultTimelineSubscriptionSource;
|
|
if (agentIds.length === 0) this.viewedTimelineAgentIdsBySource.delete(subscriptionSource);
|
|
else this.viewedTimelineAgentIdsBySource.set(subscriptionSource, new Set(agentIds));
|
|
this.rebuildViewedTimelineAgentIds();
|
|
}
|
|
|
|
private rebuildViewedTimelineAgentIds(): void {
|
|
const viewedAgentIds = new Set<string>();
|
|
for (const agentIds of this.viewedTimelineAgentIdsBySource.values()) {
|
|
for (const agentId of agentIds) viewedAgentIds.add(agentId);
|
|
}
|
|
this.viewedTimelineAgentIds = viewedAgentIds;
|
|
}
|
|
|
|
private usesSelectiveTimelineDelivery(): boolean {
|
|
if (this.clientCapabilitiesBySource.size === 0) {
|
|
return this.supports(CLIENT_CAPS.selectiveAgentTimeline);
|
|
}
|
|
for (const capabilities of this.clientCapabilitiesBySource.values()) {
|
|
if (!capabilities.has(CLIENT_CAPS.selectiveAgentTimeline)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private forwardAgentStream(
|
|
event: Extract<AgentManagerEvent, { type: "agent_stream" }>,
|
|
serializedEvent: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"],
|
|
): void {
|
|
if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) {
|
|
if (this.usesSelectiveTimelineDelivery() && serializedEvent.type === "attention_required") {
|
|
this.emit({
|
|
type: "agent_attention_required",
|
|
payload: {
|
|
agentId: event.agentId,
|
|
reason: serializedEvent.reason,
|
|
timestamp: serializedEvent.timestamp,
|
|
shouldNotify: serializedEvent.shouldNotify,
|
|
...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}),
|
|
},
|
|
});
|
|
} else if (
|
|
!this.usesSelectiveTimelineDelivery() ||
|
|
this.viewedTimelineAgentIds.has(event.agentId)
|
|
) {
|
|
this.emit({
|
|
type: "agent_stream",
|
|
payload: this.buildAgentStreamPayload(event, serializedEvent),
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
for (const [source, capabilities] of this.clientCapabilitiesBySource) {
|
|
const supportsSelectiveDelivery = capabilities.has(CLIENT_CAPS.selectiveAgentTimeline);
|
|
if (supportsSelectiveDelivery && serializedEvent.type === "attention_required") {
|
|
this.onMessageToSource(source, {
|
|
type: "agent_attention_required",
|
|
payload: {
|
|
agentId: event.agentId,
|
|
reason: serializedEvent.reason,
|
|
timestamp: serializedEvent.timestamp,
|
|
shouldNotify: serializedEvent.shouldNotify,
|
|
...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}),
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
if (
|
|
supportsSelectiveDelivery &&
|
|
!this.viewedTimelineAgentIdsBySource.get(source)?.has(event.agentId)
|
|
) {
|
|
continue;
|
|
}
|
|
this.onMessageToSource(source, {
|
|
type: "agent_stream",
|
|
payload: this.buildAgentStreamPayload(event, serializedEvent),
|
|
});
|
|
}
|
|
}
|
|
|
|
supports(capability: ClientCapability): boolean {
|
|
return this.clientCapabilities.has(capability);
|
|
}
|
|
|
|
supportsForSource(capability: ClientCapability, source: object): boolean {
|
|
return (
|
|
this.clientCapabilitiesBySource.get(source)?.has(capability) ?? this.supports(capability)
|
|
);
|
|
}
|
|
|
|
emitProjectUpdate(update: ProjectUpdate): void {
|
|
const message: SessionOutboundMessage = {
|
|
type: "project.update",
|
|
payload:
|
|
update.kind === "upsert"
|
|
? { kind: "upsert", project: this.buildProjectDescriptor(update.project) }
|
|
: update,
|
|
};
|
|
if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) {
|
|
if (this.supports(CLIENT_CAPS.projectUpdates)) this.emit(message);
|
|
return;
|
|
}
|
|
for (const [source, capabilities] of this.clientCapabilitiesBySource) {
|
|
if (capabilities.has(CLIENT_CAPS.projectUpdates)) {
|
|
this.onMessageToSource(source, message);
|
|
}
|
|
}
|
|
}
|
|
|
|
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
|
await this.workspaceGitObserver.syncObserverForWorkspace(workspace);
|
|
}
|
|
|
|
async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void> {
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true });
|
|
}
|
|
|
|
private async emitCreatedWorkspaceUpdate(workspace: WorkspaceDescriptorPayload): Promise<void> {
|
|
if (this.workspaceUpdatesSubscription) {
|
|
await this.emitWorkspaceUpdateForWorkspaceId(workspace.id);
|
|
return;
|
|
}
|
|
// COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12.
|
|
// Older clients create before subscribing and require the causal update beside the response.
|
|
this.emit({ type: "workspace_update", payload: { kind: "upsert", workspace } });
|
|
}
|
|
|
|
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>,
|
|
options?: { skipReconcile?: boolean },
|
|
): Promise<void> {
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, options);
|
|
}
|
|
|
|
async syncWorkspaceGitObserversForExternalWorkspaceIds(
|
|
workspaceIds: Iterable<string>,
|
|
): Promise<void> {
|
|
await Promise.all(
|
|
Array.from(new Set(workspaceIds)).map(async (workspaceId) => {
|
|
const workspace = await this.workspaceRegistry.get(workspaceId);
|
|
if (workspace && !workspace.archivedAt) {
|
|
await this.workspaceGitObserver.syncObserverForWorkspace(workspace);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
|
await this.workspaceGitObserver.warmGitData(workspace);
|
|
}
|
|
|
|
async refreshRecoveredWorkspaceForExternalMutation(
|
|
workspace: PersistedWorkspaceRecord,
|
|
): Promise<void> {
|
|
try {
|
|
await this.workspaceGitObserver.warmGitData(workspace);
|
|
} catch (error) {
|
|
this.sessionLogger.warn(
|
|
{ err: error, workspaceId: workspace.workspaceId },
|
|
"Failed to warm git observer after workspace recovery",
|
|
);
|
|
try {
|
|
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
|
} catch (emitError) {
|
|
this.sessionLogger.warn(
|
|
{ err: emitError, workspaceId: workspace.workspaceId },
|
|
"Failed to emit workspace update after recovery",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the client's current activity state
|
|
*/
|
|
public getClientActivity(): {
|
|
deviceType: "web" | "mobile";
|
|
focusedAgentId: string | null;
|
|
focusedTerminalId: 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.
|
|
}
|
|
|
|
/**
|
|
* 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 cancellation = await this.agentManager.cancelAgentRun(agentId);
|
|
this.sessionLogger.debug(
|
|
{ agentId, cancellation: cancellation.status, durationMs: Date.now() - t0 },
|
|
"interruptAgentIfRunning: cancelAgentRun completed",
|
|
);
|
|
if (cancellation.status === "refused") {
|
|
this.sessionLogger.warn(
|
|
{ agentId },
|
|
"interruptAgentIfRunning: reported running but no active run was cancelled",
|
|
);
|
|
throw new AgentRunCancellationError(agentId, "stop");
|
|
}
|
|
}
|
|
|
|
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}`,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Subscribe to AgentManager events and forward them to the client
|
|
*/
|
|
private subscribeToOptionalManagers(): void {
|
|
this.terminalController.start();
|
|
if (this.terminalManager) {
|
|
this.unsubscribeTerminalWorkspaceContributionEvents =
|
|
this.terminalManager.subscribeTerminalWorkspaceContributionChanged((event) => {
|
|
void this.emitWorkspaceUpdateForTerminalContribution(event).catch((error) => {
|
|
this.sessionLogger.warn(
|
|
{ err: error, terminalId: event.terminalId },
|
|
"Failed to emit workspace update after terminal contribution changed",
|
|
);
|
|
});
|
|
});
|
|
}
|
|
this.providerCatalogSession.start();
|
|
}
|
|
|
|
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.agentUpdates.forwardLiveAgent(event.agent);
|
|
return;
|
|
}
|
|
|
|
if (event.type === "provider_subagent") {
|
|
if (!this.supports(CLIENT_CAPS.providerSubagents)) {
|
|
return;
|
|
}
|
|
const update = event.event;
|
|
if (update.type === "upsert") {
|
|
this.emit({
|
|
type: "agent.provider_subagents.update",
|
|
payload: { kind: "upsert", subagent: update.subagent },
|
|
});
|
|
} else if (update.type === "timeline") {
|
|
this.emit({
|
|
type: "agent.provider_subagents.update",
|
|
payload: {
|
|
kind: "timeline",
|
|
parentAgentId: update.parentAgentId,
|
|
subagentId: update.subagentId,
|
|
provider: update.provider,
|
|
item: update.row.item,
|
|
timestamp: update.row.timestamp,
|
|
seq: update.row.seq,
|
|
epoch: update.epoch,
|
|
},
|
|
});
|
|
} else {
|
|
this.emit({
|
|
type: "agent.provider_subagents.update",
|
|
payload: {
|
|
kind: "remove",
|
|
parentAgentId: update.parentAgentId,
|
|
subagentId: update.subagentId,
|
|
},
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.voiceSession.isActiveForAgent(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.forwardAgentStream(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 = new Set(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 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 snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
|
const checkout = checkoutFromPersistedWorkspacePlacement({
|
|
workspace,
|
|
// COMPAT(workspacePlacementBackfill): added in v0.1.107, remove after 2027-01-15.
|
|
// Legacy records can lack branch and worktreeRoot because persisted registries
|
|
// are not migrated in place.
|
|
fallbackBranch: snapshot?.git.currentBranch ?? null,
|
|
fallbackWorktreeRoot: snapshot?.git.repoRoot,
|
|
});
|
|
return {
|
|
projectKey: project.projectId,
|
|
projectName: resolveProjectDisplayName(project),
|
|
workspaceName: resolveWorkspaceDisplayName(workspace),
|
|
checkout,
|
|
};
|
|
}
|
|
|
|
private async buildProjectPlacementForWorkspaceId(
|
|
workspaceId: string,
|
|
): Promise<ProjectPlacementPayload | null> {
|
|
const workspace = await this.workspaceRegistry.get(workspaceId);
|
|
if (!workspace) return null;
|
|
|
|
const project = await this.projectRegistry.get(workspace.projectId);
|
|
if (!project) return null;
|
|
return this.buildProjectPlacementForWorkspace(workspace, project);
|
|
}
|
|
|
|
/**
|
|
* Main entry point for processing session messages
|
|
*/
|
|
public async handleMessage(msg: SessionInboundMessage, source?: object): 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",
|
|
);
|
|
if (!isSessionRpcAllowed(this.scopes, msg.type)) {
|
|
const requestId = sessionRequestId(msg);
|
|
if (requestId) {
|
|
this.emit({
|
|
type: "rpc_error",
|
|
payload: {
|
|
requestId,
|
|
requestType: msg.type,
|
|
error: `Session is not authorized for ${msg.type}`,
|
|
code: "access_denied",
|
|
},
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
try {
|
|
await this.dispatchInboundMessage(msg, source);
|
|
} 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--;
|
|
}
|
|
}
|
|
|
|
public setScopes(scopes: readonly string[]): void {
|
|
this.scopes = [...scopes];
|
|
}
|
|
|
|
private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise<void> {
|
|
const promise =
|
|
this.dispatchVoiceAndControlMessage(msg) ??
|
|
this.dispatchAgentRewindMessage(msg) ??
|
|
this.dispatchAgentRelationshipMessage(msg) ??
|
|
this.dispatchAgentTimelineMessage(msg, source) ??
|
|
this.dispatchHubExecutionMessage(msg) ??
|
|
this.dispatchAgentLifecycleMessage(msg) ??
|
|
this.dispatchAgentConfigMessage(msg) ??
|
|
this.dispatchCheckoutMessage(msg) ??
|
|
this.dispatchWorkspaceRecoveryMessage(msg) ??
|
|
this.dispatchWorkspaceAndProjectMessage(msg) ??
|
|
this.dispatchWorkspaceFileMessage(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.voiceSession.handleAudioChunk(msg);
|
|
case "abort_request":
|
|
return this.voiceSession.handleAbort();
|
|
case "audio_played":
|
|
this.voiceSession.handleAudioPlayed(msg.id);
|
|
return undefined;
|
|
case "set_voice_mode":
|
|
return this.voiceSession.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId);
|
|
case "dictation_stream_start":
|
|
return this.voiceSession.handleDictationStreamStart(msg);
|
|
case "dictation_stream_chunk":
|
|
return this.voiceSession.handleDictationChunk({
|
|
dictationId: msg.dictationId,
|
|
seq: msg.seq,
|
|
audioBase64: msg.audio,
|
|
format: msg.format,
|
|
});
|
|
case "dictation_stream_finish":
|
|
return this.voiceSession.handleDictationFinish(msg.dictationId, msg.finalSeq);
|
|
case "dictation_stream_cancel":
|
|
this.voiceSession.handleDictationCancel(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 dispatchAgentRelationshipMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "agent.detach.request":
|
|
return this.handleDetachAgentRequest(msg.agentId, msg.requestId);
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private dispatchAgentTimelineMessage(
|
|
msg: SessionInboundMessage,
|
|
source?: object,
|
|
): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "fetch_agent_timeline_request":
|
|
return this.handleFetchAgentTimelineRequest(msg);
|
|
case "agent.provider_subagents.list.request":
|
|
return this.handleProviderSubagentListRequest(msg);
|
|
case "agent.provider_subagents.timeline.get.request":
|
|
return this.handleProviderSubagentTimelineRequest(msg);
|
|
case "agent.timeline.set_subscription.request": {
|
|
const agentIds = [...new Set(msg.agentIds)].sort();
|
|
if (
|
|
source
|
|
? this.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, source)
|
|
: this.supports(CLIENT_CAPS.selectiveAgentTimeline)
|
|
) {
|
|
this.replaceAgentTimelineSubscription(source, agentIds);
|
|
}
|
|
const response: SessionOutboundMessage = {
|
|
type: "agent.timeline.set_subscription.response",
|
|
payload: { agentIds, requestId: msg.requestId },
|
|
};
|
|
if (source && this.onMessageToSource) this.onMessageToSource(source, response);
|
|
else this.emit(response);
|
|
return undefined;
|
|
}
|
|
case "agent.fork_context.request":
|
|
return this.handleAgentForkContextRequest(msg);
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private dispatchHubExecutionMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
return msg.type === "hub.execution.agent.create.request"
|
|
? this.hubExecutionController?.createAgent(msg)
|
|
: undefined;
|
|
}
|
|
|
|
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 "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.agentConfigSession.handleSetAgentModeRequest(msg);
|
|
case "set_agent_model_request":
|
|
return this.agentConfigSession.handleSetAgentModelRequest(msg);
|
|
case "set_agent_feature_request":
|
|
return this.agentConfigSession.handleSetAgentFeatureRequest(msg);
|
|
case "set_agent_thinking_request":
|
|
return this.agentConfigSession.handleSetAgentThinkingRequest(msg);
|
|
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.daemonSession.handleGetStatusRequest(msg);
|
|
case "daemon.get_pairing_offer.request":
|
|
return this.daemonSession.handleGetPairingOfferRequest(msg);
|
|
case "hub.management.daemon.connect.request":
|
|
case "hub.management.daemon.get_status.request":
|
|
case "hub.management.daemon.disconnect.request":
|
|
return this.daemonSession.handleHubRelationshipRequest(msg);
|
|
case "diagnostics.request":
|
|
return this.daemonSession.handleDiagnosticsRequest(msg);
|
|
case "daemon.update.request":
|
|
return this.daemonSession.handleUpdateRequest(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.projectConfigSession.handleReadProjectConfigRequest(msg);
|
|
case "write_project_config_request":
|
|
return this.projectConfigSession.handleWriteProjectConfigRequest(msg);
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// eslint-disable-next-line complexity
|
|
private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "checkout_status_request":
|
|
return this.checkoutSession.handleStatusRequest(msg);
|
|
case "checkout.commits.list.request":
|
|
return this.checkoutSession.handleCommitsListRequest(msg);
|
|
case "checkout.commits.file_diff.request":
|
|
return this.checkoutSession.handleCommitFileDiffRequest(msg);
|
|
case "validate_branch_request":
|
|
return this.checkoutSession.handleValidateBranchRequest(msg);
|
|
case "branch_suggestions_request":
|
|
return this.checkoutSession.handleBranchSuggestionsRequest(msg);
|
|
case "directory_suggestions_request":
|
|
return this.handleDirectorySuggestionsRequest(msg);
|
|
case "subscribe_checkout_diff_request":
|
|
return this.checkoutSession.handleSubscribeDiffRequest(msg);
|
|
case "unsubscribe_checkout_diff_request":
|
|
this.checkoutSession.handleUnsubscribeDiffRequest(msg);
|
|
return undefined;
|
|
case "checkout_switch_branch_request":
|
|
return this.checkoutSession.handleCheckoutSwitchBranchRequest(msg);
|
|
case "checkout.rename_branch.request":
|
|
return this.checkoutSession.handleCheckoutRenameBranchRequest(msg);
|
|
case "checkout_commit_request":
|
|
return this.checkoutSession.handleCheckoutCommitRequest(msg);
|
|
case "checkout_merge_request":
|
|
return this.checkoutSession.handleCheckoutMergeRequest(msg);
|
|
case "checkout_merge_from_base_request":
|
|
return this.checkoutSession.handleCheckoutMergeFromBaseRequest(msg);
|
|
case "checkout_pull_request":
|
|
return this.checkoutSession.handleCheckoutPullRequest(msg);
|
|
case "checkout_push_request":
|
|
return this.checkoutSession.handleCheckoutPushRequest(msg);
|
|
case "checkout.refresh.request":
|
|
return this.checkoutSession.handleRefreshRequest(msg);
|
|
case "checkout_pr_create_request":
|
|
return this.checkoutSession.handleCheckoutPrCreateRequest(msg);
|
|
case "checkout_pr_merge_request":
|
|
return this.checkoutSession.handleCheckoutPrMergeRequest(msg);
|
|
case "checkout.forge.set_auto_merge.request":
|
|
case "checkout.github.set_auto_merge.request":
|
|
return this.checkoutSession.handleCheckoutForgeSetAutoMergeRequest(msg);
|
|
case "checkout.forge.get_check_details.request":
|
|
case "checkout.github.get_check_details.request":
|
|
return this.checkoutSession.handleCheckoutForgeGetCheckDetailsRequest(msg);
|
|
case "checkout_pr_status_request":
|
|
return this.checkoutSession.handleCheckoutPrStatusRequest(msg);
|
|
case "pull_request_timeline_request":
|
|
return this.checkoutSession.handlePullRequestTimelineRequest(msg);
|
|
case "forge.search.request":
|
|
case "github_search_request":
|
|
return this.checkoutSession.handleForgeSearchRequest(msg);
|
|
case "stash_save_request":
|
|
return this.checkoutSession.handleStashSaveRequest(msg);
|
|
case "stash_pop_request":
|
|
return this.checkoutSession.handleStashPopRequest(msg);
|
|
case "stash_list_request":
|
|
return this.checkoutSession.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);
|
|
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
|
|
case "list_available_editors_request":
|
|
return this.handleLegacyListAvailableEditorsRequest(msg);
|
|
case "open_in_editor_request":
|
|
return this.handleLegacyOpenInEditorRequest(msg);
|
|
case "open_project_request":
|
|
return this.handleOpenProjectRequest(msg);
|
|
case "project.add.request":
|
|
return this.handleProjectAddRequest(msg);
|
|
case "project.create_directory.request":
|
|
return this.handleProjectCreateDirectoryRequest(msg);
|
|
case "workspace.github.search_repositories.request":
|
|
return this.handleWorkspaceGithubSearchRepositoriesRequest(msg);
|
|
case "project.github.clone.request":
|
|
return this.handleProjectGithubCloneRequest(msg);
|
|
case "archive_workspace_request":
|
|
return this.handleArchiveWorkspaceRequest(msg);
|
|
case "project.remove.request":
|
|
return this.handleProjectRemoveRequest(msg);
|
|
case "workspace.create.request":
|
|
return this.handleWorkspaceCreateRequest(msg);
|
|
case "workspace.clear_attention.request":
|
|
return this.handleWorkspaceClearAttentionRequest(msg);
|
|
case "workspace.title.set.request":
|
|
return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId);
|
|
case "workspace.pin.set.request":
|
|
return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId);
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private dispatchWorkspaceFileMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "file_explorer_request":
|
|
return this.workspaceFilesSession.handleFileExplorerRequest(msg);
|
|
case "project_icon_request":
|
|
return this.workspaceFilesSession.handleProjectIconRequest(msg);
|
|
case "file_download_token_request":
|
|
return this.workspaceFilesSession.handleFileDownloadTokenRequest(msg);
|
|
case "file.upload.request":
|
|
this.workspaceFilesSession.handleFileUploadRequest(msg);
|
|
return undefined;
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private dispatchWorkspaceRecoveryMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "workspace.recovery.inspect.request":
|
|
return this.handleWorkspaceRecoveryInspectRequest(msg);
|
|
case "workspace.recovery.restore.request":
|
|
return this.handleWorkspaceRecoveryRestoreRequest(msg);
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private dispatchProviderMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "list_provider_models_request":
|
|
return this.providerCatalogSession.handleListProviderModelsRequest(msg);
|
|
case "list_provider_modes_request":
|
|
return this.providerCatalogSession.handleListProviderModesRequest(msg);
|
|
case "list_provider_features_request":
|
|
return this.providerCatalogSession.handleListProviderFeaturesRequest(msg);
|
|
case "list_available_providers_request":
|
|
return this.providerCatalogSession.handleListAvailableProvidersRequest(msg);
|
|
case "get_providers_snapshot_request":
|
|
return this.providerCatalogSession.handleGetProvidersSnapshotRequest(msg);
|
|
case "refresh_providers_snapshot_request":
|
|
return this.providerCatalogSession.handleRefreshProvidersSnapshotRequest(msg);
|
|
case "provider_diagnostic_request":
|
|
return this.providerCatalogSession.handleProviderDiagnosticRequest(msg);
|
|
case "provider.usage.list.request":
|
|
return this.providerCatalogSession.handleProviderUsageListRequest(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);
|
|
}
|
|
|
|
// eslint-disable-next-line complexity
|
|
private dispatchChatScheduleLoopMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "chat/create":
|
|
return this.chatScheduleLoopSession.handleChatCreateRequest(msg);
|
|
case "chat/list":
|
|
return this.chatScheduleLoopSession.handleChatListRequest(msg);
|
|
case "chat/inspect":
|
|
return this.chatScheduleLoopSession.handleChatInspectRequest(msg);
|
|
case "chat/delete":
|
|
return this.chatScheduleLoopSession.handleChatDeleteRequest(msg);
|
|
case "chat/post":
|
|
return this.chatScheduleLoopSession.handleChatPostRequest(msg);
|
|
case "chat/read":
|
|
return this.chatScheduleLoopSession.handleChatReadRequest(msg);
|
|
case "chat/wait":
|
|
return this.chatScheduleLoopSession.handleChatWaitRequest(msg);
|
|
case "loop/run":
|
|
return this.chatScheduleLoopSession.handleLoopRunRequest(msg);
|
|
case "loop/list":
|
|
return this.chatScheduleLoopSession.handleLoopListRequest(msg);
|
|
case "loop/inspect":
|
|
return this.chatScheduleLoopSession.handleLoopInspectRequest(msg);
|
|
case "loop/logs":
|
|
return this.chatScheduleLoopSession.handleLoopLogsRequest(msg);
|
|
case "loop/stop":
|
|
return this.chatScheduleLoopSession.handleLoopStopRequest(msg);
|
|
case "schedule/create":
|
|
return this.chatScheduleLoopSession.handleScheduleCreateRequest(msg);
|
|
case "schedule/list":
|
|
return this.chatScheduleLoopSession.handleScheduleListRequest(msg);
|
|
case "schedule/inspect":
|
|
return this.chatScheduleLoopSession.handleScheduleInspectRequest(msg);
|
|
case "schedule/logs":
|
|
return this.chatScheduleLoopSession.handleScheduleLogsRequest(msg);
|
|
case "schedule/pause":
|
|
return this.chatScheduleLoopSession.handleSchedulePauseRequest(msg);
|
|
case "schedule/resume":
|
|
return this.chatScheduleLoopSession.handleScheduleResumeRequest(msg);
|
|
case "schedule/delete":
|
|
return this.chatScheduleLoopSession.handleScheduleDeleteRequest(msg);
|
|
case "schedule/run-once":
|
|
return this.chatScheduleLoopSession.handleScheduleRunOnceRequest(msg);
|
|
case "schedule/update":
|
|
return this.chatScheduleLoopSession.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 getSessionId(): string {
|
|
return this.sessionId;
|
|
}
|
|
|
|
public async handleBinaryFrame(binaryFrame: BinaryFrame): Promise<void> {
|
|
if (binaryFrame.kind === "file_transfer") {
|
|
await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame);
|
|
return;
|
|
}
|
|
this.terminalController.handleBinaryFrame(binaryFrame.frame);
|
|
}
|
|
|
|
private async handleRestartServerRequest(requestId: string, reason?: string): Promise<void> {
|
|
const lifecycleReason = normalizeClientRestartRpcReason(reason);
|
|
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: lifecycleReason }, "Restart requested via websocket");
|
|
this.emit({
|
|
type: "status",
|
|
payload,
|
|
});
|
|
|
|
this.emitLifecycleIntent({
|
|
type: "restart",
|
|
clientId: this.clientId,
|
|
requestId,
|
|
reason: lifecycleReason,
|
|
});
|
|
}
|
|
|
|
private async handleShutdownServerRequest(requestId: string): Promise<void> {
|
|
const reason = CLIENT_SHUTDOWN_RPC_REASON;
|
|
this.sessionLogger.warn({ reason }, "Shutdown requested via websocket");
|
|
this.emit({
|
|
type: "status",
|
|
payload: {
|
|
status: "shutdown_requested",
|
|
clientId: this.clientId,
|
|
requestId,
|
|
},
|
|
});
|
|
|
|
this.emitLifecycleIntent({
|
|
type: "shutdown",
|
|
clientId: this.clientId,
|
|
requestId,
|
|
reason,
|
|
});
|
|
}
|
|
|
|
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 knownWorkspaceId =
|
|
this.agentManager.getAgent(agentId)?.workspaceId ??
|
|
(await this.agentStorage.get(agentId))?.workspaceId ??
|
|
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.deleteAgentState(agentId);
|
|
} catch (error) {
|
|
this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`);
|
|
}
|
|
|
|
this.emit({
|
|
type: "agent_deleted",
|
|
payload: {
|
|
agentId,
|
|
requestId,
|
|
},
|
|
});
|
|
|
|
this.agentUpdates.removeAgent(agentId);
|
|
|
|
if (knownWorkspaceId) {
|
|
await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId);
|
|
}
|
|
}
|
|
|
|
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.agentUpdates.hasSubscription()) {
|
|
const payload = await this.agentUpdates.emitStoredRecord(archivedRecord);
|
|
if (payload.workspaceId) {
|
|
await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
|
|
}
|
|
}
|
|
|
|
return { agentId, archivedAt };
|
|
}
|
|
|
|
private async handleDetachAgentRequest(agentId: string, requestId: string): Promise<void> {
|
|
this.sessionLogger.info({ agentId, requestId }, "Detaching agent from parent");
|
|
|
|
try {
|
|
const result = await detachAgentCommand({ agentManager: this.agentManager }, agentId);
|
|
const affectedWorkspaceIds = new Set<string>();
|
|
|
|
if (!result.live) {
|
|
const payload = await this.agentUpdates.emitStoredRecord(result.record);
|
|
if (payload.workspaceId) {
|
|
affectedWorkspaceIds.add(payload.workspaceId);
|
|
}
|
|
} else if (result.record.workspaceId) {
|
|
affectedWorkspaceIds.add(result.record.workspaceId);
|
|
}
|
|
|
|
if (result.previousParentAgentId) {
|
|
const rootWorkspaceId = await this.resolveDelegationRootWorkspaceId(
|
|
result.previousParentAgentId,
|
|
);
|
|
if (rootWorkspaceId) {
|
|
affectedWorkspaceIds.add(rootWorkspaceId);
|
|
}
|
|
}
|
|
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds, {
|
|
skipReconcile: true,
|
|
});
|
|
|
|
this.emit({
|
|
type: "agent.detach.response",
|
|
payload: {
|
|
requestId,
|
|
agentId,
|
|
accepted: true,
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const message = getErrorMessageOr(error, "Failed to detach agent");
|
|
this.sessionLogger.error({ err: error, agentId, requestId }, "Failed to detach agent");
|
|
this.emit({
|
|
type: "agent.detach.response",
|
|
payload: {
|
|
requestId,
|
|
agentId,
|
|
accepted: false,
|
|
error: message,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
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 async handleProjectRemoveRequest(
|
|
request: Extract<SessionInboundMessage, { type: "project.remove.request" }>,
|
|
): Promise<void> {
|
|
const { projectId, requestId } = request;
|
|
this.sessionLogger.info({ projectId, requestId }, "session: project.remove.request");
|
|
|
|
try {
|
|
const projectWorkspaces = (await this.workspaceRegistry.list()).filter(
|
|
(workspace) => workspace.projectId === projectId,
|
|
);
|
|
const activeWorkspaceIds = projectWorkspaces
|
|
.filter((workspace) => !workspace.archivedAt)
|
|
.map((workspace) => workspace.workspaceId);
|
|
|
|
if (activeWorkspaceIds.length > 0) {
|
|
this.markWorkspaceArchiving(activeWorkspaceIds, new Date().toISOString());
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds(activeWorkspaceIds, {
|
|
skipReconcile: true,
|
|
});
|
|
}
|
|
|
|
const removedWorkspaceIds: string[] = [];
|
|
try {
|
|
for (const workspaceId of activeWorkspaceIds) {
|
|
await archiveWorkspaceContents(
|
|
{
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
killTerminalsForWorkspace: (id) =>
|
|
this.terminalController.killTerminalsForWorkspace(id),
|
|
sessionLogger: this.sessionLogger,
|
|
},
|
|
workspaceId,
|
|
);
|
|
await this.archiveWorkspaceRecord(workspaceId);
|
|
removedWorkspaceIds.push(workspaceId);
|
|
}
|
|
|
|
await this.projectRegistry.remove(projectId);
|
|
} finally {
|
|
if (activeWorkspaceIds.length > 0) {
|
|
this.clearWorkspaceArchiving(activeWorkspaceIds);
|
|
}
|
|
}
|
|
|
|
const updateIds =
|
|
removedWorkspaceIds.length > 0
|
|
? removedWorkspaceIds
|
|
: [projectWorkspaces[0]?.workspaceId ?? projectId];
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds(updateIds, {
|
|
skipReconcile: true,
|
|
removedProjectId: projectId,
|
|
});
|
|
|
|
this.emit({
|
|
type: "project.remove.response",
|
|
payload: {
|
|
requestId,
|
|
projectId,
|
|
accepted: true,
|
|
removedWorkspaceIds,
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.sessionLogger.error(
|
|
{ err: error, projectId, requestId },
|
|
"session: project.remove.request error",
|
|
);
|
|
this.emit({
|
|
type: "activity_log",
|
|
payload: {
|
|
id: uuidv4(),
|
|
timestamp: new Date(),
|
|
type: "error",
|
|
content: `Failed to remove project: ${getErrorMessage(error)}`,
|
|
},
|
|
});
|
|
this.emit({
|
|
type: "project.remove.response",
|
|
payload: {
|
|
requestId,
|
|
projectId,
|
|
accepted: false,
|
|
removedWorkspaceIds: [],
|
|
error: getErrorMessageOr(error, "Failed to remove project"),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleWorkspaceTitleSetRequest(
|
|
workspaceId: string,
|
|
title: string | null,
|
|
requestId: string,
|
|
): Promise<void> {
|
|
this.sessionLogger.info(
|
|
{ workspaceId, requestId, hasTitle: typeof title === "string" },
|
|
"session: workspace.title.set.request",
|
|
);
|
|
|
|
try {
|
|
const trimmed = title?.trim() ?? "";
|
|
const nextTitle = trimmed.length === 0 ? null : trimmed;
|
|
const updatedAt = new Date().toISOString();
|
|
const updated = await this.workspaceRegistry.update(workspaceId, (existing) => ({
|
|
...existing,
|
|
title: nextTitle,
|
|
updatedAt,
|
|
}));
|
|
if (!updated) {
|
|
this.emit({
|
|
type: "workspace.title.set.response",
|
|
payload: {
|
|
requestId,
|
|
workspaceId,
|
|
accepted: false,
|
|
title: null,
|
|
error: "Workspace not found",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.emit({
|
|
type: "workspace.title.set.response",
|
|
payload: {
|
|
requestId,
|
|
workspaceId,
|
|
accepted: true,
|
|
title: nextTitle,
|
|
error: null,
|
|
},
|
|
});
|
|
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], {
|
|
skipReconcile: true,
|
|
});
|
|
} catch (error) {
|
|
this.sessionLogger.error(
|
|
{ err: error, workspaceId, requestId },
|
|
"session: workspace.title.set.request error",
|
|
);
|
|
this.emit({
|
|
type: "activity_log",
|
|
payload: {
|
|
id: uuidv4(),
|
|
timestamp: new Date(),
|
|
type: "error",
|
|
content: `Failed to set workspace title: ${getErrorMessage(error)}`,
|
|
},
|
|
});
|
|
this.emit({
|
|
type: "workspace.title.set.response",
|
|
payload: {
|
|
requestId,
|
|
workspaceId,
|
|
accepted: false,
|
|
title: null,
|
|
error: getErrorMessageOr(error, "Failed to set workspace title"),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleWorkspacePinSetRequest(
|
|
workspaceId: string,
|
|
pinned: boolean,
|
|
requestId: string,
|
|
): Promise<void> {
|
|
const logContext = { workspaceId, pinned, requestId };
|
|
this.sessionLogger.info(logContext, "session: workspace.pin.set.request");
|
|
const emitResponse = (accepted: boolean, pinnedAt: string | null, error: string | null) => {
|
|
this.emit({
|
|
type: "workspace.pin.set.response",
|
|
payload: { requestId, workspaceId, accepted, pinnedAt, error },
|
|
});
|
|
};
|
|
|
|
try {
|
|
const nextPinnedAt = pinned ? new Date().toISOString() : null;
|
|
const updatedAt = new Date().toISOString();
|
|
const updated = await this.workspaceRegistry.update(workspaceId, (existing) => ({
|
|
...existing,
|
|
pinnedAt: nextPinnedAt,
|
|
updatedAt,
|
|
}));
|
|
if (!updated) {
|
|
emitResponse(false, null, "Workspace not found");
|
|
return;
|
|
}
|
|
emitResponse(true, nextPinnedAt, null);
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true });
|
|
} catch (error) {
|
|
this.sessionLogger.error(
|
|
{ ...logContext, err: error },
|
|
"session: workspace.pin.set.request error",
|
|
);
|
|
this.emit({
|
|
type: "activity_log",
|
|
payload: {
|
|
id: uuidv4(),
|
|
timestamp: new Date(),
|
|
type: "error",
|
|
content: `Failed to pin workspace: ${getErrorMessage(error)}`,
|
|
},
|
|
});
|
|
emitResponse(false, null, getErrorMessageOr(error, "Failed to pin workspace"));
|
|
}
|
|
}
|
|
|
|
private async handleWorkspaceRecoveryInspectRequest(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.recovery.inspect.request" }>,
|
|
): Promise<void> {
|
|
const state = await this.workspaceRecovery.inspect(request.workspaceId);
|
|
this.emit({
|
|
type: "workspace.recovery.inspect.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
state,
|
|
},
|
|
});
|
|
}
|
|
|
|
private async handleWorkspaceRecoveryRestoreRequest(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.recovery.restore.request" }>,
|
|
): Promise<void> {
|
|
try {
|
|
await this.restoreWorkspaceAndEmit(request.workspaceId);
|
|
this.emit({
|
|
type: "workspace.recovery.restore.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspaceId: request.workspaceId,
|
|
accepted: true,
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const message = getErrorMessageOr(error, "Failed to recover workspace");
|
|
this.sessionLogger.warn(
|
|
{ err: error, workspaceId: request.workspaceId, requestId: request.requestId },
|
|
"session: workspace.recovery.restore.request rejected",
|
|
);
|
|
this.emit({
|
|
type: "workspace.recovery.restore.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspaceId: request.workspaceId,
|
|
accepted: false,
|
|
error: message,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 = 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: CreateAgentRequestMessage): Promise<void> {
|
|
const {
|
|
config,
|
|
worktreeName,
|
|
requestId,
|
|
initialPrompt,
|
|
clientMessageId,
|
|
outputSchema,
|
|
git,
|
|
worktree,
|
|
autoArchive,
|
|
images,
|
|
attachments,
|
|
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 requestedCwd = resolve(config.cwd);
|
|
const needsRequestedDirectory =
|
|
Boolean(worktreeName || git || worktree) || (!msg.workspaceId && !msg.callerAgentId);
|
|
if (needsRequestedDirectory && !(await this.filesystem.isDirectory(requestedCwd))) {
|
|
throw new Error(`Working directory does not exist or is not a directory: ${requestedCwd}`);
|
|
}
|
|
const trimmedPrompt = initialPrompt?.trim();
|
|
const { provisionalTitle } = resolveCreateAgentTitles({
|
|
configTitle: config.title,
|
|
initialPrompt: trimmedPrompt,
|
|
});
|
|
|
|
const firstAgentContext: FirstAgentContext = {
|
|
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
|
|
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
|
};
|
|
const workspacePromptTitle = resolveFirstAgentPromptTitle(firstAgentContext);
|
|
const createdWorktree = await this.createAgentLifecycleDispatch.createWorktreeForRequest({
|
|
cwd: config.cwd,
|
|
target: worktree,
|
|
firstAgentContext,
|
|
hasLegacyGitOptions: Boolean(git),
|
|
});
|
|
createdWorktreeForCleanup = createdWorktree;
|
|
const resolvedIntent = await this.resolveSessionCreateAgentIntent({
|
|
request: msg,
|
|
createdWorktree,
|
|
workspacePromptTitle,
|
|
});
|
|
const resolvedCwd = resolve(resolvedIntent.config.cwd);
|
|
if (!(await this.filesystem.isDirectory(resolvedCwd))) {
|
|
throw new Error(`Working directory does not exist or is not a directory: ${resolvedCwd}`);
|
|
}
|
|
|
|
const { snapshot, liveSnapshot } = await createAgentCommand(
|
|
{
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
logger: this.sessionLogger,
|
|
paseoHome: this.paseoHome,
|
|
worktreesRoot: this.worktreesRoot,
|
|
providerSnapshotManager: this.providerSnapshotManager,
|
|
},
|
|
{
|
|
kind: "session",
|
|
config: resolvedIntent.config,
|
|
workspaceId: resolvedIntent.intent.workspaceId,
|
|
worktreeName,
|
|
initialPrompt,
|
|
clientMessageId,
|
|
outputSchema,
|
|
images,
|
|
attachments,
|
|
git,
|
|
labels: resolvedIntent.intent.labels,
|
|
env,
|
|
provisionalTitle,
|
|
firstAgentContext,
|
|
buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
|
|
this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
|
|
},
|
|
);
|
|
createdAgentId = snapshot.id;
|
|
await this.agentUpdates.forwardLiveAgent(snapshot);
|
|
if (resolvedIntent.createdDirectoryWorkspace && trimmedPrompt) {
|
|
this.workspaceAutoName.scheduleForDirectory(
|
|
{
|
|
workspaceId: resolvedIntent.intent.workspaceId,
|
|
cwd: resolvedIntent.config.cwd,
|
|
firstAgentContext,
|
|
},
|
|
{ currentSelection: this.getFocusedAgentSelectionForCwd(resolvedIntent.config.cwd) },
|
|
);
|
|
}
|
|
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 resolveSessionCreateAgentIntent(input: {
|
|
request: CreateAgentRequestMessage;
|
|
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
|
workspacePromptTitle: string | null;
|
|
}): Promise<ResolvedSessionCreateAgentIntent> {
|
|
const { request, createdWorktree } = input;
|
|
const callerAgent = request.callerAgentId
|
|
? this.agentManager.getAgent(request.callerAgentId)
|
|
: null;
|
|
if (request.callerAgentId && !callerAgent) {
|
|
throw new Error(`Caller agent ${request.callerAgentId} not found`);
|
|
}
|
|
|
|
let config = request.config;
|
|
|
|
const intent = await resolveCreateAgentIntent({
|
|
explicitWorkspaceId: createdWorktree?.workspace.workspaceId ?? request.workspaceId,
|
|
caller: callerAgent
|
|
? { id: callerAgent.id, cwd: callerAgent.cwd, workspaceId: callerAgent.workspaceId }
|
|
: null,
|
|
labels: request.labels,
|
|
resolveWorkspace: async (workspaceId) => {
|
|
if (createdWorktree?.workspace.workspaceId === workspaceId) {
|
|
return { workspaceId, cwd: createdWorktree.workspace.cwd };
|
|
}
|
|
const workspace = await this.workspaceRegistry.get(workspaceId);
|
|
if (!workspace || workspace.archivedAt) {
|
|
throw new Error(`Workspace ${workspaceId} not found`);
|
|
}
|
|
return { workspaceId, cwd: workspace.cwd };
|
|
},
|
|
createWorkspace: async () => ({
|
|
workspaceId: await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent({
|
|
createdWorktree: null,
|
|
cwd: config.cwd,
|
|
initialTitle: input.workspacePromptTitle,
|
|
}),
|
|
cwd: config.cwd,
|
|
}),
|
|
});
|
|
config = { ...config, cwd: intent.cwd };
|
|
|
|
return {
|
|
config,
|
|
intent,
|
|
createdDirectoryWorkspace: !createdWorktree && !request.workspaceId && !callerAgent,
|
|
};
|
|
}
|
|
|
|
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.agentUpdates.forwardLiveAgent(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 {
|
|
if (!normalized.cwd) {
|
|
throw new Error("Import requires cwd from the selected provider session");
|
|
}
|
|
const { snapshot, timelineSize, createdWorkspace } = await importProviderSession({
|
|
request: normalized,
|
|
workspaceProvisioning: this.workspaceProvisioning,
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
logger: this.sessionLogger,
|
|
});
|
|
if (createdWorkspace) {
|
|
await this.registerWorkspaceForImportedAgent(createdWorkspace);
|
|
}
|
|
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 this.restoreOwningWorkspaceForLegacyAgentRefresh(agentId);
|
|
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, { broadcast: true });
|
|
await this.agentUpdates.forwardLiveAgent(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: error instanceof WorktreeRequestError ? error.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,
|
|
error: null,
|
|
},
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (requestId) {
|
|
this.sessionLogger.error(
|
|
{ err: error, agentId },
|
|
`Failed to cancel running agent on request for agent ${agentId}`,
|
|
);
|
|
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,
|
|
error: errorToFriendlyMessage(error),
|
|
},
|
|
});
|
|
} else {
|
|
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"];
|
|
createdWorkspaceId?: string;
|
|
}> {
|
|
return buildWorktreeAgentSessionConfig(
|
|
{
|
|
paseoHome: this.paseoHome,
|
|
worktreesRoot: this.worktreesRoot,
|
|
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.gitMutation.checkoutExistingBranch(cwd, branch),
|
|
createBranchFromBase: (params) => this.gitMutation.createBranchFromBase(params),
|
|
},
|
|
config,
|
|
gitOptions,
|
|
legacyWorktreeName,
|
|
firstAgentContext,
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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) =>
|
|
ensureAgentLoaded(id, {
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
logger: this.sessionLogger,
|
|
}),
|
|
),
|
|
);
|
|
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;
|
|
focusedTerminalId?: string | null;
|
|
lastActivityAt: string;
|
|
appVisible: boolean;
|
|
appVisibilityChangedAt?: string;
|
|
}): void {
|
|
const focusedTerminalId = msg.focusedTerminalId?.trim() || null;
|
|
const appVisibilityChangedAt = msg.appVisibilityChangedAt
|
|
? new Date(msg.appVisibilityChangedAt)
|
|
: new Date(msg.lastActivityAt);
|
|
this.clientActivity = {
|
|
deviceType: msg.deviceType,
|
|
focusedAgentId: msg.focusedAgentId,
|
|
focusedTerminalId,
|
|
lastActivityAt: new Date(msg.lastActivityAt),
|
|
appVisible: msg.appVisible,
|
|
appVisibilityChangedAt,
|
|
};
|
|
if (msg.appVisible && focusedTerminalId) {
|
|
void this.clearFocusedTerminalAttention(focusedTerminalId);
|
|
}
|
|
}
|
|
|
|
private async clearFocusedTerminalAttention(terminalId: string): Promise<void> {
|
|
const terminalManager = this.terminalManager;
|
|
if (!terminalManager) {
|
|
return;
|
|
}
|
|
try {
|
|
await terminalManager.clearTerminalAttention(terminalId);
|
|
} catch (error) {
|
|
this.sessionLogger.warn({ err: error, terminalId }, "Failed to clear terminal attention");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 existing = this.agentManager.getAgent(agentId);
|
|
const stored = existing ? null : await this.agentStorage.get(agentId);
|
|
const agent =
|
|
existing || (stored && !stored.archivedAt)
|
|
? await ensureAgentLoaded(agentId, {
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
logger: this.sessionLogger,
|
|
})
|
|
: null;
|
|
|
|
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 handleDirectorySuggestionsRequest(msg: DirectorySuggestionsRequest): Promise<void> {
|
|
const { query, limit, requestId, cwd, includeFiles, includeDirectories, matchMode } = msg;
|
|
|
|
try {
|
|
const workspaceCwd = cwd?.trim();
|
|
const searchesWorkspace = Boolean(workspaceCwd);
|
|
const entries = await searchDirectoryEntries({
|
|
root: workspaceCwd ? expandTilde(workspaceCwd) : (process.env.HOME ?? homedir()),
|
|
query,
|
|
pathFormat: searchesWorkspace ? "relative" : "absolute",
|
|
pathQueryPolicy: searchesWorkspace ? "slashes" : "rooted",
|
|
blankQueryBehavior: searchesWorkspace ? "children" : "none",
|
|
rootAliases: searchesWorkspace ? [] : ["~"],
|
|
traversableHiddenDirectoryNames: searchesWorkspace
|
|
? WORKSPACE_SEARCH_HIDDEN_DIRECTORIES
|
|
: [],
|
|
confidentResultScanThreshold: searchesWorkspace ? undefined : 5_000,
|
|
includeFiles,
|
|
includeDirectories,
|
|
matchMode,
|
|
limit,
|
|
});
|
|
const directories = entries
|
|
.filter((entry) => entry.kind === "directory")
|
|
.map((entry) => entry.path);
|
|
this.emit({
|
|
type: "directory_suggestions_response",
|
|
payload: {
|
|
directories,
|
|
entries,
|
|
error: null,
|
|
requestId,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.emit({
|
|
type: "directory_suggestions_response",
|
|
payload: {
|
|
directories: [],
|
|
entries: [],
|
|
error: error instanceof Error ? error.message : String(error),
|
|
requestId,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async 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,
|
|
paseoWorktreesBaseRoot: this.worktreesRoot,
|
|
github: this.github,
|
|
workspaceGitService: this.workspaceGitService,
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
|
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
|
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),
|
|
killTerminalsForWorkspace: (workspaceId) =>
|
|
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
|
sessionLogger: this.sessionLogger,
|
|
},
|
|
msg,
|
|
);
|
|
}
|
|
|
|
private async listTerminalActivityContributions(): Promise<
|
|
Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }>
|
|
> {
|
|
const terminalManager = this.terminalManager;
|
|
if (!terminalManager) {
|
|
return [];
|
|
}
|
|
const directories = terminalManager.listDirectories();
|
|
const terminalsByDirectory = await Promise.all(
|
|
directories.map((cwd) => terminalManager.getTerminals(cwd)),
|
|
);
|
|
return terminalsByDirectory.flat().map((session) => {
|
|
const contribution: { cwd: string; workspaceId?: string; activity: TerminalActivity | null } =
|
|
{
|
|
cwd: session.cwd,
|
|
activity: session.getActivity(),
|
|
};
|
|
if (session.workspaceId) {
|
|
contribution.workspaceId = session.workspaceId;
|
|
}
|
|
return contribution;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build the current agent list payload (live + persisted), optionally filtered by labels.
|
|
*/
|
|
private async listAgentPayloads(filter?: {
|
|
labels?: Record<string, string>;
|
|
includeArchived?: boolean;
|
|
includeUnavailablePersisted?: boolean;
|
|
}): Promise<AgentSnapshotPayload[]> {
|
|
const includeArchived = filter?.includeArchived === true;
|
|
const labelEntries = filter?.labels ? Object.entries(filter.labels) : [];
|
|
|
|
// 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 = new Set(this.providerSnapshotManager.listRegisteredProviderIds());
|
|
const persistedAgents = registryRecords
|
|
.filter((record) => !liveIds.has(record.id) && !record.internal)
|
|
// Keep raw-record filters ahead of projection; seeded homes can carry thousands of archived agents.
|
|
.filter((record) => includeArchived || !record.archivedAt)
|
|
.filter((record) => labelEntries.every(([key, value]) => record.labels?.[key] === value))
|
|
.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));
|
|
if (!includeArchived) {
|
|
agents = agents.filter((agent) => !agent.archivedAt);
|
|
}
|
|
|
|
// Filter by labels if filter provided
|
|
if (labelEntries.length > 0) {
|
|
agents = agents.filter((agent) =>
|
|
labelEntries.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 resolveDelegationRootWorkspaceId(agentId: string): Promise<string | null> {
|
|
const seen = new Set<string>();
|
|
let currentAgentId = agentId;
|
|
|
|
while (true) {
|
|
if (seen.has(currentAgentId)) {
|
|
return null;
|
|
}
|
|
seen.add(currentAgentId);
|
|
|
|
const live = this.agentManager.getAgent(currentAgentId);
|
|
const source = live ?? (await this.agentStorage.get(currentAgentId));
|
|
if (!source) {
|
|
return null;
|
|
}
|
|
if ("archivedAt" in source && source.archivedAt) {
|
|
return null;
|
|
}
|
|
|
|
const parentAgentId = getParentAgentIdFromLabels(source.labels);
|
|
if (!parentAgentId) {
|
|
return source.workspaceId ?? null;
|
|
}
|
|
currentAgentId = parentAgentId;
|
|
}
|
|
}
|
|
|
|
private async buildActiveProjectPlacementsByWorkspaceId(): 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 placementsByWorkspaceId = 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) {
|
|
placementsByWorkspaceId.set(pairs[i].workspace.workspaceId, placements[i]);
|
|
}
|
|
|
|
return placementsByWorkspaceId;
|
|
}
|
|
|
|
private async collectFetchAgentsEntries(params: {
|
|
candidates: AgentSnapshotPayload[];
|
|
limit: number;
|
|
getPlacement: (workspaceId: string | undefined) => 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.workspaceId);
|
|
return project ? { agent, project } : null;
|
|
}),
|
|
);
|
|
for (const entry of batchEntries) {
|
|
if (!entry) {
|
|
continue;
|
|
}
|
|
if (
|
|
!matchesAgentUpdatesFilter({
|
|
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,
|
|
includeArchived: filter?.includeArchived,
|
|
includeUnavailablePersisted: request.type === "fetch_agent_history_request",
|
|
});
|
|
const activePlacementsByWorkspaceId =
|
|
scope === "active" ? await this.buildActiveProjectPlacementsByWorkspaceId() : null;
|
|
if (activePlacementsByWorkspaceId) {
|
|
agents = agents.filter(
|
|
(agent) =>
|
|
!agent.archivedAt &&
|
|
agent.workspaceId != null &&
|
|
activePlacementsByWorkspaceId.has(agent.workspaceId),
|
|
);
|
|
}
|
|
|
|
const placementByWorkspaceId = new Map<string, Promise<ProjectPlacementPayload | null>>();
|
|
const getPlacement = (
|
|
workspaceId: string | undefined,
|
|
): Promise<ProjectPlacementPayload | null> => {
|
|
if (!workspaceId) {
|
|
return Promise.resolve(null);
|
|
}
|
|
if (activePlacementsByWorkspaceId) {
|
|
return Promise.resolve(activePlacementsByWorkspaceId.get(workspaceId) ?? null);
|
|
}
|
|
const existing = placementByWorkspaceId.get(workspaceId);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
const placementPromise = this.buildProjectPlacementForWorkspaceId(workspaceId);
|
|
placementByWorkspaceId.set(workspaceId, 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: resolveWorkspaceDisplayName(workspace),
|
|
title: workspace.title,
|
|
pinnedAt: workspace.pinnedAt,
|
|
archivingAt: null,
|
|
status: "done",
|
|
statusEnteredAt: null,
|
|
activityAt: null,
|
|
diffStat,
|
|
scripts: this.buildWorkspaceScriptPayloadSnapshot(workspace, resolvedProjectRecord),
|
|
...(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.forge.featuresEnabled,
|
|
pullRequest: snapshot.forge.pullRequest,
|
|
error: snapshot.forge.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: resolveWorkspaceName({ title: workspace.title, derivedDisplayName: displayName }),
|
|
diffStat: snapshot.git.diffStat ?? null,
|
|
gitRuntime: this.buildWorkspaceGitRuntimePayload(snapshot) ?? undefined,
|
|
githubRuntime: this.buildWorkspaceGitHubRuntimePayload(snapshot),
|
|
// Reuse the forge already resolved on the snapshot (probe-aware; GitHub-only
|
|
// resolves to "github") so the sidebar/hover-card brand mark matches the
|
|
// status projection without a second resolve.
|
|
forge: snapshot.forge.forge,
|
|
};
|
|
}
|
|
|
|
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: projectRecord?.kind ?? "git",
|
|
workspaceKind: result.workspace.kind,
|
|
name: resolveWorkspaceName({
|
|
title: result.workspace.title,
|
|
derivedDisplayName: result.worktree.branchName || result.workspace.displayName,
|
|
}),
|
|
title: result.workspace.title,
|
|
pinnedAt: result.workspace.pinnedAt,
|
|
archivingAt: null,
|
|
status: "done",
|
|
statusEnteredAt: result.workspace.createdAt,
|
|
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.workspace.kind !== "directory") {
|
|
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);
|
|
}
|
|
|
|
// external path→workspace adapter, not ownership. Used by archive-by-path flows
|
|
// where the request carries a worktree path (unique to one workspace) rather
|
|
// than a workspaceId. This is a directory lookup for an archive target, not a
|
|
// status/ownership attribution.
|
|
private async findWorkspaceIdForCwd(cwd: string): Promise<string | null> {
|
|
const workspaces = await this.workspaceRegistry.list();
|
|
return resolveWorkspaceIdForPath(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[];
|
|
emptyProjects: WorkspaceProjectDescriptorPayload[];
|
|
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?: {
|
|
snapshotByWorkspaceId?: Map<
|
|
string,
|
|
{ status: string; statusEnteredAt: string | null; activityAtMs: number | null }
|
|
>;
|
|
}): 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 snapshot = options?.snapshotByWorkspaceId?.get(payload.workspace.id);
|
|
const updateActivityAtMs = payload.workspace.activityAt
|
|
? Date.parse(payload.workspace.activityAt)
|
|
: null;
|
|
const shouldEmit = shouldEmitPendingBootstrapUpdate({
|
|
snapshot: snapshot
|
|
? {
|
|
status: snapshot.status,
|
|
statusEnteredAt: snapshot.statusEnteredAt,
|
|
activityAtMs: snapshot.activityAtMs,
|
|
}
|
|
: null,
|
|
update: {
|
|
status: payload.workspace.status,
|
|
statusEnteredAt: payload.workspace.statusEnteredAt ?? null,
|
|
activityAtMs: Number.isNaN(updateActivityAtMs) ? null : updateActivityAtMs,
|
|
},
|
|
});
|
|
if (!shouldEmit) {
|
|
continue;
|
|
}
|
|
}
|
|
const workspaceId = payload.kind === "upsert" ? payload.workspace.id : payload.id;
|
|
subscription.lastEmittedByWorkspaceId.set(workspaceId, payload);
|
|
this.emit({
|
|
type: "workspace_update",
|
|
payload,
|
|
});
|
|
}
|
|
}
|
|
|
|
private buildProjectDescriptor(
|
|
project: PersistedProjectRecord,
|
|
): WorkspaceProjectDescriptorPayload {
|
|
return {
|
|
projectId: project.projectId,
|
|
projectDisplayName: resolveProjectDisplayName(project),
|
|
projectCustomName: project.customName ?? null,
|
|
projectRootPath: project.rootPath,
|
|
projectKind: project.kind,
|
|
};
|
|
}
|
|
|
|
private async restoreWorkspaceAndEmit(workspaceId: string): Promise<void> {
|
|
await this.workspaceRecovery.restore(workspaceId);
|
|
const workspace = await this.workspaceRegistry.get(workspaceId);
|
|
if (!workspace) {
|
|
throw new Error(`Recovered workspace record not found: ${workspaceId}`);
|
|
}
|
|
if (this.onWorkspaceRecovered) {
|
|
try {
|
|
await this.onWorkspaceRecovered(workspace);
|
|
return;
|
|
} catch (error) {
|
|
this.sessionLogger.warn(
|
|
{ err: error, workspaceId },
|
|
"Failed to publish workspace recovery to active sessions",
|
|
);
|
|
}
|
|
}
|
|
await this.refreshRecoveredWorkspaceForExternalMutation(workspace);
|
|
}
|
|
|
|
private async restoreOwningWorkspaceForLegacyAgentRefresh(agentId: string): Promise<void> {
|
|
// COMPAT(worktreeRestore): clients older than v0.1.105 used refresh_agent_request
|
|
// as their explicit recovery RPC. Remove after 2027-01-11.
|
|
if (!clientUsesLegacyWorkspaceRestore(this.appVersion)) {
|
|
return;
|
|
}
|
|
const record = await this.agentStorage.get(agentId);
|
|
if (!record?.workspaceId) {
|
|
return;
|
|
}
|
|
const recovery = await this.workspaceRecovery.inspect(record.workspaceId);
|
|
if (recovery.kind !== "recoverable") {
|
|
return;
|
|
}
|
|
await this.restoreWorkspaceAndEmit(record.workspaceId);
|
|
}
|
|
|
|
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 }
|
|
: {}),
|
|
workspaceGitService: this.workspaceGitService,
|
|
workspaceProvisioning: this.workspaceProvisioning,
|
|
});
|
|
void Promise.all([
|
|
this.gitMutation.notifyGitMutation(input.cwd, "create-worktree"),
|
|
this.gitMutation.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 listActiveWorkspaceRefs(): Promise<ActiveWorkspaceRef[]> {
|
|
const workspaces = await this.workspaceRegistry.list();
|
|
return workspaces
|
|
.filter((workspace) => !workspace.archivedAt)
|
|
.map((workspace) => ({
|
|
workspaceId: workspace.workspaceId,
|
|
cwd: workspace.cwd,
|
|
kind: workspace.kind,
|
|
worktreeRoot: workspace.worktreeRoot,
|
|
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
|
|
mainRepoRoot: workspace.mainRepoRoot,
|
|
}));
|
|
}
|
|
|
|
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
|
const archiveTimestamp = archivedAt ?? new Date().toISOString();
|
|
const existingWorkspace = await archivePersistedWorkspaceRecord({
|
|
workspaceId,
|
|
archivedAt: archiveTimestamp,
|
|
workspaceRegistry: this.workspaceRegistry,
|
|
});
|
|
if (!existingWorkspace) {
|
|
this.workspaceGitObserver.removeForWorkspaceId(workspaceId);
|
|
return;
|
|
}
|
|
|
|
if (!existingWorkspace.archivedAt) {
|
|
const activeSiblings = (await this.workspaceRegistry.list()).filter(
|
|
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
|
|
);
|
|
this.sessionLogger.info(
|
|
{
|
|
workspaceId,
|
|
workspaceCwd: existingWorkspace.cwd,
|
|
projectId: existingWorkspace.projectId,
|
|
projectArchived: activeSiblings.length === 0,
|
|
archivedAt: archiveTimestamp,
|
|
},
|
|
"Workspace archived",
|
|
);
|
|
}
|
|
|
|
await this.teardownArchivedWorkspace(existingWorkspace.workspaceId);
|
|
}
|
|
|
|
private async teardownArchivedWorkspace(workspaceId: string): Promise<void> {
|
|
this.workspaceGitObserver.removeForWorkspaceId(workspaceId);
|
|
this.scriptRuntimeStore?.removeForWorkspace(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.teardownArchivedWorkspace(change.workspaceId);
|
|
changedWorkspaceIds.add(change.workspaceId);
|
|
break;
|
|
case "workspace_updated":
|
|
changedWorkspaceIds.add(change.workspaceId);
|
|
break;
|
|
case "project_updated":
|
|
changedProjectIds.add(change.projectId);
|
|
break;
|
|
}
|
|
}),
|
|
);
|
|
|
|
if (changedProjectIds.size > 0) {
|
|
for (const workspaceId of workspaceIdsForProjects(
|
|
await this.workspaceRegistry.list(),
|
|
changedProjectIds,
|
|
)) {
|
|
changedWorkspaceIds.add(workspaceId);
|
|
}
|
|
}
|
|
|
|
return changedWorkspaceIds;
|
|
}
|
|
|
|
private async emitWorkspaceUpdatesForWorkspaceIds(
|
|
workspaceIds: Iterable<string>,
|
|
options?: { skipReconcile?: boolean; dedupeGitState?: boolean; removedProjectId?: string },
|
|
): 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.workspaceGitObserver.shouldSkipUpdate(workspaceId, nextWorkspace)
|
|
) {
|
|
continue;
|
|
}
|
|
this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace);
|
|
|
|
if (!nextWorkspace) {
|
|
if (workspace && !subscription.lastEmittedByWorkspaceId.has(workspaceId)) {
|
|
continue;
|
|
}
|
|
subscription.lastEmittedByWorkspaceId.delete(workspaceId);
|
|
this.bufferOrEmitWorkspaceUpdate(
|
|
subscription,
|
|
await this.buildWorkspaceRemoveUpdatePayload(workspaceId, options?.removedProjectId),
|
|
);
|
|
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 buildWorkspaceRemoveUpdatePayload(
|
|
workspaceId: string,
|
|
removedProjectId?: string,
|
|
): Promise<WorkspaceUpdatePayload> {
|
|
if (removedProjectId) {
|
|
return { kind: "remove", id: workspaceId, removedProjectId };
|
|
}
|
|
return {
|
|
kind: "remove",
|
|
id: workspaceId,
|
|
...(await this.resolveProjectWithoutActiveWorkspacesForArchivedWorkspace(workspaceId)),
|
|
};
|
|
}
|
|
|
|
// When a workspace is archived its project may have no active workspaces left.
|
|
// Resolve that project parent so the `remove` update can carry it, keeping the
|
|
// sidebar in sync without a full re-hydration.
|
|
private async resolveProjectWithoutActiveWorkspacesForArchivedWorkspace(
|
|
workspaceId: string,
|
|
): Promise<{ emptyProject: WorkspaceProjectDescriptorPayload } | null> {
|
|
const archivedWorkspace = await this.workspaceRegistry.get(workspaceId);
|
|
if (!archivedWorkspace) {
|
|
return null;
|
|
}
|
|
const projectWithoutActiveWorkspaces = (await this.workspaceDirectory.listEmptyProjects()).find(
|
|
(project) => project.projectId === archivedWorkspace.projectId,
|
|
);
|
|
return projectWithoutActiveWorkspaces ? { emptyProject: projectWithoutActiveWorkspaces } : null;
|
|
}
|
|
|
|
private async emitWorkspaceUpdateForTerminalContribution(
|
|
event: TerminalWorkspaceContributionChangedEvent,
|
|
): Promise<void> {
|
|
// A terminal's activity contributes only to the workspace it carries. A
|
|
// terminal with no workspaceId attributes to nothing — status is per-id.
|
|
if (!event.workspaceId) {
|
|
return;
|
|
}
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds([event.workspaceId], {
|
|
skipReconcile: true,
|
|
});
|
|
}
|
|
|
|
// A git fact (branch, diff, dirty, PR) changed at `cwd`. Every workspace whose
|
|
// OWN cwd is this folder re-derives its git facts from that folder (id → cwd)
|
|
// and emits its own per-id descriptor. This is a deliberate same-folder fan,
|
|
// not a cwd → id ownership lookup: git never resolves which workspace owns a
|
|
// path. See `workspaceIdsOnCheckout`.
|
|
private async emitWorkspaceUpdateForCwd(
|
|
cwd: string,
|
|
options?: {
|
|
skipReconcile?: boolean;
|
|
dedupeGitState?: boolean;
|
|
},
|
|
): Promise<void> {
|
|
const workspaceIds = workspaceIdsOnCheckout(await this.workspaceRegistry.list(), cwd);
|
|
if (workspaceIds.length === 0) {
|
|
return;
|
|
}
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, 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.agentUpdates.beginSubscription({
|
|
subscriptionId,
|
|
filter: request.filter,
|
|
});
|
|
}
|
|
|
|
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.agentUpdates.flushBootstrapped(subscriptionId, { snapshotUpdatedAtByAgentId });
|
|
}
|
|
} catch (error) {
|
|
if (subscriptionId) {
|
|
this.agentUpdates.clearSubscription(subscriptionId);
|
|
}
|
|
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.workspaceGitObserver.syncObservers(payload.entries);
|
|
this.sessionLogger.debug(
|
|
{
|
|
requestId: request.requestId,
|
|
subscriptionId,
|
|
pageInfo: payload.pageInfo,
|
|
payload: summarizeFetchWorkspacesEntries(payload.entries),
|
|
},
|
|
"fetch_workspaces_response_ready",
|
|
);
|
|
const snapshot = this.buildBootstrapSnapshot(payload.entries);
|
|
this.seedWorkspaceSubscriptionSnapshot(subscriptionId, request.filter, payload.entries);
|
|
|
|
this.emit({
|
|
type: "fetch_workspaces_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
...(subscriptionId ? { subscriptionId } : {}),
|
|
...payload,
|
|
},
|
|
});
|
|
|
|
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
|
|
this.flushBootstrappedWorkspaceUpdates(snapshot);
|
|
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,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// Build the bootstrap snapshot used by `flushBootstrappedWorkspaceUpdates`
|
|
// to decide which pending updates to drop. Captures the status,
|
|
// statusEnteredAt, and activityAt (parsed to ms) for each workspace entry
|
|
// so a status-only change (e.g. the unmask case), a statusEnteredAt-only
|
|
// change (e.g. a fresh unmask time), AND a fresher activity all still
|
|
// ship to the client.
|
|
private buildBootstrapSnapshot(entries: FetchWorkspacesResponseEntry[]): {
|
|
snapshotByWorkspaceId: Map<
|
|
string,
|
|
{ status: string; statusEnteredAt: string | null; activityAtMs: number | null }
|
|
>;
|
|
} {
|
|
const snapshotByWorkspaceId = new Map<
|
|
string,
|
|
{ status: string; statusEnteredAt: string | null; activityAtMs: number | null }
|
|
>();
|
|
for (const entry of entries) {
|
|
const parsedActivity = entry.activityAt ? Date.parse(entry.activityAt) : null;
|
|
snapshotByWorkspaceId.set(entry.id, {
|
|
status: entry.status,
|
|
statusEnteredAt: entry.statusEnteredAt ?? null,
|
|
activityAtMs: Number.isNaN(parsedActivity) ? null : parsedActivity,
|
|
});
|
|
}
|
|
return { snapshotByWorkspaceId };
|
|
}
|
|
|
|
private seedWorkspaceSubscriptionSnapshot(
|
|
subscriptionId: string | null,
|
|
filter: FetchWorkspacesRequestFilter | undefined,
|
|
entries: FetchWorkspacesResponseEntry[],
|
|
): void {
|
|
const subscription = this.workspaceUpdatesSubscription;
|
|
if (!subscription) return;
|
|
if (subscriptionId && subscription.subscriptionId !== subscriptionId) return;
|
|
if (!subscriptionId && !equal(subscription.filter, filter)) return;
|
|
for (const entry of entries) {
|
|
subscription.lastEmittedByWorkspaceId.set(entry.id, {
|
|
kind: "upsert",
|
|
workspace: entry,
|
|
});
|
|
}
|
|
}
|
|
|
|
private async registerWorkspaceForImportedAgent(
|
|
workspace: PersistedWorkspaceRecord,
|
|
): Promise<void> {
|
|
try {
|
|
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
|
await this.describeWorkspaceRecord(workspace);
|
|
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
|
} catch (error) {
|
|
this.sessionLogger.warn(
|
|
{ err: error, workspaceId: workspace.workspaceId, cwd: workspace.cwd },
|
|
"Failed to register workspace for imported agent",
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleWorkspaceCreateRequest(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
|
): Promise<void> {
|
|
try {
|
|
if (request.source.kind === "directory") {
|
|
await this.handleWorkspaceCreateLocal(request);
|
|
return;
|
|
}
|
|
await this.handleWorkspaceCreateWorktree(request);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Failed to create workspace";
|
|
this.sessionLogger.error(
|
|
{ err: error, sourceKind: request.source.kind, requestId: request.requestId },
|
|
"Failed to create workspace",
|
|
);
|
|
const errorCode = error instanceof WorkspaceProvisioningError ? error.code : undefined;
|
|
this.emit({
|
|
type: "workspace.create.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
setupTerminalId: null,
|
|
error: message,
|
|
errorCode,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleWorkspaceCreateLocal(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
|
): Promise<void> {
|
|
if (request.source.kind !== "directory") {
|
|
return;
|
|
}
|
|
|
|
const cwd = expandTilde(request.source.path);
|
|
const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false);
|
|
if (!directoryExists) {
|
|
this.emit({
|
|
type: "workspace.create.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
setupTerminalId: null,
|
|
error: `Directory not found: ${cwd}`,
|
|
errorCode: "directory_not_found",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const explicitTitle = request.title?.trim() || null;
|
|
const promptTitle = resolveFirstAgentPromptTitle(request.firstAgentContext);
|
|
const workspace = await this.workspaceProvisioning.createWorkspaceForDirectory(
|
|
cwd,
|
|
explicitTitle ?? promptTitle,
|
|
request.source.projectId,
|
|
);
|
|
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
|
const descriptor = await this.describeWorkspaceRecord(workspace);
|
|
this.emit({
|
|
type: "workspace.create.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: descriptor,
|
|
setupTerminalId: null,
|
|
error: null,
|
|
},
|
|
});
|
|
await this.emitCreatedWorkspaceUpdate(descriptor);
|
|
void this.workspaceGitService
|
|
.getSnapshot(workspace.cwd, { force: true, includeForge: true, reason: "open_project" })
|
|
.catch((error) => {
|
|
this.sessionLogger.warn(
|
|
{ err: error, cwd: workspace.cwd },
|
|
"Background snapshot refresh failed after workspace.create",
|
|
);
|
|
});
|
|
if (request.firstAgentContext) {
|
|
const firstAgentContext = request.firstAgentContext;
|
|
this.workspaceAutoName.scheduleForDirectory(
|
|
{
|
|
workspaceId: workspace.workspaceId,
|
|
cwd: workspace.cwd,
|
|
firstAgentContext,
|
|
},
|
|
{ currentSelection: this.getFocusedAgentSelectionForCwd(workspace.cwd) },
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleWorkspaceCreateWorktree(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
|
): Promise<void> {
|
|
if (request.source.kind !== "worktree") {
|
|
return;
|
|
}
|
|
|
|
const source = request.source;
|
|
|
|
if (!source.cwd && !source.projectId) {
|
|
this.emit({
|
|
type: "workspace.create.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
setupTerminalId: null,
|
|
error: "cwd or projectId is required for a worktree-backed workspace",
|
|
errorCode: "source_required",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
const sourceCwd = await resolveWorktreeSourceCwd(source, this.projectRegistry);
|
|
|
|
const result = await this.createPaseoWorktreeWorkflow(
|
|
{
|
|
cwd: sourceCwd,
|
|
projectId: source.projectId,
|
|
worktreeSlug: source.worktreeSlug,
|
|
action: source.action,
|
|
refName: source.refName,
|
|
branchName: source.branchName,
|
|
checkoutSource: source.checkoutSource,
|
|
githubPrNumber: source.githubPrNumber,
|
|
firstAgentContext: request.firstAgentContext,
|
|
title: request.title,
|
|
},
|
|
source.baseBranch
|
|
? { resolveDefaultBranch: async () => source.baseBranch as string }
|
|
: undefined,
|
|
);
|
|
|
|
const descriptor = await this.describeCreatedWorktreeWorkspace(result);
|
|
this.emit({
|
|
type: "workspace.create.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: descriptor,
|
|
setupTerminalId: null,
|
|
error: null,
|
|
},
|
|
});
|
|
await this.emitCreatedWorkspaceUpdate(descriptor);
|
|
}
|
|
|
|
private async handleOpenProjectRequest(
|
|
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
|
|
): Promise<void> {
|
|
const requestedCwd = request.cwd;
|
|
const cwd = expandTilde(requestedCwd);
|
|
const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false);
|
|
if (!directoryExists) {
|
|
this.sessionLogger.info(
|
|
{ requestedCwd, resolvedCwd: cwd, reason: "directory_not_found" },
|
|
"Open project rejected",
|
|
);
|
|
this.emit({
|
|
type: "open_project_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
error: `Directory not found: ${cwd}`,
|
|
errorCode: "directory_not_found",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const projectsBefore = new Map<string, PersistedProjectRecord>();
|
|
for (const project of await this.projectRegistry.list()) {
|
|
projectsBefore.set(project.projectId, project);
|
|
}
|
|
const workspacesBefore = new Map<string, PersistedWorkspaceRecord>();
|
|
for (const workspaceRecord of await this.workspaceRegistry.list()) {
|
|
workspacesBefore.set(workspaceRecord.workspaceId, workspaceRecord);
|
|
}
|
|
const workspace = await this.workspaceProvisioning.findOrCreateWorkspaceForDirectory(cwd);
|
|
const project = await this.projectRegistry.get(workspace.projectId);
|
|
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
|
const descriptor = await this.describeWorkspaceRecord(workspace);
|
|
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
|
this.sessionLogger.info(
|
|
{
|
|
requestedCwd,
|
|
resolvedCwd: cwd,
|
|
workspaceCwd: workspace.cwd,
|
|
workspaceId: workspace.workspaceId,
|
|
workspaceKind: workspace.kind,
|
|
workspaceTransition: describeRegistryTransition(
|
|
workspacesBefore.get(workspace.workspaceId) ?? null,
|
|
),
|
|
projectId: workspace.projectId,
|
|
projectKind: project?.kind ?? null,
|
|
projectTransition: describeRegistryTransition(
|
|
projectsBefore.get(workspace.projectId) ?? null,
|
|
),
|
|
},
|
|
"Project opened",
|
|
);
|
|
this.emit({
|
|
type: "open_project_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: descriptor,
|
|
error: null,
|
|
},
|
|
});
|
|
void this.workspaceGitService
|
|
.getSnapshot(workspace.cwd, {
|
|
force: true,
|
|
includeForge: 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 }, "Failed to open project");
|
|
this.emit({
|
|
type: "open_project_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
error: message,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleProjectAddRequest(
|
|
request: Extract<SessionInboundMessage, { type: "project.add.request" }>,
|
|
): Promise<void> {
|
|
const requestedCwd = request.cwd;
|
|
const cwd = expandTilde(requestedCwd);
|
|
const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false);
|
|
if (!directoryExists) {
|
|
this.sessionLogger.info(
|
|
{ requestedCwd, resolvedCwd: cwd, reason: "directory_not_found" },
|
|
"Add project rejected",
|
|
);
|
|
this.emit({
|
|
type: "project.add.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
project: null,
|
|
error: `Directory not found: ${cwd}`,
|
|
errorCode: "directory_not_found",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const projectsBefore = new Map<string, PersistedProjectRecord>();
|
|
for (const project of await this.projectRegistry.list()) {
|
|
projectsBefore.set(project.projectId, project);
|
|
}
|
|
const project = await this.workspaceProvisioning.findOrCreateProjectForDirectory(cwd);
|
|
this.sessionLogger.info(
|
|
{
|
|
requestedCwd,
|
|
resolvedCwd: cwd,
|
|
projectId: project.projectId,
|
|
projectKind: project.kind,
|
|
projectTransition: describeRegistryTransition(
|
|
projectsBefore.get(project.projectId) ?? null,
|
|
),
|
|
},
|
|
"Project added",
|
|
);
|
|
this.emit({
|
|
type: "project.add.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
project: this.buildProjectDescriptor(project),
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Failed to add project";
|
|
this.sessionLogger.error({ err: error, cwd }, "Failed to add project");
|
|
this.emit({
|
|
type: "project.add.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
project: null,
|
|
error: message,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleProjectCreateDirectoryRequest(
|
|
request: Extract<SessionInboundMessage, { type: "project.create_directory.request" }>,
|
|
): Promise<void> {
|
|
try {
|
|
const result = await createProjectDirectory(
|
|
{ parentPath: request.parentPath, name: request.name },
|
|
{
|
|
registerProject: (directoryPath) =>
|
|
this.workspaceProvisioning.findOrCreateProjectForDirectory(directoryPath),
|
|
},
|
|
);
|
|
this.emit({
|
|
type: "project.create_directory.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
directoryPath: result.directoryPath,
|
|
project: this.buildProjectDescriptor(result.project),
|
|
error: null,
|
|
errorCode: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const requestError =
|
|
error instanceof ProjectDirectoryRequestError
|
|
? error
|
|
: new ProjectDirectoryRequestError(
|
|
"registration_failed",
|
|
error instanceof Error ? error.message : "Failed to create project directory",
|
|
);
|
|
this.sessionLogger.error(
|
|
{
|
|
err: error,
|
|
parentPath: request.parentPath,
|
|
name: request.name,
|
|
errorCode: requestError.code,
|
|
},
|
|
"Failed to create project directory",
|
|
);
|
|
this.emit({
|
|
type: "project.create_directory.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
directoryPath: requestError.directoryPath,
|
|
project: null,
|
|
error: requestError.message,
|
|
errorCode: requestError.code,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleWorkspaceGithubSearchRepositoriesRequest(
|
|
request: Extract<
|
|
SessionInboundMessage,
|
|
{ type: "workspace.github.search_repositories.request" }
|
|
>,
|
|
): Promise<void> {
|
|
try {
|
|
const searchRepositories = (this.github as Partial<GitHubService>).searchRepositories;
|
|
if (!searchRepositories) {
|
|
throw new Error("GitHub repository search is unavailable");
|
|
}
|
|
const repositories = await searchRepositories.call(this.github, {
|
|
cwd: homedir(),
|
|
query: request.query,
|
|
limit: request.limit,
|
|
});
|
|
this.emit({
|
|
type: "workspace.github.search_repositories.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
repositories,
|
|
status: "success",
|
|
available: true,
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const missing = error instanceof GitHubCliMissingError;
|
|
const unauthenticated = error instanceof GitHubAuthenticationError;
|
|
const commandError = error instanceof GitHubCommandError ? error.stderr.trim() : "";
|
|
let message: string;
|
|
if (missing) {
|
|
message = "GitHub CLI (gh) is not installed or not in PATH";
|
|
} else if (unauthenticated) {
|
|
message = "GitHub CLI is not authenticated. Run gh auth login on the host.";
|
|
} else if (commandError) {
|
|
message = commandError;
|
|
} else {
|
|
message = error instanceof Error ? error.message : "GitHub search failed";
|
|
}
|
|
let payload: WorkspaceGithubSearchRepositoriesResponsePayload;
|
|
if (missing) {
|
|
payload = {
|
|
status: "unavailable",
|
|
requestId: request.requestId,
|
|
repositories: [],
|
|
reason: "gh_missing",
|
|
available: false,
|
|
error: message,
|
|
};
|
|
} else if (unauthenticated) {
|
|
payload = {
|
|
status: "unauthenticated",
|
|
requestId: request.requestId,
|
|
repositories: [],
|
|
available: false,
|
|
error: message,
|
|
};
|
|
} else {
|
|
payload = {
|
|
status: "error",
|
|
requestId: request.requestId,
|
|
repositories: [],
|
|
available: true,
|
|
error: message,
|
|
};
|
|
}
|
|
this.sessionLogger.warn({ err: error }, "GitHub repository search failed");
|
|
this.emit({
|
|
type: "workspace.github.search_repositories.response",
|
|
payload,
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleProjectGithubCloneRequest(
|
|
request: Extract<SessionInboundMessage, { type: "project.github.clone.request" }>,
|
|
): Promise<void> {
|
|
let normalizedRepo = request.repo;
|
|
let checkoutPath: string | null = null;
|
|
try {
|
|
const repo = normalizeCloneRepository({
|
|
repo: request.repo,
|
|
cloneProtocol: request.cloneProtocol,
|
|
});
|
|
normalizedRepo = repo.displayName;
|
|
const targetParent = resolve(expandTilde(request.targetDirectory.trim()));
|
|
checkoutPath = resolve(targetParent, repo.name);
|
|
if (!this.isPathWithinRoot(targetParent, checkoutPath)) {
|
|
throw new Error("Resolved checkout path must stay inside the target directory");
|
|
}
|
|
|
|
await mkdir(targetParent, { recursive: true });
|
|
try {
|
|
await lstat(checkoutPath);
|
|
throw new Error(`Checkout path already exists: ${checkoutPath}`);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const cloneStagingPath = await mkdtemp(resolve(targetParent, ".paseo-clone-"));
|
|
try {
|
|
await runGitCommand(["clone", repo.cloneUrl, cloneStagingPath], {
|
|
cwd: targetParent,
|
|
timeout: 5 * 60 * 1000,
|
|
maxOutputBytes: 1024 * 1024,
|
|
logger: this.sessionLogger,
|
|
});
|
|
await rename(cloneStagingPath, checkoutPath);
|
|
} catch (error) {
|
|
await rm(cloneStagingPath, { recursive: true, force: true }).catch((cleanupError) => {
|
|
this.sessionLogger.warn(
|
|
{ err: cleanupError, cloneStagingPath },
|
|
"Failed to clean up partial GitHub clone",
|
|
);
|
|
});
|
|
throw error;
|
|
}
|
|
|
|
const project =
|
|
await this.workspaceProvisioning.findOrCreateProjectForDirectory(checkoutPath);
|
|
|
|
this.emit({
|
|
type: "project.github.clone.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
repo: repo.displayName,
|
|
checkoutPath,
|
|
project: this.buildProjectDescriptor(project),
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Failed to clone GitHub repo";
|
|
this.sessionLogger.error(
|
|
{ err: error, repo: request.repo, targetDirectory: request.targetDirectory },
|
|
"Failed to clone GitHub project",
|
|
);
|
|
this.emit({
|
|
type: "project.github.clone.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
repo: normalizedRepo,
|
|
checkoutPath,
|
|
project: null,
|
|
error: message,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// Named accessor: the workspace descriptor builder and the git-watch test both read a workspace's
|
|
// scripts snapshot through here; the workspace-scripts module owns the payload assembly.
|
|
private buildWorkspaceScriptPayloadSnapshot(
|
|
workspace: PersistedWorkspaceRecord,
|
|
project: PersistedProjectRecord | null,
|
|
): WorkspaceDescriptorPayload["scripts"] {
|
|
return this.workspaceScripts.buildSnapshot(workspace, project);
|
|
}
|
|
|
|
private handleStartWorkspaceScriptRequest(request: StartWorkspaceScriptRequest): Promise<void> {
|
|
return this.workspaceScripts.start(request);
|
|
}
|
|
|
|
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
|
|
private async handleLegacyListAvailableEditorsRequest(
|
|
request: Extract<SessionInboundMessage, { type: "list_available_editors_request" }>,
|
|
): Promise<void> {
|
|
this.emit({
|
|
type: "list_available_editors_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
editors: [],
|
|
error: "Editor opening moved to the desktop app and is no longer supported by the daemon",
|
|
},
|
|
});
|
|
}
|
|
|
|
private async handleLegacyOpenInEditorRequest(
|
|
request: Extract<SessionInboundMessage, { type: "open_in_editor_request" }>,
|
|
): Promise<void> {
|
|
this.emit({
|
|
type: "open_in_editor_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
error: "Editor opening moved to the desktop app and is no longer supported by the daemon",
|
|
},
|
|
});
|
|
}
|
|
|
|
private async handleCreatePaseoWorktreeRequest(
|
|
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
|
|
): Promise<void> {
|
|
return handleCreateWorktreeRequest(
|
|
{
|
|
paseoHome: this.paseoHome,
|
|
worktreesRoot: this.worktreesRoot,
|
|
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,
|
|
worktreesRoot: this.worktreesRoot,
|
|
createPaseoWorktree: (workflowInput, serviceOptions) =>
|
|
this.createPaseoWorktree(workflowInput, serviceOptions),
|
|
warmWorkspaceGitData: (workspace) => this.warmWorkspaceGitDataForWorkspace(workspace),
|
|
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
|
|
this.workspaceAutoName.scheduleForWorktree(autoNameInput, {
|
|
currentSelection: this.getFocusedAgentSelectionForCwd(autoNameInput.workspace.cwd),
|
|
}),
|
|
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
|
|
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
|
|
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
|
|
this.workspaceSetupSnapshots.set(workspaceId, snapshot);
|
|
},
|
|
emit: (message) => this.emit(message),
|
|
sessionLogger: this.sessionLogger,
|
|
terminalManager: this.terminalManager,
|
|
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
|
serviceProxy: this.serviceProxy,
|
|
scriptRuntimeStore: this.scriptRuntimeStore,
|
|
getDaemonTcpPort: this.getDaemonTcpPort,
|
|
getDaemonTcpHost: this.getDaemonTcpHost,
|
|
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
|
onScriptsChanged: (workspaceId, workspaceDirectory) => {
|
|
this.workspaceScripts.emitStatusUpdate(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 requireActiveWorkspaceForArchive(
|
|
{ listActiveWorkspaces: () => this.listActiveWorkspaceRefs() },
|
|
request.workspaceId,
|
|
);
|
|
|
|
await archiveByScope(
|
|
{
|
|
paseoHome: this.paseoHome,
|
|
paseoWorktreesBaseRoot: this.worktreesRoot,
|
|
github: this.github,
|
|
workspaceGitService: this.workspaceGitService,
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
|
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
|
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
|
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
|
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
|
|
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
|
|
this.markWorkspaceArchiving(workspaceIds, archivingAt),
|
|
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
|
|
killTerminalsForWorkspace: (workspaceId) =>
|
|
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
|
sessionLogger: this.sessionLogger,
|
|
},
|
|
{
|
|
scope: { kind: "workspace", workspaceId: existing.workspaceId },
|
|
requestId: request.requestId,
|
|
},
|
|
);
|
|
|
|
const archivedWorkspace = await this.workspaceRegistry.get(request.workspaceId);
|
|
const archivedAt = archivedWorkspace?.archivedAt ?? new Date().toISOString();
|
|
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 handleWorkspaceClearAttentionRequest(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.clear_attention.request" }>,
|
|
): Promise<void> {
|
|
const { requestId, workspaceId } = request;
|
|
const requestedWorkspaceIds = Array.isArray(workspaceId) ? workspaceId : [workspaceId];
|
|
let agents: AgentSnapshotPayload[];
|
|
try {
|
|
agents = await this.listAgentPayloads();
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
const results = requestedWorkspaceIds.map((requestedWorkspaceId) => ({
|
|
workspaceId: requestedWorkspaceId,
|
|
clearedAgentIds: [],
|
|
success: false,
|
|
error: message,
|
|
}));
|
|
this.emit({
|
|
type: "workspace.clear_attention.response",
|
|
payload: {
|
|
requestId,
|
|
workspaceId,
|
|
clearedAgentIds: [],
|
|
results,
|
|
success: false,
|
|
error: message,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
const results: Array<{
|
|
workspaceId: string;
|
|
clearedAgentIds: string[];
|
|
success: boolean;
|
|
error: string | null;
|
|
}> = [];
|
|
|
|
for (const requestedWorkspaceId of requestedWorkspaceIds) {
|
|
const clearedAgentIds: string[] = [];
|
|
try {
|
|
const workspace = await this.workspaceRegistry.get(requestedWorkspaceId);
|
|
if (!workspace || workspace.archivedAt) {
|
|
throw new Error(`Workspace not found: ${requestedWorkspaceId}`);
|
|
}
|
|
|
|
// Clearing attention is scoped to the workspace that OWNS the agent, by
|
|
// workspaceId — never by comparing cwd strings. A sibling workspace
|
|
// sharing the same directory keeps its own agents' attention.
|
|
const clearableAgentIds = agents
|
|
.filter((agent) => !agent.archivedAt)
|
|
.filter((agent) => agent.workspaceId === workspace.workspaceId)
|
|
.filter((agent) => agent.requiresAttention === true)
|
|
.filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0)
|
|
.filter((agent) => agent.attentionReason !== "permission")
|
|
.map((agent) => agent.id);
|
|
|
|
for (const agentId of clearableAgentIds) {
|
|
const liveAgent = this.agentManager.getAgent(agentId);
|
|
if (liveAgent) {
|
|
await this.agentManager.clearAgentAttention(agentId);
|
|
clearedAgentIds.push(agentId);
|
|
continue;
|
|
}
|
|
|
|
const record = await this.agentStorage.get(agentId);
|
|
if (
|
|
!record ||
|
|
record.internal ||
|
|
record.archivedAt ||
|
|
record.requiresAttention !== true
|
|
) {
|
|
continue;
|
|
}
|
|
const nextRecord: StoredAgentRecord = {
|
|
...record,
|
|
updatedAt: new Date().toISOString(),
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
attentionTimestamp: null,
|
|
};
|
|
await this.agentStorage.upsert(nextRecord);
|
|
const agent = this.buildStoredAgentPayload(nextRecord);
|
|
const project = await this.buildProjectPlacementForWorkspace(workspace);
|
|
this.emit({
|
|
type: "agent_update",
|
|
payload: {
|
|
kind: "upsert",
|
|
agent,
|
|
project,
|
|
},
|
|
});
|
|
clearedAgentIds.push(agentId);
|
|
}
|
|
|
|
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
|
results.push({
|
|
workspaceId: requestedWorkspaceId,
|
|
clearedAgentIds,
|
|
success: true,
|
|
error: null,
|
|
});
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.sessionLogger.error(
|
|
{ err: error, workspaceId: requestedWorkspaceId },
|
|
"Failed to clear workspace attention",
|
|
);
|
|
results.push({
|
|
workspaceId: requestedWorkspaceId,
|
|
clearedAgentIds,
|
|
success: false,
|
|
error: message,
|
|
});
|
|
}
|
|
}
|
|
|
|
const clearedAgentIds = results.flatMap((result) => result.clearedAgentIds);
|
|
const failedResults = results.filter((result) => !result.success);
|
|
this.emit({
|
|
type: "workspace.clear_attention.response",
|
|
payload: {
|
|
requestId,
|
|
workspaceId,
|
|
clearedAgentIds,
|
|
results,
|
|
success: failedResults.length === 0,
|
|
error:
|
|
failedResults.length === 0
|
|
? null
|
|
: failedResults
|
|
.map((result) => result.error)
|
|
.filter((error) => error !== null)
|
|
.join("; "),
|
|
},
|
|
});
|
|
}
|
|
|
|
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 = agent.workspaceId
|
|
? await this.buildProjectPlacementForWorkspaceId(agent.workspaceId)
|
|
: null;
|
|
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 selectCanonicalTimelineProjection(input: {
|
|
timeline: AgentTimelineFetchResult;
|
|
}): AgentTimelineProjectionSelection {
|
|
const entries = projectTimelineRows({ rows: input.timeline.rows, mode: "canonical" });
|
|
return {
|
|
timeline: input.timeline,
|
|
entries,
|
|
startSeq: entries[0]?.seqStart ?? null,
|
|
endSeq: entries[entries.length - 1]?.seqEnd ?? null,
|
|
hasOlder: input.timeline.hasOlder,
|
|
hasNewer: input.timeline.hasNewer,
|
|
};
|
|
}
|
|
|
|
private selectProjectedTimelineProjection(input: {
|
|
agentId: string;
|
|
controlTimeline: AgentTimelineFetchResult;
|
|
direction: AgentTimelineFetchDirection;
|
|
cursor?: AgentTimelineCursor;
|
|
pageLimit: number;
|
|
}): AgentTimelineProjectionSelection {
|
|
const timeline = this.shouldUseFullTimelineForProjectedPage({
|
|
timeline: input.controlTimeline,
|
|
})
|
|
? this.agentManager.fetchTimeline(input.agentId, { direction: "tail", limit: 0 })
|
|
: input.controlTimeline;
|
|
const page = selectProjectedTimelinePage({
|
|
rows: timeline.rows,
|
|
bounds: timeline.window,
|
|
direction: input.controlTimeline.reset ? "tail" : input.direction,
|
|
...(input.cursor ? { cursorSeq: input.cursor.seq } : {}),
|
|
limit: input.pageLimit,
|
|
});
|
|
|
|
return {
|
|
timeline,
|
|
entries: page.entries,
|
|
startSeq: page.startSeq,
|
|
endSeq: page.endSeq,
|
|
hasOlder: page.hasOlder || (page.startSeq !== null && page.startSeq > timeline.window.minSeq),
|
|
hasNewer: page.hasNewer,
|
|
};
|
|
}
|
|
|
|
private selectTimelineProjection(input: {
|
|
agentId: string;
|
|
projection: TimelineProjectionMode;
|
|
controlTimeline: AgentTimelineFetchResult;
|
|
direction: AgentTimelineFetchDirection;
|
|
cursor?: AgentTimelineCursor;
|
|
pageLimit: number;
|
|
}): AgentTimelineProjectionSelection {
|
|
if (input.projection === "canonical") {
|
|
return this.selectCanonicalTimelineProjection({ timeline: input.controlTimeline });
|
|
}
|
|
|
|
return this.selectProjectedTimelineProjection(input);
|
|
}
|
|
|
|
private async handleFetchAgentTimelineRequest(
|
|
msg: Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>,
|
|
): Promise<void> {
|
|
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
|
|
const projection: TimelineProjectionMode = msg.projection ?? "projected";
|
|
const requestedLimit = msg.limit;
|
|
const 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 selectedTimeline = this.selectTimelineProjection({
|
|
agentId: msg.agentId,
|
|
projection,
|
|
controlTimeline,
|
|
direction,
|
|
...(cursor ? { cursor } : {}),
|
|
pageLimit,
|
|
});
|
|
const startCursor =
|
|
selectedTimeline.startSeq !== null
|
|
? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.startSeq }
|
|
: null;
|
|
const endCursor =
|
|
selectedTimeline.endSeq !== null
|
|
? { epoch: selectedTimeline.timeline.epoch, seq: selectedTimeline.endSeq }
|
|
: null;
|
|
|
|
this.emit({
|
|
type: "fetch_agent_timeline_response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
agentId: msg.agentId,
|
|
agent: agentPayload,
|
|
direction,
|
|
projection,
|
|
epoch: selectedTimeline.timeline.epoch,
|
|
reset: controlTimeline.reset,
|
|
staleCursor: controlTimeline.staleCursor,
|
|
gap: controlTimeline.gap,
|
|
window: selectedTimeline.timeline.window,
|
|
startCursor,
|
|
endCursor,
|
|
hasOlder: selectedTimeline.hasOlder,
|
|
hasNewer: selectedTimeline.hasNewer,
|
|
entries: selectedTimeline.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 handleProviderSubagentListRequest(
|
|
msg: Extract<SessionInboundMessage, { type: "agent.provider_subagents.list.request" }>,
|
|
): Promise<void> {
|
|
try {
|
|
await ensureUnarchivedAgentLoaded(msg.parentAgentId, {
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.emit({
|
|
type: "agent.provider_subagents.list.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
parentAgentId: msg.parentAgentId,
|
|
subagents: this.agentManager.listProviderSubagents(msg.parentAgentId),
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.emit({
|
|
type: "agent.provider_subagents.list.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
parentAgentId: msg.parentAgentId,
|
|
subagents: [],
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleProviderSubagentTimelineRequest(
|
|
msg: Extract<SessionInboundMessage, { type: "agent.provider_subagents.timeline.get.request" }>,
|
|
): Promise<void> {
|
|
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
|
|
try {
|
|
await ensureUnarchivedAgentLoaded(msg.parentAgentId, {
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
logger: this.sessionLogger,
|
|
});
|
|
const descriptor = this.agentManager.getProviderSubagent(msg.parentAgentId, msg.subagentId);
|
|
if (!descriptor) {
|
|
throw new Error("Provider subagent not found");
|
|
}
|
|
const timeline = this.agentManager.fetchProviderSubagentTimeline(
|
|
msg.parentAgentId,
|
|
msg.subagentId,
|
|
{
|
|
direction,
|
|
cursor: msg.cursor,
|
|
limit: msg.limit ?? (direction === "after" ? 0 : 200),
|
|
},
|
|
);
|
|
this.emit({
|
|
type: "agent.provider_subagents.timeline.get.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
parentAgentId: msg.parentAgentId,
|
|
subagentId: msg.subagentId,
|
|
provider: descriptor.provider,
|
|
direction,
|
|
epoch: timeline.epoch,
|
|
reset: timeline.reset,
|
|
staleCursor: timeline.staleCursor,
|
|
gap: timeline.gap,
|
|
window: timeline.window,
|
|
hasOlder: timeline.hasOlder,
|
|
hasNewer: timeline.hasNewer,
|
|
rows: timeline.rows.map((row) => ({
|
|
item: row.item,
|
|
timestamp: row.timestamp,
|
|
seq: row.seq,
|
|
})),
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.emit({
|
|
type: "agent.provider_subagents.timeline.get.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
parentAgentId: msg.parentAgentId,
|
|
subagentId: msg.subagentId,
|
|
provider: null,
|
|
direction,
|
|
epoch: "",
|
|
reset: false,
|
|
staleCursor: false,
|
|
gap: false,
|
|
window: { minSeq: 0, maxSeq: 0, nextSeq: 0 },
|
|
hasOlder: false,
|
|
hasNewer: false,
|
|
rows: [],
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleAgentForkContextRequest(
|
|
msg: Extract<SessionInboundMessage, { type: "agent.fork_context.request" }>,
|
|
): Promise<void> {
|
|
try {
|
|
const snapshot = await ensureAgentLoaded(msg.agentId, {
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
logger: this.sessionLogger,
|
|
});
|
|
const agentPayload = await this.buildAgentPayload(snapshot);
|
|
const timeline = this.agentManager.fetchTimeline(msg.agentId, {
|
|
direction: "tail",
|
|
limit: 0,
|
|
});
|
|
const forkContext = buildAgentForkContextAttachment({
|
|
rows: timeline.rows,
|
|
cursorBoundary: msg.boundaryCursor
|
|
? { timelineEpoch: timeline.epoch, cursor: msg.boundaryCursor }
|
|
: null,
|
|
boundaryMessageId: msg.boundaryMessageId,
|
|
agentTitle: agentPayload.title,
|
|
cwd: snapshot.cwd,
|
|
});
|
|
|
|
this.emit({
|
|
type: "agent.fork_context.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
agentId: msg.agentId,
|
|
attachment: forkContext.attachment,
|
|
itemCount: forkContext.itemCount,
|
|
boundaryCursor: forkContext.boundaryCursor,
|
|
boundaryMessageId: forkContext.boundaryMessageId,
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.sessionLogger.error(
|
|
{ err: error, agentId: msg.agentId },
|
|
"Failed to handle agent.fork_context.request",
|
|
);
|
|
this.emit({
|
|
type: "agent.fork_context.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
agentId: msg.agentId,
|
|
attachment: null,
|
|
itemCount: 0,
|
|
boundaryCursor: msg.boundaryCursor ?? null,
|
|
boundaryMessageId: msg.boundaryMessageId ?? null,
|
|
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 = 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit a message to the client
|
|
*/
|
|
private emit(msg: SessionOutboundMessage): void {
|
|
if (msg.type !== "rpc_error" && !isSessionRpcAllowed(this.scopes, msg.type)) {
|
|
return;
|
|
}
|
|
// JSON.stringify(msg) is only computed when trace is enabled — it runs for
|
|
// every outbound message otherwise, and trace is disabled by default.
|
|
// Optional-chained because test logger stubs don't implement isLevelEnabled.
|
|
if (this.sessionLogger.isLevelEnabled?.("trace")) {
|
|
this.sessionLogger.trace(
|
|
{
|
|
messageType: msg.type,
|
|
payloadBytes: JSON.stringify(msg).length,
|
|
},
|
|
"agent.session.outbound",
|
|
);
|
|
}
|
|
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;
|
|
}
|
|
this.agentUpdates.dispose();
|
|
await this.hubExecutionController?.cleanup();
|
|
if (this.unsubscribeTerminalWorkspaceContributionEvents) {
|
|
this.unsubscribeTerminalWorkspaceContributionEvents();
|
|
this.unsubscribeTerminalWorkspaceContributionEvents = null;
|
|
}
|
|
this.providerCatalogSession.dispose();
|
|
|
|
await this.voiceSession.cleanup();
|
|
|
|
this.terminalController.dispose();
|
|
|
|
this.checkoutSession.cleanup();
|
|
|
|
this.workspaceGitObserver.dispose();
|
|
}
|
|
}
|
|
|
|
interface CloneRepositoryInput {
|
|
name: string;
|
|
displayName: string;
|
|
cloneUrl: string;
|
|
}
|
|
|
|
function normalizeCloneRepository(input: {
|
|
repo: string;
|
|
cloneProtocol?: "https" | "ssh";
|
|
}): CloneRepositoryInput {
|
|
const trimmed = input.repo.trim();
|
|
if (!trimmed) {
|
|
throw new Error("Repository is required");
|
|
}
|
|
|
|
const remote = parseGitRemoteLocation(trimmed);
|
|
if (remote) {
|
|
const segments = remote.path.split("/").filter(Boolean);
|
|
const name = segments.at(-1);
|
|
if (!name || !isValidGitHubRepoSegment(name)) {
|
|
throw new Error("Repository name contains invalid characters");
|
|
}
|
|
return { name, displayName: remote.path, cloneUrl: trimmed };
|
|
}
|
|
|
|
const [owner, rawName, ...extra] = trimmed.split("/");
|
|
if (!owner || !rawName || extra.length > 0) {
|
|
throw new Error("Repository must use owner/repo format or a git remote URL");
|
|
}
|
|
const name = rawName.endsWith(".git") ? rawName.slice(0, -4) : rawName;
|
|
if (!isValidGitHubRepoSegment(owner) || !isValidGitHubRepoSegment(name)) {
|
|
throw new Error("Repository contains invalid characters");
|
|
}
|
|
if (!input.cloneProtocol) {
|
|
throw new Error("Clone protocol is required for owner/repo repository names");
|
|
}
|
|
const cloneUrl =
|
|
input.cloneProtocol === "ssh"
|
|
? `git@github.com:${owner}/${name}.git`
|
|
: `https://github.com/${owner}/${name}.git`;
|
|
return {
|
|
name,
|
|
displayName: `${owner}/${name}`,
|
|
cloneUrl,
|
|
};
|
|
}
|
|
|
|
function isValidGitHubRepoSegment(value: string): boolean {
|
|
return /^[A-Za-z0-9._-]+$/u.test(value);
|
|
}
|