mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 07:33:18 +00:00
The rename is adapter-specific, so it lives where the provider id resolves to its adapter: AgentManager converts an agent's own options on create, resume, import, reload, apply and draft controls, following derivedFromProviderId, so a custom provider extending codex keeps working with a saved or requested fast_mode. Storage, MCP, schedules, hub and profiles pass options through. The featureValues-to-options fold stays provider-agnostic at the legacy edges.
9091 lines
321 KiB
TypeScript
9091 lines
321 KiB
TypeScript
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
|
import type { AgentMessageDelivery } from "./agent/agent-messages/index.js";
|
|
import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js";
|
|
import { ConnectedHostSession } from "./connected-hosts/index.js";
|
|
import type { ConnectedHosts } from "./connected-hosts/index.js";
|
|
import { searchTimeline } from "./agent/chat-search/index.js";
|
|
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
|
|
import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities";
|
|
import type {
|
|
SessionEventSubscription,
|
|
UsageReportEntry,
|
|
ProviderUsage,
|
|
} from "@getpaseo/protocol/messages";
|
|
import { relative } from "node:path";
|
|
import { isAbsolute } from "node:path";
|
|
import { CreationService, type CreatedAgent } from "./creation/index.js";
|
|
import type { CreationSnapshot, AgentCreateRequest } from "@getpaseo/protocol/messages";
|
|
import type { MessageReceipts } from "./message-receipts/index.js";
|
|
import equal from "fast-deep-equal";
|
|
import { WorkspaceSetupObservation } from "./workspace-setup-observation.js";
|
|
import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
|
import { basename, join, resolve, sep } from "path";
|
|
import { homedir } from "node:os";
|
|
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
|
import { formatPluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
|
|
import {
|
|
serializeAgentStreamEvent,
|
|
type AgentSnapshotPayload,
|
|
type AgentAttachment,
|
|
type FirstAgentContext,
|
|
type SessionInboundMessage,
|
|
type SessionOutboundMessage,
|
|
type ScriptStatusUpdateMessage,
|
|
type StartWorkspaceScriptRequest,
|
|
type WorkspaceScriptListRequest,
|
|
type WorkspaceScriptStartRequest,
|
|
type WorkspaceScriptStopRequest,
|
|
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 {
|
|
encodePluginEventFrame,
|
|
encodePluginOutputFrame,
|
|
PluginFrameOpcode,
|
|
} from "@getpaseo/protocol/binary-frames/index";
|
|
import { CursorError } from "./pagination/cursor.js";
|
|
import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js";
|
|
import { matchesAgentHistoryQuery } from "./agent-history-search.js";
|
|
import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js";
|
|
import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js";
|
|
import {
|
|
buildConfigOverrides,
|
|
isStoredAgentProviderAvailable,
|
|
resolveStoredAgentUpdatedAt,
|
|
toAgentPersistenceHandle,
|
|
} from "./persistence-hooks.js";
|
|
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent/agent-loading.js";
|
|
import {
|
|
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 { toWireDaemonConfig, type DaemonConfigStore } from "./daemon-config-store.js";
|
|
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.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 { DirectorySyncService } from "./directory-sync/index.js";
|
|
import {
|
|
assertWorkspaceAutomationAllowedForWorkspace,
|
|
clearWorkspaceAutomationBlock,
|
|
formatWorkspaceAutomationBlockedMessage,
|
|
} from "./workspace-automation-gate.js";
|
|
import {
|
|
WorkspaceLabelError,
|
|
WorkspaceLabelStorageUncertainError,
|
|
type WorkspaceLabelService,
|
|
} from "./workspace-labels/index.js";
|
|
|
|
import { AgentManager, AgentRunCancellationError } from "./agent/agent-manager.js";
|
|
import { buildTimelinePromptIndex } from "./agent/timeline-prompt-index.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 {
|
|
inheritAgentBackground,
|
|
inheritWorkspaceBackground,
|
|
resolveCreateAgentIntent,
|
|
type CreateAgentIntent,
|
|
type CreateAgentCaller,
|
|
} from "./agent/create-agent/intent.js";
|
|
import {
|
|
archiveAgentCommand,
|
|
cancelAgentRunCommand,
|
|
closeAgentCommand,
|
|
detachAgentCommand,
|
|
setAgentModeCommand,
|
|
updateAgentCommand,
|
|
} from "./agent/lifecycle-command.js";
|
|
import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js";
|
|
import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js";
|
|
import { parsePluginClientId } from "./plugins/plugin-session-identity.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 { listProjectSuggestions, ProjectSuggestionsRequestError } from "./project-suggestions.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 ProjectMutation,
|
|
type ProjectRegistry,
|
|
type WorkspaceMutation,
|
|
type WorkspaceRegistry,
|
|
} from "./workspace-registry.js";
|
|
import { wrapSpokenInput } from "./voice-config.js";
|
|
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
|
|
import {
|
|
ProjectIconReader,
|
|
removeProjectCustomIcon,
|
|
setProjectCustomIcon,
|
|
} from "../utils/project-custom-icon.js";
|
|
import { VoiceSessions } from "./session/voice/index.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 { ScheduleSession } from "./session/schedule/schedule-session.js";
|
|
import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js";
|
|
import { UsageSession } from "./session/usage/usage-session.js";
|
|
import {
|
|
SpeechModelsSession,
|
|
type SpeechModelsSessionOptions,
|
|
} from "./session/speech/speech-models-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 type { PushNotifications } from "./push/index.js";
|
|
import {
|
|
archivePersistedWorkspaceRecord,
|
|
archiveWorkspaceContents,
|
|
} from "./workspace-archive-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,
|
|
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 { 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 {
|
|
resolveWorkspaceRootAgent,
|
|
summarizeFetchWorkspacesEntries,
|
|
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 {
|
|
createPaseoWorktreeWorkflow as createWorktreeWorkflow,
|
|
type CreatePaseoWorktreeWorkflowResult,
|
|
handleCreatePaseoWorktreeRequest as handleCreateWorktreeRequest,
|
|
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
|
|
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
|
|
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
|
|
handleWorkspaceSetupRunRequest as handleWorkspaceSetupRunRequestMessage,
|
|
} from "./worktree-session.js";
|
|
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
|
|
import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js";
|
|
import { SessionAuthorization, type DaemonPermission } from "./authorization/index.js";
|
|
|
|
function resolveWorkspaceSetupRuntime(
|
|
runtime: WorkspaceSetupRuntime | undefined,
|
|
): WorkspaceSetupRuntime {
|
|
return runtime ?? new WorkspaceSetupRuntime();
|
|
}
|
|
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";
|
|
|
|
type ProviderSubagentManagerEvent = Extract<
|
|
AgentManagerEvent,
|
|
{ type: "provider_subagent" }
|
|
>["event"];
|
|
|
|
// 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 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;
|
|
workspaceGitWatchedDirectoryCount: number;
|
|
workspaceGitWorkspaceRecordCount: number;
|
|
workspaceGitSubscriptionCount: 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;
|
|
|
|
/**
|
|
* Only history carries a query. The active-agents directory filters on
|
|
* structure and never ranks, so it always reads as no query at all.
|
|
*/
|
|
function agentDirectorySearchQuery(request: AgentDirectoryRequestMessage): string {
|
|
if (request.type !== "fetch_agent_history_request") return "";
|
|
return request.search?.trim() ?? "";
|
|
}
|
|
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;
|
|
background: boolean;
|
|
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;
|
|
owner: OwnedSubscription;
|
|
syncEnabled?: boolean;
|
|
filter?: WorkspaceUpdatesFilter;
|
|
isBootstrapping: boolean;
|
|
pendingUpdatesByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
|
|
lastEmittedByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
|
|
visibleEmptyProjectIds?: Set<string>;
|
|
}
|
|
|
|
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 {
|
|
connectedHosts?: ConnectedHosts;
|
|
sourceId?: string;
|
|
routed?: boolean;
|
|
browserToolsBroker?: BrowserToolsBroker | null;
|
|
clientId: string;
|
|
permissions: readonly DaemonPermission[];
|
|
appVersion?: string | null;
|
|
clientCapabilities?: Record<string, unknown> | null;
|
|
onMessage: (msg: SessionOutboundMessage) => void;
|
|
onMessageToSource?: (source: object, msg: SessionOutboundMessage) => void | Promise<void>;
|
|
onBinaryMessage?: (frame: Uint8Array) => void;
|
|
onBinaryMessageToSource?: (source: object, frame: Uint8Array) => Promise<void>;
|
|
getTransportBufferedAmount?: (source?: object) => number | null;
|
|
getConnectedHostBufferedAmount?: () => number | null;
|
|
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
|
|
onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
|
publishScriptStatusUpdate?: (message: ScriptStatusUpdateMessage) => void;
|
|
logger: pino.Logger;
|
|
downloadTokenStore: DownloadTokenStore;
|
|
pushNotifications: PushNotifications;
|
|
paseoHome: string;
|
|
worktreesRoot?: string;
|
|
agentManager: AgentManager;
|
|
agentStorage: AgentStorage;
|
|
messageReceipts: Pick<MessageReceipts, "send">;
|
|
agentMessages: AgentMessageDelivery;
|
|
creationService: Pick<CreationService, "create" | "subscribe">;
|
|
projectRegistry: ProjectRegistry;
|
|
workspaceRegistry: WorkspaceRegistry;
|
|
directorySync?: DirectorySyncService;
|
|
workspaceLabelService?: WorkspaceLabelService;
|
|
filesystem?: SessionFileSystem;
|
|
scheduleService: ScheduleService;
|
|
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;
|
|
workspaceRuntimeStore: WorkspaceRuntimeStore;
|
|
workspaceAutoName: WorkspaceAutoName;
|
|
daemonConfigStore: DaemonConfigStore;
|
|
pluginRuntime?: {
|
|
wait: import("./plugins/lifecycle/index.js").PluginLifecycle["wait"];
|
|
before: import("./plugins/lifecycle/index.js").PluginLifecycle["before"];
|
|
emit: import("./plugins/lifecycle/index.js").PluginLifecycle["emit"];
|
|
listPlugins(): Promise<import("@getpaseo/protocol/messages").PluginListItem[]>;
|
|
getLogs(pluginId: string): import("@getpaseo/protocol/messages").PluginLogEntry[];
|
|
installDirectory(input: {
|
|
path: string;
|
|
id?: string;
|
|
}): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
|
|
inspectDirectory(path: string): Promise<{ id: string }>;
|
|
installSource(input: {
|
|
source: string;
|
|
id?: string;
|
|
ref?: string;
|
|
}): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
|
|
statusSources(
|
|
pluginId?: string,
|
|
): Promise<import("@getpaseo/protocol/messages").PluginSourceStatusItem[]>;
|
|
previewUpdates(input: {
|
|
pluginId?: string;
|
|
target?: import("@getpaseo/protocol/messages").PluginUpdateSelection;
|
|
}): Promise<import("@getpaseo/protocol/messages").PluginUpdatePreview[]>;
|
|
applyUpdates(
|
|
proposals: import("@getpaseo/protocol/messages").PluginUpdateProposal[],
|
|
): Promise<import("@getpaseo/protocol/messages").PluginUpdateResult[]>;
|
|
updateSources(
|
|
pluginId?: string,
|
|
): Promise<import("@getpaseo/protocol/messages").PluginSourceUpdateItem[]>;
|
|
reloadPlugin(pluginId: string): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
|
|
enablePlugin(pluginId: string): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
|
|
disablePlugin(pluginId: string): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
|
|
removePlugin(pluginId: string): Promise<void>;
|
|
subscribe(listener: (pluginId: string) => void): () => void;
|
|
subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void;
|
|
catalog(): Array<{ id: string; clientBundle: string; clientBundleHash?: string }>;
|
|
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown>;
|
|
subscribePluginRpc(
|
|
pluginId: string,
|
|
method: string,
|
|
input: unknown,
|
|
): import("./plugins/runtime-subscription.js").PluginRuntimeSubscription;
|
|
listUsageReports(options?: {
|
|
forceRefresh?: boolean;
|
|
reportIds?: string[];
|
|
}): Promise<UsageReportEntry[]>;
|
|
listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>;
|
|
};
|
|
orchestrationSkills?: import("./orchestration-skills/index.js").OrchestrationSkills;
|
|
mcpBaseUrl?: string | null;
|
|
stt: Resolvable<SpeechToTextProvider | null>;
|
|
sttLanguage?: string;
|
|
tts: Resolvable<TextToSpeechProvider | null>;
|
|
speechModels?: SpeechModelsSessionOptions["runtime"];
|
|
terminalManager: TerminalManager | null;
|
|
providerSnapshotManager: ProviderSnapshotManager;
|
|
hubExecutionAgents?: HubExecutionAgents;
|
|
hubRelationships?: HubRelationshipManagement;
|
|
serviceProxy?: ServiceProxySubsystem;
|
|
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
|
|
workspaceSetupObservation?: WorkspaceSetupObservation;
|
|
workspaceSetupRuntime?: WorkspaceSetupRuntime;
|
|
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;
|
|
keepTerminals: boolean;
|
|
}
|
|
| {
|
|
type: "restart";
|
|
clientId: string;
|
|
requestId: string;
|
|
reason: string;
|
|
keepTerminals: boolean;
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
type RegistryTransition = "created" | "unarchived" | "existing";
|
|
|
|
interface ArchivedRecordSnapshot {
|
|
archivedAt?: string | null;
|
|
}
|
|
|
|
interface WorkspaceUpdateOptions {
|
|
removedProjectId?: string;
|
|
optimisticStatus?: WorkspaceDescriptorPayload["status"];
|
|
}
|
|
|
|
function resolveDirectorySync(service: DirectorySyncService | undefined): DirectorySyncService {
|
|
return service ?? new DirectorySyncService();
|
|
}
|
|
|
|
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.
|
|
*/
|
|
function resolveWorkspaceLabelService(
|
|
service: WorkspaceLabelService | undefined,
|
|
): WorkspaceLabelService | null {
|
|
return service ?? null;
|
|
}
|
|
|
|
function hasInitialCreationPrompt(
|
|
request?: Pick<AgentCreateRequest, "initialPrompt" | "images" | "attachments">,
|
|
): boolean {
|
|
return Boolean(
|
|
request &&
|
|
buildAgentPrompt(request.initialPrompt?.trim() ?? "", request.images, request.attachments)
|
|
.length,
|
|
);
|
|
}
|
|
|
|
function workspaceLabelErrorCode(error: unknown): string {
|
|
if (
|
|
error instanceof WorkspaceLabelError ||
|
|
error instanceof WorkspaceLabelStorageUncertainError ||
|
|
error instanceof SessionRequestError
|
|
) {
|
|
return error.code;
|
|
}
|
|
return "workspace_label_failed";
|
|
}
|
|
|
|
interface ClientActivity {
|
|
deviceType: "web" | "mobile";
|
|
focusedAgentId: string | null;
|
|
focusedTerminalId: string | null;
|
|
lastActivityAt: Date;
|
|
appVisible: boolean;
|
|
appVisibilityChangedAt: Date;
|
|
}
|
|
|
|
export class Session {
|
|
private readonly connectedHostSession: ConnectedHostSession | null;
|
|
private routed = false;
|
|
isRouted(): boolean {
|
|
return this.routed;
|
|
}
|
|
getHostSourceId(): string {
|
|
return this.hostSourceId;
|
|
}
|
|
private hostSourceId: string;
|
|
setHostSource(sourceId: string): void {
|
|
this.hostSourceId = sourceId;
|
|
this.connectedHostSession?.setSource(sourceId);
|
|
}
|
|
setRouted(routed: boolean): void {
|
|
this.routed = routed;
|
|
}
|
|
readonly delivery = new SessionDelivery(
|
|
(source, message) => {
|
|
if (!this.authorization.allowsOutbound(message)) return;
|
|
if (this.onMessageToSource) return this.onMessageToSource(source, message);
|
|
this.onMessage(message);
|
|
},
|
|
(source, frame) => {
|
|
if (this.onBinaryMessageToSource) {
|
|
return this.onBinaryMessageToSource(source, frame);
|
|
} else this.emitBinary(frame);
|
|
},
|
|
(source, message) => this.workspaceSetupMessageForClient(message, source),
|
|
(request, message) =>
|
|
this.sessionLogger.warn(
|
|
{
|
|
requestType: request.type,
|
|
messageType: message.type,
|
|
status: message.type === "status" ? message.payload.status : undefined,
|
|
},
|
|
"Unrecognized reply contract; application delivery denied",
|
|
),
|
|
);
|
|
private readonly browserToolsBroker: SessionOptions["browserToolsBroker"];
|
|
private readonly clientId: string;
|
|
private readonly authorization: SessionAuthorization;
|
|
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 | Promise<void>)
|
|
| null;
|
|
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
|
|
private readonly onBinaryMessageToSource:
|
|
| ((source: object, frame: Uint8Array) => Promise<void>)
|
|
| null;
|
|
private readonly getTransportBufferedAmount: (source?: object) => 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 projectIcons: ProjectIconReader;
|
|
private readonly worktreesRoot: string | undefined;
|
|
private readonly rewindInitiators = new Map<string, object | undefined>();
|
|
|
|
private agentManager: AgentManager;
|
|
private readonly agentStorage: AgentStorage;
|
|
private readonly projectRegistry: ProjectRegistry;
|
|
private readonly workspaceRegistry: WorkspaceRegistry;
|
|
private readonly agentDirectory: AgentDirectory;
|
|
private readonly directorySync: DirectorySyncService;
|
|
private readonly filesystem: SessionFileSystem;
|
|
private readonly github: ForgeService;
|
|
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
|
|
private readonly workspaceGitService: WorkspaceGitService;
|
|
private readonly workspaceRuntimeStore: WorkspaceRuntimeStore;
|
|
private readonly workspaceAutoName: WorkspaceAutoName;
|
|
private readonly gitMutation: GitMutationService;
|
|
private readonly workspaceProvisioning: WorkspaceProvisioningService;
|
|
private readonly workspaceRecovery: WorkspaceRecoveryService;
|
|
private readonly daemonConfigStore: DaemonConfigStore;
|
|
private readonly pushNotifications: PushNotifications;
|
|
private readonly pluginRuntime: SessionOptions["pluginRuntime"];
|
|
private readonly orchestrationSkills: SessionOptions["orchestrationSkills"];
|
|
private unsubscribeAgentEvents: (() => void) | null = null;
|
|
private unsubscribeProjectMutations: (() => void) | null = null;
|
|
private unsubscribePluginChanges: (() => void) | null = null;
|
|
private unsubscribeWorkspaceMutations: (() => void) | null = null;
|
|
private registryMutationQueue: Promise<void> = Promise.resolve();
|
|
private projectUpdateQueue: Promise<void> = Promise.resolve();
|
|
private isCleanedUp = false;
|
|
private readonly timelineSubscriptions = new Map<
|
|
string,
|
|
{ owner: OwnedSubscription; agentIds: Set<string> }
|
|
>();
|
|
private readonly clientSources = new Map<
|
|
object,
|
|
{
|
|
capabilities: ReadonlySet<ClientCapability>;
|
|
appVersion: string | null;
|
|
activity: ClientActivity | null;
|
|
pushToken: string | null;
|
|
}
|
|
>();
|
|
private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null;
|
|
private readonly agentUpdates: AgentUpdatesService;
|
|
private readonly workspaceUpdatesSubscriptions = new Map<
|
|
string,
|
|
WorkspaceUpdatesSubscriptionState
|
|
>();
|
|
private readonly workspaceLabelService: WorkspaceLabelService | null;
|
|
private readonly eventSubscriptions = new Map<
|
|
string,
|
|
{ owner: OwnedSubscription; events: Set<SessionEventSubscription>; notifications: boolean }
|
|
>();
|
|
private readonly workspaceUpdateTails = new Map<string, Promise<void>>();
|
|
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 workspaceSetupObservation: WorkspaceSetupObservation;
|
|
private readonly unsubscribeSetupObservation: () => void;
|
|
private readonly workspaceSetupRuntime: WorkspaceSetupRuntime;
|
|
private readonly workspaceGitObserver: WorkspaceGitObserverService;
|
|
private readonly workspaceDirectory: WorkspaceDirectory;
|
|
private readonly voiceSessions: VoiceSessions;
|
|
private readonly checkoutSession: CheckoutSession;
|
|
private readonly scheduleSession: ScheduleSession;
|
|
private readonly providerCatalogSession: ProviderCatalogSession;
|
|
private readonly usageSession: UsageSession;
|
|
private readonly speechModelsSession: SpeechModelsSession;
|
|
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 messageReceipts: Pick<MessageReceipts, "send">;
|
|
private readonly agentMessages: AgentMessageDelivery;
|
|
private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch;
|
|
private readonly creationService: Pick<CreationService, "create" | "subscribe">;
|
|
|
|
constructor(options: SessionOptions) {
|
|
const {
|
|
clientId,
|
|
permissions,
|
|
appVersion,
|
|
clientCapabilities,
|
|
onMessage,
|
|
onMessageToSource,
|
|
onBinaryMessage,
|
|
onBinaryMessageToSource,
|
|
getTransportBufferedAmount,
|
|
onLifecycleIntent,
|
|
onWorkspaceRecovered,
|
|
publishScriptStatusUpdate,
|
|
logger,
|
|
downloadTokenStore,
|
|
pushNotifications,
|
|
paseoHome,
|
|
worktreesRoot,
|
|
agentManager,
|
|
agentStorage,
|
|
projectRegistry,
|
|
workspaceRegistry,
|
|
directorySync,
|
|
workspaceLabelService,
|
|
filesystem,
|
|
scheduleService,
|
|
checkoutDiffManager,
|
|
github,
|
|
renameCurrentBranch,
|
|
workspaceGitService,
|
|
workspaceRuntimeStore,
|
|
workspaceAutoName,
|
|
daemonConfigStore,
|
|
pluginRuntime,
|
|
orchestrationSkills,
|
|
stt,
|
|
sttLanguage,
|
|
tts,
|
|
speechModels,
|
|
terminalManager,
|
|
providerSnapshotManager,
|
|
serviceProxy,
|
|
scriptRuntimeStore,
|
|
workspaceSetupObservation,
|
|
workspaceSetupRuntime,
|
|
onBranchChanged,
|
|
getDaemonTcpPort,
|
|
getDaemonTcpHost,
|
|
serviceProxyPublicBaseUrl,
|
|
resolveScriptHealth,
|
|
voice,
|
|
voiceBridge,
|
|
dictation,
|
|
serverId,
|
|
daemonVersion,
|
|
daemonRuntimeConfig,
|
|
getWebSocketRuntimeMetrics,
|
|
} = options;
|
|
this.browserToolsBroker = options.browserToolsBroker;
|
|
this.clientId = clientId;
|
|
this.authorization = new SessionAuthorization(permissions);
|
|
|
|
this.appVersion = appVersion ?? null;
|
|
this.clientCapabilities = parseClientCapabilities(clientCapabilities);
|
|
this.sessionId = uuidv4();
|
|
this.hostSourceId = hostSource(options);
|
|
this.connectedHostSession = createHostSession(options, this);
|
|
this.onMessage = onMessage;
|
|
this.onMessageToSource = onMessageToSource
|
|
? (source, message) => this.observeSourceSend(() => onMessageToSource(source, message))
|
|
: null;
|
|
this.onBinaryMessage = onBinaryMessage ?? null;
|
|
this.onBinaryMessageToSource = onBinaryMessageToSource
|
|
? (source, frame) => this.observeSourceSend(() => onBinaryMessageToSource(source, frame))
|
|
: null;
|
|
this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0);
|
|
this.onLifecycleIntent = onLifecycleIntent ?? null;
|
|
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
|
|
this.pushNotifications = pushNotifications;
|
|
this.paseoHome = paseoHome;
|
|
this.messageReceipts = options.messageReceipts;
|
|
this.agentMessages = options.agentMessages;
|
|
this.creationService = options.creationService;
|
|
this.projectIcons = new ProjectIconReader(paseoHome);
|
|
this.worktreesRoot = worktreesRoot;
|
|
this.pluginRuntime = pluginRuntime;
|
|
this.orchestrationSkills = orchestrationSkills;
|
|
this.sessionLogger = logger.child({
|
|
module: "session",
|
|
clientId: this.clientId,
|
|
sessionId: this.sessionId,
|
|
});
|
|
this.workspaceFilesSession = new WorkspaceFilesSession({
|
|
host: {
|
|
emit: (msg, source) => this.emitForSource(msg, source),
|
|
emitBinary: (frame, source) => this.emitBinaryReply(frame, source),
|
|
hasBinaryChannel: () => this.onBinaryMessage !== null,
|
|
},
|
|
downloadTokenStore,
|
|
paseoHome,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.agentManager = agentManager;
|
|
this.agentStorage = agentStorage;
|
|
this.projectRegistry = projectRegistry;
|
|
this.workspaceRegistry = workspaceRegistry;
|
|
this.directorySync = resolveDirectorySync(directorySync);
|
|
this.workspaceLabelService = resolveWorkspaceLabelService(workspaceLabelService);
|
|
this.filesystem = filesystem ?? nodeSessionFileSystem;
|
|
this.github = github ?? createGitHubService();
|
|
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
|
|
this.workspaceGitService = workspaceGitService;
|
|
this.workspaceRuntimeStore = workspaceRuntimeStore;
|
|
this.gitMutation = createGitMutationService({
|
|
workspaceGitService: this.workspaceGitService,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.workspaceAutoName = workspaceAutoName;
|
|
this.workspaceProvisioning = createWorkspaceProvisioningService({
|
|
lifecycle: this.pluginRuntime,
|
|
serverId,
|
|
workspaceRegistry: this.workspaceRegistry,
|
|
projectRegistry: this.projectRegistry,
|
|
workspaceGitService: this.workspaceGitService,
|
|
isDirectory: (path) => this.filesystem.isDirectory(path),
|
|
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),
|
|
logger: this.sessionLogger,
|
|
}),
|
|
logger: this.sessionLogger,
|
|
}),
|
|
paseoHome: this.paseoHome,
|
|
worktreesRoot: this.worktreesRoot,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.workspaceGitObserver = createWorkspaceGitObserverService({
|
|
workspaceGitService: this.workspaceGitService,
|
|
emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd),
|
|
emitStatusUpdate: (cwd, snapshot) => this.checkoutSession.emitStatusUpdate(cwd, snapshot),
|
|
onBranchChanged,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.scheduleSession = new ScheduleSession({
|
|
host: { emit: (msg) => this.emit(msg) },
|
|
scheduleService,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.providerCatalogSession = new ProviderCatalogSession({
|
|
host: {
|
|
emit: (msg) => this.emit(msg),
|
|
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
|
|
supportsCustomModeIcons: () => this.supports(CLIENT_CAPS.customModeIcons),
|
|
supportsCompactProviderSnapshots: () => this.supports(CLIENT_CAPS.compactProviderSnapshots),
|
|
publishSnapshot: (project) => {
|
|
const delivered = new Set<object>();
|
|
for (const subscription of this.eventSubscriptions.values()) {
|
|
if (!subscription.events.has("providers_snapshot_update")) continue;
|
|
const message = this.delivery.forSource(subscription.owner.source, project);
|
|
if (message) subscription.owner.emit(message);
|
|
delivered.add(subscription.owner.source);
|
|
}
|
|
// COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit provider delivery after 2027-03-09.
|
|
for (const source of this.clientSources.keys()) {
|
|
if (
|
|
delivered.has(source) ||
|
|
this.delivery.isModern(source) ||
|
|
!this.wantsEvent("providers_snapshot_update", source)
|
|
)
|
|
continue;
|
|
const message = this.delivery.forSource(source, project);
|
|
if (message) this.onMessageToSource?.(source, message);
|
|
}
|
|
},
|
|
supportsProviderSnapshotReferences: () =>
|
|
this.supports(CLIENT_CAPS.providerSnapshotReferences),
|
|
listProviderAvailability: () => this.agentManager.listProviderAvailability(),
|
|
listDraftControls: (config) => this.agentManager.listDraftControls(config),
|
|
},
|
|
providerSnapshotManager,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.usageSession = new UsageSession({
|
|
isEnabled: () => daemonConfigStore.get().usage?.enabled !== false,
|
|
emit: (msg) => this.emit(msg),
|
|
runtime: pluginRuntime,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.speechModelsSession = new SpeechModelsSession({
|
|
emit: (msg) => this.emit(msg),
|
|
runtime: speechModels,
|
|
});
|
|
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),
|
|
setOptions: (agentId, patch) => agentManager.setAgentOptions(agentId, patch),
|
|
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,
|
|
getObservationMetrics: () => ({
|
|
Registrations: this.delivery.registrationCount,
|
|
"Source registrations": this.delivery.sourceRegistrationCount(),
|
|
"Producer listeners": [
|
|
this.unsubscribeAgentEvents,
|
|
this.unsubscribeProjectMutations,
|
|
this.unsubscribeWorkspaceMutations,
|
|
this.unsubscribePluginChanges,
|
|
this.unsubscribeTerminalWorkspaceContributionEvents,
|
|
this.providerCatalogSession.isObserving,
|
|
this.hubExecutionController?.isObserving,
|
|
].filter(Boolean).length,
|
|
"Git observations": this.workspaceGitObserver.getMetrics().subscriptionCount,
|
|
"Terminal directories": this.terminalController.getMetrics().directorySubscriptionCount,
|
|
"Terminal output": this.terminalController.getMetrics().streamSubscriptionCount,
|
|
}),
|
|
listProviderAvailability: () => this.agentManager.listProviderAvailability(),
|
|
getTerminalsUnavailable: () => terminalManager?.getUnavailableError?.()?.message ?? null,
|
|
listAgents: () => this.agentManager.listAgents(),
|
|
listProjects: () => this.projectRegistry.list(),
|
|
// Diagnostics report both the active count and total archived history.
|
|
listWorkspaces: () => this.workspaceRegistry.listAll(),
|
|
logger: this.sessionLogger,
|
|
hubRelationships: options.hubRelationships,
|
|
reloadConfig: () => daemonConfigStore.reload(),
|
|
});
|
|
this.hubExecutionController = options.hubExecutionAgents
|
|
? new HubExecutionController({
|
|
agents: options.hubExecutionAgents,
|
|
validateAgentConfiguration: (input) =>
|
|
providerSnapshotManager.validateAgentConfiguration(input),
|
|
send: (message) => this.emit(message),
|
|
})
|
|
: null;
|
|
this.daemonConfigStore = daemonConfigStore;
|
|
this.terminalManager = terminalManager;
|
|
this.terminalController = new TerminalSessionController({
|
|
terminalManager,
|
|
emit: (msg) => this.emit(msg),
|
|
hasBinaryChannel: () => this.onBinaryMessage !== null,
|
|
supportsChunkedReplay: (source) =>
|
|
this.supportsForSource(CLIENT_CAPS.terminalChunkedReplay, source),
|
|
isPathWithinRoot: (rootPath, candidatePath) => this.isPathWithinRoot(rootPath, candidatePath),
|
|
sessionLogger: this.sessionLogger,
|
|
listTerminalWorkspaceRefs: () => this.listActiveWorkspaceRefs(),
|
|
getClientBufferedAmount: (source) => this.getTransportBufferedAmount(source),
|
|
});
|
|
this.agentDirectory = new AgentDirectory({
|
|
manager: this.agentManager,
|
|
storage: this.agentStorage,
|
|
workspaces: this.workspaceRegistry,
|
|
projectLive: (agent) => this.buildAgentPayload(agent),
|
|
projectStored: (record) => this.buildStoredAgentPayload(record),
|
|
isProviderVisible: (provider) => this.isProviderVisibleToClient(provider),
|
|
isStoredProviderAvailable: (record) =>
|
|
isStoredAgentProviderAvailable(
|
|
record,
|
|
new Set(this.providerSnapshotManager.listRegisteredProviderIds()),
|
|
),
|
|
});
|
|
this.agentUpdates = createAgentUpdatesService({
|
|
includesWorkspace: (agent, backgroundVisibility) =>
|
|
this.agentDirectory.includes(agent, backgroundVisibility),
|
|
emit: (message) => this.emit(message),
|
|
enrichAgentPayload: (payload) => this.enrichAgentPayload(payload),
|
|
buildStoredAgentPayload: (record) => this.buildStoredAgentPayload(record),
|
|
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
|
|
buildProjectPlacementForWorkspaceId: (workspaceId) =>
|
|
this.buildProjectPlacementForWorkspaceId(workspaceId),
|
|
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
|
|
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
|
|
sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence, includeBackground) =>
|
|
this.directorySync.sequenceAgentUpdate(
|
|
payload,
|
|
agent && project ? { agent, project } : null,
|
|
agentId,
|
|
includeSequence,
|
|
includeBackground,
|
|
),
|
|
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.workspaceSetupObservation = workspaceSetupObservation ?? new WorkspaceSetupObservation();
|
|
this.unsubscribeSetupObservation = this.workspaceSetupObservation.subscribe(
|
|
(workspaceId, snapshot, statusChanged) => {
|
|
if (statusChanged)
|
|
void this.emitWorkspaceUpdateForWorkspaceId(workspaceId).catch((error) =>
|
|
this.sessionLogger.warn({ err: error, workspaceId }, "Failed to publish setup status"),
|
|
);
|
|
this.emitSubscribedEvent({
|
|
type: "workspace_setup_progress",
|
|
payload: { workspaceId, ...snapshot },
|
|
});
|
|
},
|
|
);
|
|
this.workspaceSetupRuntime = resolveWorkspaceSetupRuntime(workspaceSetupRuntime);
|
|
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,
|
|
workspaceRuntimeStore: this.workspaceRuntimeStore,
|
|
getDaemonTcpPort: this.getDaemonTcpPort,
|
|
getDaemonTcpHost: this.getDaemonTcpHost,
|
|
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
|
resolveScriptHealth: this.resolveScriptHealth,
|
|
logger: this.sessionLogger,
|
|
emit: (message) => this.emit(message),
|
|
publishStatusUpdate: (message) => {
|
|
if (publishScriptStatusUpdate) publishScriptStatusUpdate(message);
|
|
else this.emit(message);
|
|
},
|
|
spawnWorkspaceScript,
|
|
assertAutomationAllowed: (workspaceId) =>
|
|
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
|
|
getGlobalServicePorts: () => this.daemonConfigStore.readServicePorts(),
|
|
});
|
|
this.workspaceDirectory = new WorkspaceDirectory({
|
|
logger: this.sessionLogger,
|
|
projectRegistry: this.projectRegistry,
|
|
workspaceRegistry: this.workspaceRegistry,
|
|
listAgentPayloads: (workspaceIds) =>
|
|
this.listAgentPayloads({ workspaceIds, includeBackground: true, includeAncestors: true }),
|
|
workspaceLastUserMessageTimes: (workspaceIds) =>
|
|
this.agentDirectory.workspaceLastUserMessageTimes(workspaceIds),
|
|
listProviderSubagentActivity: async () => this.agentManager.listProviderSubagentActivity(),
|
|
listTerminalActivityContributions: () => this.listTerminalActivityContributions(),
|
|
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
|
|
buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input),
|
|
});
|
|
|
|
this.voiceSessions = new VoiceSessions(
|
|
{
|
|
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.delivery,
|
|
() => this.refreshObservationProducers(),
|
|
);
|
|
|
|
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,
|
|
appVersion = this.appVersion,
|
|
): void {
|
|
this.clientCapabilities = parseClientCapabilities(capabilities);
|
|
if (source) {
|
|
this.delivery.attach(source, capabilities?.[CLIENT_CAPS.ownedSubscriptions] === true);
|
|
this.clientSources.set(source, {
|
|
capabilities: this.clientCapabilities,
|
|
appVersion,
|
|
activity: this.clientSources.get(source)?.activity ?? null,
|
|
pushToken: this.clientSources.get(source)?.pushToken ?? null,
|
|
});
|
|
this.refreshObservationProducers();
|
|
// COMPAT(ownedSubscriptions): added in v0.8.0, remove capability-driven legacy host registration after 2027-03-09.
|
|
if (
|
|
!this.delivery.isModern(source) &&
|
|
this.authorization.allowsPermission("workspace.write")
|
|
) {
|
|
const host = BrowserAutomationHostCapabilitySchema.safeParse(capabilities?.browser_host);
|
|
if (host.success && this.browserToolsBroker) {
|
|
const request = {
|
|
type: "browser.host.register.request" as const,
|
|
requestId: uuidv4(),
|
|
hostKind: host.data.hostKind,
|
|
supportedCommands: host.data.supportedCommands,
|
|
};
|
|
void this.delivery
|
|
.request(source, request, () => this.registerBrowserHost(request, false))
|
|
.catch((error) =>
|
|
this.sessionLogger.warn({ err: error }, "Failed to register legacy browser host"),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
clearAgentTimelineSubscription(source: object): void {
|
|
void this.delivery
|
|
.detach(source)
|
|
.catch((err) => this.sessionLogger.error({ err }, "Failed to release source subscriptions"));
|
|
this.clientSources.delete(source);
|
|
this.refreshObservationProducers();
|
|
}
|
|
|
|
private subscribeAgentTimelines(agentIds: string[]): OwnedSubscription {
|
|
const owner = this.delivery.begin("timelines", undefined, (id) => {
|
|
this.timelineSubscriptions.delete(id);
|
|
this.refreshObservationProducers();
|
|
});
|
|
this.timelineSubscriptions.set(owner.id, { owner, agentIds: new Set(agentIds) });
|
|
this.refreshObservationProducers();
|
|
return owner;
|
|
}
|
|
|
|
// COMPAT(timelineItemCapabilities): plugin items added in v0.8.0, notifications in v0.7.2.
|
|
// Remove after 2027-03-07 once the supported client floor is >= v0.8.0.
|
|
private supportsTimelineItem(item: { type: string }, source?: object): boolean {
|
|
let capability: ClientCapability;
|
|
if (item.type === "notification") capability = CLIENT_CAPS.timelineNotifications;
|
|
else if (item.type === "plugin") capability = CLIENT_CAPS.pluginTimelineItems;
|
|
else return true;
|
|
return source ? this.supportsForSource(capability, source) : this.supports(capability);
|
|
}
|
|
|
|
private forwardAgentStream(
|
|
event: Extract<AgentManagerEvent, { type: "agent_stream" }>,
|
|
serializedEvent: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"],
|
|
): void {
|
|
const attention = serializedEvent.type === "attention_required";
|
|
if (attention) {
|
|
this.emit({
|
|
type: "agent_attention_required",
|
|
payload: {
|
|
agentId: event.agentId,
|
|
reason: serializedEvent.reason,
|
|
timestamp: serializedEvent.timestamp,
|
|
shouldNotify: serializedEvent.shouldNotify,
|
|
...(serializedEvent.notification ? { notification: serializedEvent.notification } : {}),
|
|
},
|
|
});
|
|
}
|
|
const message: SessionOutboundMessage = {
|
|
type: "agent_stream",
|
|
payload: this.buildAgentStreamPayload(event, serializedEvent),
|
|
};
|
|
for (const subscription of this.timelineSubscriptions.values()) {
|
|
const source = subscription.owner.source;
|
|
if (!subscription.agentIds.has(event.agentId)) continue;
|
|
if (
|
|
attention &&
|
|
(this.delivery.isModern(source) ||
|
|
this.supportsForSource(CLIENT_CAPS.selectiveAgentTimeline, source))
|
|
)
|
|
continue;
|
|
if (
|
|
serializedEvent.type === "timeline" &&
|
|
!this.supportsTimelineItem(serializedEvent.item, source)
|
|
)
|
|
continue;
|
|
subscription.owner.emit(message);
|
|
}
|
|
// COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit timeline delivery after 2027-03-09.
|
|
for (const [source, { capabilities }] of this.clientSources) {
|
|
if (this.delivery.isModern(source) || capabilities.has(CLIENT_CAPS.selectiveAgentTimeline))
|
|
continue;
|
|
if (
|
|
serializedEvent.type === "timeline" &&
|
|
!this.supportsTimelineItem(serializedEvent.item, source)
|
|
)
|
|
continue;
|
|
const alreadyDelivered = [...this.timelineSubscriptions.values()].some(
|
|
(subscription) =>
|
|
subscription.owner.source === source && subscription.agentIds.has(event.agentId),
|
|
);
|
|
if (!alreadyDelivered) this.onMessageToSource?.(source, message);
|
|
}
|
|
if (
|
|
this.clientSources.size === 0 &&
|
|
!this.supports(CLIENT_CAPS.selectiveAgentTimeline) &&
|
|
this.timelineSubscriptions.size === 0
|
|
)
|
|
this.emit(message);
|
|
}
|
|
|
|
supports(capability: ClientCapability): boolean {
|
|
const source = this.delivery.currentSource;
|
|
return source
|
|
? this.supportsForSource(capability, source)
|
|
: this.clientCapabilities.has(capability);
|
|
}
|
|
|
|
supportsForSource(capability: ClientCapability, source: object): boolean {
|
|
return (
|
|
this.clientSources.get(source)?.capabilities.has(capability) ??
|
|
this.clientCapabilities.has(capability)
|
|
);
|
|
}
|
|
|
|
emitProjectUpdate(update: ProjectUpdate): Promise<void> {
|
|
if (!this.wantsEvent("project.update")) return Promise.resolve();
|
|
const queued = this.projectUpdateQueue
|
|
.catch(() => undefined)
|
|
.then(() => this.publishProjectUpdate(update));
|
|
this.projectUpdateQueue = queued;
|
|
return queued;
|
|
}
|
|
|
|
private async publishProjectUpdate(update: ProjectUpdate): Promise<void> {
|
|
if (!this.wantsEvent("project.update")) return;
|
|
const projectedPayload =
|
|
update.kind === "upsert"
|
|
? { kind: "upsert" as const, project: await this.buildProjectDescriptor(update.project) }
|
|
: update;
|
|
if (!this.wantsEvent("project.update")) return;
|
|
const message: SessionOutboundMessage = {
|
|
type: "project.update",
|
|
payload: this.directorySync.sequenceProjectUpdate(projectedPayload, true),
|
|
};
|
|
this.emit(message);
|
|
}
|
|
|
|
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
|
if (this.workspaceUpdatesSubscriptions.size === 0 && !this.wantsEvent("checkout_status_update"))
|
|
return;
|
|
const descriptor = await this.describeWorkspaceRecord(workspace);
|
|
const demanded = [...this.workspaceUpdatesSubscriptions.values()].some((subscription) =>
|
|
this.matchesWorkspaceFilter({ workspace: descriptor, filter: subscription.filter }),
|
|
);
|
|
if (demanded || this.wantsEvent("checkout_status_update"))
|
|
this.workspaceGitObserver.syncObservers([descriptor]);
|
|
}
|
|
|
|
async emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void> {
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId]);
|
|
}
|
|
|
|
private async emitCreatedWorkspaceUpdate(
|
|
workspace: WorkspaceDescriptorPayload,
|
|
optimisticStatus?: WorkspaceDescriptorPayload["status"],
|
|
): Promise<void> {
|
|
if (this.workspaceUpdatesSubscriptions.size > 0) {
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds(
|
|
[workspace.id],
|
|
optimisticStatus ? { optimisticStatus } : undefined,
|
|
);
|
|
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.
|
|
if (workspace.background) return;
|
|
this.emit({
|
|
type: "workspace_update",
|
|
payload: {
|
|
kind: "upsert",
|
|
workspace: optimisticStatus ? { ...workspace, status: optimisticStatus } : workspace,
|
|
},
|
|
});
|
|
}
|
|
|
|
markWorkspaceArchivingForExternalMutation(
|
|
workspaceIds: Iterable<string>,
|
|
archivingAt: string,
|
|
): void {
|
|
this.markWorkspaceArchiving(workspaceIds, archivingAt);
|
|
}
|
|
|
|
clearWorkspaceArchivingForExternalMutation(workspaceIds: Iterable<string>): void {
|
|
this.clearWorkspaceArchiving(workspaceIds);
|
|
}
|
|
|
|
async emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable<string>): Promise<void> {
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds);
|
|
}
|
|
|
|
async syncWorkspaceGitObserversForExternalWorkspaceIds(
|
|
workspaceIds: Iterable<string>,
|
|
): Promise<void> {
|
|
if (!this.delivery.hasDemand("workspaces") && !this.wantsEvent("checkout_status_update"))
|
|
return;
|
|
await Promise.all(
|
|
Array.from(new Set(workspaceIds)).map(async (workspaceId) => {
|
|
const workspace = await this.workspaceRegistry.get(workspaceId);
|
|
if (workspace && !workspace.archivedAt) {
|
|
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
|
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
|
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
|
}
|
|
|
|
async refreshRecoveredWorkspaceForExternalMutation(
|
|
workspace: PersistedWorkspaceRecord,
|
|
): Promise<void> {
|
|
try {
|
|
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
|
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
|
} 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
|
|
*/
|
|
private currentClientMetadata() {
|
|
const source = this.delivery.currentSource;
|
|
if (!source) throw new Error("Client control has no source");
|
|
let metadata = this.clientSources.get(source);
|
|
if (!metadata) {
|
|
// COMPAT(ownedSubscriptions): added in v0.8.0, remove socketless legacy session adapter after 2027-03-09.
|
|
metadata = {
|
|
capabilities: this.clientCapabilities,
|
|
appVersion: this.appVersion,
|
|
activity: null,
|
|
pushToken: null,
|
|
};
|
|
this.clientSources.set(source, metadata);
|
|
}
|
|
return metadata;
|
|
}
|
|
|
|
public getClientActivity(source = this.delivery.currentSource): ClientActivity | null {
|
|
return source ? (this.clientSources.get(source)?.activity ?? null) : null;
|
|
}
|
|
|
|
private getFocusedAgentSelectionForCwd(cwd: string):
|
|
| {
|
|
provider?: string | null;
|
|
model?: string | null;
|
|
thinkingOptionId?: string | null;
|
|
}
|
|
| undefined {
|
|
const focusedAgentId = this.getClientActivity()?.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();
|
|
const workspaceGitMetrics = this.workspaceGitObserver.getMetrics();
|
|
return {
|
|
terminalDirectorySubscriptionCount: terminalMetrics.directorySubscriptionCount,
|
|
terminalSubscriptionCount: terminalMetrics.streamSubscriptionCount,
|
|
workspaceGitWatchedDirectoryCount: workspaceGitMetrics.watchedDirectoryCount,
|
|
workspaceGitWorkspaceRecordCount: workspaceGitMetrics.workspaceRecordCount,
|
|
workspaceGitSubscriptionCount: workspaceGitMetrics.subscriptionCount,
|
|
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 refreshObservationProducers(): void {
|
|
const legacy = this.delivery.hasLegacySources();
|
|
const workspaces =
|
|
legacy || this.delivery.hasDemand("workspaces") || this.wantsEvent("checkout_status_update");
|
|
const agents =
|
|
workspaces ||
|
|
this.delivery.hasDemand("agents") ||
|
|
this.delivery.hasDemand("timelines") ||
|
|
this.voiceSessions.hasDemand ||
|
|
this.wantsEvent("agent_attention_required") ||
|
|
this.wantsEvent("agent_permission_request") ||
|
|
this.wantsEvent("agent_permission_resolved") ||
|
|
this.wantsEvent("agent.provider_subagents.update");
|
|
if (agents && !this.unsubscribeAgentEvents) this.subscribeToAgentEvents();
|
|
if (!agents) {
|
|
this.unsubscribeAgentEvents?.();
|
|
this.unsubscribeAgentEvents = null;
|
|
}
|
|
this.refreshWorkspaceProducers(workspaces);
|
|
this.refreshCatalogProducers(legacy);
|
|
}
|
|
|
|
private refreshWorkspaceProducers(observing: boolean): void {
|
|
if (observing) {
|
|
this.unsubscribeProjectMutations ??=
|
|
this.projectRegistry.subscribeToMutations?.((mutation) =>
|
|
this.enqueueRegistryMutation(() => this.handleProjectMutation(mutation)),
|
|
) ?? null;
|
|
this.unsubscribeWorkspaceMutations ??=
|
|
this.workspaceRegistry.subscribeToMutations?.((mutation) =>
|
|
this.enqueueRegistryMutation(() => this.handleWorkspaceMutation(mutation)),
|
|
) ?? null;
|
|
this.unsubscribeTerminalWorkspaceContributionEvents ??=
|
|
this.terminalManager?.subscribeTerminalWorkspaceContributionChanged((event) => {
|
|
void this.emitWorkspaceUpdateForTerminalContribution(event).catch((error) =>
|
|
this.sessionLogger.warn(
|
|
{ err: error, terminalId: event.terminalId },
|
|
"Failed to emit workspace terminal contribution",
|
|
),
|
|
);
|
|
}) ?? null;
|
|
} else {
|
|
this.unsubscribeProjectMutations?.();
|
|
this.unsubscribeProjectMutations = null;
|
|
this.unsubscribeWorkspaceMutations?.();
|
|
this.unsubscribeWorkspaceMutations = null;
|
|
this.unsubscribeTerminalWorkspaceContributionEvents?.();
|
|
this.unsubscribeTerminalWorkspaceContributionEvents = null;
|
|
}
|
|
}
|
|
|
|
private refreshCatalogProducers(legacy: boolean): void {
|
|
this.hubExecutionController?.setObserving(
|
|
legacy ||
|
|
this.wantsEvent("hub.execution.agent.update") ||
|
|
this.wantsEvent("hub.execution.agent.stream"),
|
|
);
|
|
if (this.wantsEvent("providers_snapshot_update")) this.providerCatalogSession.start();
|
|
else this.providerCatalogSession.dispose();
|
|
if (
|
|
legacy ||
|
|
this.wantsEvent("status.plugin_catalog_changed") ||
|
|
this.wantsEvent("status.plugin_settings_changed")
|
|
)
|
|
this.unsubscribePluginChanges ??= this.subscribeToPluginChanges(this.pluginRuntime);
|
|
else {
|
|
this.unsubscribePluginChanges?.();
|
|
this.unsubscribePluginChanges = null;
|
|
}
|
|
}
|
|
|
|
private enqueueRegistryMutation(handleMutation: () => Promise<void>): Promise<void> {
|
|
const next = this.registryMutationQueue.then(handleMutation);
|
|
this.registryMutationQueue = next.catch(() => {});
|
|
return next;
|
|
}
|
|
|
|
private async handleWorkspaceMutation(mutation: WorkspaceMutation): Promise<void> {
|
|
try {
|
|
if (this.isCleanedUp) {
|
|
return;
|
|
}
|
|
if (
|
|
mutation.kind === "archive" ||
|
|
mutation.kind === "remove" ||
|
|
mutation.workspace?.archivedAt
|
|
) {
|
|
this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId);
|
|
} else {
|
|
await this.syncWorkspaceMutationObserver(mutation);
|
|
}
|
|
if (this.isCleanedUp) {
|
|
return;
|
|
}
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds(
|
|
[mutation.workspaceId],
|
|
mutation.expectsInitialAgent ? { optimisticStatus: "running" } : undefined,
|
|
);
|
|
} catch (error) {
|
|
this.sessionLogger.warn(
|
|
{ err: error, workspaceId: mutation.workspaceId, mutationKind: mutation.kind },
|
|
"Failed to apply workspace mutation to session",
|
|
);
|
|
}
|
|
}
|
|
|
|
private async syncWorkspaceMutationObserver(mutation: WorkspaceMutation): Promise<void> {
|
|
if (
|
|
!mutation.workspace ||
|
|
(this.workspaceUpdatesSubscriptions.size === 0 && !this.wantsEvent("checkout_status_update"))
|
|
)
|
|
return;
|
|
const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({
|
|
workspaceIds: [mutation.workspaceId],
|
|
includeGitData: false,
|
|
});
|
|
const descriptor = descriptorsByWorkspaceId.get(mutation.workspaceId);
|
|
if (
|
|
!descriptor ||
|
|
(!this.wantsEvent("checkout_status_update") &&
|
|
![...this.workspaceUpdatesSubscriptions.values()].some((subscription) =>
|
|
this.matchesWorkspaceFilter({ workspace: descriptor, filter: subscription.filter }),
|
|
))
|
|
) {
|
|
this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId);
|
|
return;
|
|
}
|
|
const currentWorkspace = await this.workspaceRegistry.get(mutation.workspaceId);
|
|
if (!currentWorkspace || currentWorkspace.archivedAt) {
|
|
this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId);
|
|
return;
|
|
}
|
|
await this.syncWorkspaceGitObserverForWorkspace(currentWorkspace);
|
|
if (this.isCleanedUp) {
|
|
this.workspaceGitObserver.removeForWorkspaceId(mutation.workspaceId);
|
|
}
|
|
}
|
|
|
|
private async handleProjectMutation(mutation: ProjectMutation): Promise<void> {
|
|
for (const subscription of this.workspaceUpdatesSubscriptions.values()) {
|
|
await this.handleProjectMutationForSubscription(mutation, subscription);
|
|
}
|
|
}
|
|
|
|
private async handleProjectMutationForSubscription(
|
|
mutation: ProjectMutation,
|
|
subscription: WorkspaceUpdatesSubscriptionState,
|
|
): Promise<void> {
|
|
try {
|
|
if (this.isCleanedUp) {
|
|
return;
|
|
}
|
|
const projectWorkspaceIds = (await this.workspaceRegistry.listActive())
|
|
.filter((workspace) => workspace.projectId === mutation.projectId)
|
|
.map((workspace) => workspace.workspaceId);
|
|
|
|
if (mutation.kind === "remove") {
|
|
// Archived workspaces can still represent an empty project in this subscription.
|
|
for (const [workspaceId, update] of subscription.lastEmittedByWorkspaceId) {
|
|
const projectId =
|
|
update.kind === "upsert" ? update.workspace.projectId : update.emptyProject?.projectId;
|
|
if (projectId === mutation.projectId && !projectWorkspaceIds.includes(workspaceId)) {
|
|
projectWorkspaceIds.push(workspaceId);
|
|
}
|
|
}
|
|
const visibleWorkspaceIds = projectWorkspaceIds.filter((workspaceId) => {
|
|
const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId);
|
|
return (
|
|
lastEmitted?.kind === "upsert" ||
|
|
(lastEmitted?.kind === "remove" &&
|
|
lastEmitted.emptyProject?.projectId === mutation.projectId)
|
|
);
|
|
});
|
|
let updateIds = visibleWorkspaceIds;
|
|
if (
|
|
updateIds.length === 0 &&
|
|
subscription.visibleEmptyProjectIds?.has(mutation.projectId)
|
|
) {
|
|
updateIds = [mutation.projectId];
|
|
}
|
|
if (updateIds.length === 0) {
|
|
return;
|
|
}
|
|
for (const workspaceId of projectWorkspaceIds) {
|
|
this.workspaceGitObserver.removeForWorkspaceId(workspaceId);
|
|
}
|
|
await this.enqueueWorkspaceUpdates(new Set(updateIds), subscription, {
|
|
removedProjectId: mutation.projectId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (mutation.kind === "archive" || mutation.project?.archivedAt) {
|
|
for (const workspaceId of projectWorkspaceIds) {
|
|
this.workspaceGitObserver.removeForWorkspaceId(workspaceId);
|
|
}
|
|
}
|
|
await this.enqueueWorkspaceUpdates(new Set(projectWorkspaceIds), subscription, undefined);
|
|
} catch (error) {
|
|
this.sessionLogger.warn(
|
|
{ err: error, projectId: mutation.projectId, mutationKind: mutation.kind },
|
|
"Failed to apply project mutation to session",
|
|
);
|
|
}
|
|
}
|
|
|
|
private supportsSubagentTimelineItem(
|
|
item: AgentTimelineFetchResult["rows"][number]["item"],
|
|
source?: object,
|
|
): boolean {
|
|
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gate after 2027-03-14.
|
|
const supportsProjection = source
|
|
? this.supportsForSource(CLIENT_CAPS.projectedSubagentTimeline, source)
|
|
: this.supports(CLIENT_CAPS.projectedSubagentTimeline);
|
|
return supportsProjection && this.supportsTimelineItem(item, source);
|
|
}
|
|
|
|
private forwardProviderSubagentUpdate(
|
|
update: Extract<AgentManagerEvent, { type: "provider_subagent" }>["event"],
|
|
): void {
|
|
let message: SessionOutboundMessage;
|
|
if (update.type === "upsert") {
|
|
message = {
|
|
type: "agent.provider_subagents.update",
|
|
payload: { kind: "upsert", subagent: update.subagent },
|
|
};
|
|
} else if (update.type === "timeline") {
|
|
message = {
|
|
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 {
|
|
message = {
|
|
type: "agent.provider_subagents.update",
|
|
payload: {
|
|
kind: "remove",
|
|
parentAgentId: update.parentAgentId,
|
|
subagentId: update.subagentId,
|
|
},
|
|
};
|
|
}
|
|
|
|
const delivered = new Set<object>();
|
|
for (const subscription of this.eventSubscriptions.values()) {
|
|
if (!subscription.events.has("agent.provider_subagents.update")) continue;
|
|
if (
|
|
update.type === "timeline" &&
|
|
!this.supportsSubagentTimelineItem(update.row.item, subscription.owner.source)
|
|
)
|
|
continue;
|
|
subscription.owner.emit(message);
|
|
delivered.add(subscription.owner.source);
|
|
}
|
|
if (this.clientSources.size === 0 || !this.onMessageToSource) {
|
|
if (
|
|
this.supports(CLIENT_CAPS.providerSubagents) &&
|
|
(update.type !== "timeline" || this.supportsSubagentTimelineItem(update.row.item))
|
|
) {
|
|
this.emit(message);
|
|
}
|
|
return;
|
|
}
|
|
for (const [source, { capabilities }] of this.clientSources) {
|
|
if (
|
|
delivered.has(source) ||
|
|
this.delivery.isModern(source) ||
|
|
!capabilities.has(CLIENT_CAPS.providerSubagents)
|
|
)
|
|
continue;
|
|
if (update.type === "timeline" && !this.supportsSubagentTimelineItem(update.row.item, source))
|
|
continue;
|
|
this.onMessageToSource(source, message);
|
|
}
|
|
}
|
|
|
|
private subscribeToAgentEvents(): void {
|
|
if (this.unsubscribeAgentEvents) {
|
|
this.unsubscribeAgentEvents();
|
|
}
|
|
|
|
this.unsubscribeAgentEvents = this.agentManager.subscribe(
|
|
(event) => {
|
|
if (event.type === "timeline_replacement") {
|
|
this.deliverTimelineReplacement(event.agentId, this.rewindInitiators.get(event.agentId));
|
|
return;
|
|
}
|
|
|
|
if (event.type === "agent_removed") {
|
|
if (event.internal) return;
|
|
void this.agentUpdates
|
|
.removeAgent(event.agentId)
|
|
.then(() => {
|
|
if (event.workspaceId)
|
|
return this.emitWorkspaceUpdateForWorkspaceId(event.workspaceId);
|
|
return undefined;
|
|
})
|
|
.catch((error) => {
|
|
this.sessionLogger.error(
|
|
{ err: error, agentId: event.agentId },
|
|
"Failed to emit agent removal",
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
|
|
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") {
|
|
// The subagents track of an internal parent stays hidden.
|
|
const parentAgentId =
|
|
event.event.type === "upsert"
|
|
? event.event.subagent.parentAgentId
|
|
: event.event.parentAgentId;
|
|
if (this.agentManager.getAgent(parentAgentId)?.internal) return;
|
|
this.emitProviderSubagentWorkspaceUpdate(event.event);
|
|
this.forwardProviderSubagentUpdate(event.event);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.voiceSessions.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);
|
|
|
|
// An internal agent streams only to clients subscribed to it by id;
|
|
// session-wide prompts would surface it everywhere.
|
|
const internal = this.agentManager.getAgent(event.agentId)?.internal === true;
|
|
if (event.event.type === "permission_requested" && !internal) {
|
|
this.emit({
|
|
type: "agent_permission_request",
|
|
payload: {
|
|
agentId: event.agentId,
|
|
request: event.event.request,
|
|
},
|
|
});
|
|
} else if (event.event.type === "permission_resolved") {
|
|
// A provider event is not the outcome of whichever request is currently running.
|
|
this.emitSubscribedEvent({
|
|
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.
|
|
},
|
|
// Internal agents flow through so exact-id subscribers and opted-in
|
|
// listings see them; everything else above filters them out.
|
|
{ replayState: false, includeInternal: true },
|
|
);
|
|
}
|
|
|
|
private emitProviderSubagentWorkspaceUpdate(event: ProviderSubagentManagerEvent): void {
|
|
if (event.type === "timeline") {
|
|
return;
|
|
}
|
|
const parentAgentId =
|
|
event.type === "upsert" ? event.subagent.parentAgentId : event.parentAgentId;
|
|
const parent = this.agentManager.getAgent(parentAgentId);
|
|
if (!parent?.workspaceId) {
|
|
return;
|
|
}
|
|
void this.emitWorkspaceUpdateForWorkspaceId(parent.workspaceId).catch((error) => {
|
|
this.sessionLogger.error(
|
|
{ err: error, parentAgentId, workspaceId: parent.workspaceId },
|
|
"Failed to emit provider subagent workspace update",
|
|
);
|
|
});
|
|
}
|
|
|
|
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 enrichAgentPayload(payload: AgentSnapshotPayload): Promise<AgentSnapshotPayload> {
|
|
const storedRecord = await this.agentStorage.get(payload.id);
|
|
payload.title = storedRecord?.title ?? null;
|
|
payload.archivedAt = storedRecord?.archivedAt ?? null;
|
|
return payload;
|
|
}
|
|
|
|
private buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload> {
|
|
return this.enrichAgentPayload(toAgentPayload(agent));
|
|
}
|
|
|
|
private buildStoredAgentPayload(
|
|
record: StoredAgentRecord,
|
|
registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds()),
|
|
): AgentSnapshotPayload {
|
|
return buildStoredAgentPayload(record, registeredProviderIds);
|
|
}
|
|
|
|
private isProviderVisibleToClient(provider: string): boolean {
|
|
if (
|
|
this.supports(CLIENT_CAPS.allProviders) ||
|
|
clientSupportsAllProviders(
|
|
this.delivery.currentSource
|
|
? (this.clientSources.get(this.delivery.currentSource)?.appVersion ?? null)
|
|
: 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.workspaceRuntimeStore.get(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> {
|
|
return this.delivery.request(source, msg, () => this.handleRequest(msg, source));
|
|
}
|
|
|
|
private async handleRequest(msg: SessionInboundMessage, source?: object): Promise<void> {
|
|
this.inflightRequests++;
|
|
if (this.inflightRequests > this.peakInflightRequests) {
|
|
this.peakInflightRequests = this.inflightRequests;
|
|
}
|
|
try {
|
|
// Stringifying a binary plugin input costs its size; test logger stubs lack isLevelEnabled.
|
|
if (this.sessionLogger.isLevelEnabled?.("trace"))
|
|
this.sessionLogger.trace(
|
|
{
|
|
messageType: msg.type,
|
|
payloadBytes: JSON.stringify(msg).length,
|
|
},
|
|
"agent.session.inbound",
|
|
);
|
|
if (!this.authorization.allowsInbound(msg)) {
|
|
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: msg.type.startsWith("hosts.") ? "permission_denied" : "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 setPermissions(permissions: readonly DaemonPermission[]): void {
|
|
this.authorization.replacePermissions(permissions);
|
|
this.connectedHostSession?.revoke();
|
|
if (!this.authorization.allowsPermission("workspace.write")) {
|
|
void this.delivery
|
|
.releaseFamily("browser-host")
|
|
.catch((error) =>
|
|
this.sessionLogger.error({ err: error }, "Failed to release revoked browser hosting"),
|
|
);
|
|
}
|
|
// Binary plugin events skip the outbound permission check, so revocation ends the streams.
|
|
if (!this.authorization.allowsPermission("daemon.manage")) {
|
|
void this.delivery
|
|
.releaseFamily("plugin-rpc")
|
|
.catch((error) =>
|
|
this.sessionLogger.error(
|
|
{ err: error },
|
|
"Failed to release revoked plugin subscriptions",
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
public getPermissions(): DaemonPermission[] {
|
|
return this.authorization.listPermissions();
|
|
}
|
|
|
|
public allowsInbound(message: SessionInboundMessage): boolean {
|
|
return this.authorization.allowsInbound(message);
|
|
}
|
|
|
|
public allowsPermission(permission: DaemonPermission): boolean {
|
|
return this.authorization.allowsPermission(permission);
|
|
}
|
|
|
|
public subscribesToAgent(agent: ManagedAgent, source: object): Promise<boolean> {
|
|
return this.agentUpdates.includesLiveAgent(
|
|
agent,
|
|
new Set(this.delivery.subscriptionIds(source, "agents")),
|
|
);
|
|
}
|
|
|
|
public subscribesToTerminalDirectory(
|
|
input: {
|
|
cwd: string;
|
|
workspaceId?: string;
|
|
},
|
|
source: object,
|
|
): Promise<boolean> {
|
|
return this.terminalController.hasDirectorySubscription(input, source);
|
|
}
|
|
|
|
public publishToSource(source: object, message: SessionOutboundMessage): void {
|
|
if (!this.authorization.allowsOutbound(message)) return;
|
|
this.emitSubscribedEvent(message, source);
|
|
}
|
|
|
|
public wantsSourceEvent(source: object, event: SessionEventSubscription): boolean {
|
|
return this.wantsEvent(event, source);
|
|
}
|
|
|
|
public wantsSourceNotification(source: object, event: SessionEventSubscription): boolean {
|
|
// COMPAT(ownedSubscriptions): added in v0.8.0, remove legacy notification selection after 2027-03-09.
|
|
if (!this.delivery.isModern(source)) return true;
|
|
return [...this.eventSubscriptions.values()].some(
|
|
(subscription) =>
|
|
subscription.owner.source === source &&
|
|
subscription.notifications &&
|
|
subscription.events.has(event),
|
|
);
|
|
}
|
|
|
|
public publish(message: SessionOutboundMessage): void {
|
|
this.emit(message);
|
|
}
|
|
|
|
private async registerBrowserHost(
|
|
request: Extract<SessionInboundMessage, { type: "browser.host.register.request" }>,
|
|
respond = true,
|
|
): Promise<void> {
|
|
if (!this.browserToolsBroker) throw new Error("Browser hosting is unavailable");
|
|
let unregister: (() => void) | undefined;
|
|
const owner = this.delivery.begin("browser-host", undefined, () => unregister?.());
|
|
try {
|
|
unregister = this.browserToolsBroker.registerClient({
|
|
id: owner.id,
|
|
hostKind: request.hostKind,
|
|
supportedCommands: request.supportedCommands,
|
|
sendBrowserAutomationRequest: (message) => owner.emit(message),
|
|
});
|
|
if (respond)
|
|
this.emit({
|
|
type: "browser.host.register.response",
|
|
payload: { requestId: request.requestId, subscriptionId: owner.responseId },
|
|
});
|
|
} catch (error) {
|
|
await owner.release();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async subscribeWorkspaceSetup(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.setup.subscribe.request" }>,
|
|
): Promise<void> {
|
|
if (
|
|
!this.supports(CLIENT_CAPS.workspaceSetupObservation) ||
|
|
!this.delivery.isModern(this.delivery.currentSource)
|
|
)
|
|
throw new Error("Workspace setup observation requires owned subscriptions");
|
|
// Resolve persisted blocked state first. Register and read the current cache in one
|
|
// synchronous turn afterward, so a setup transition during this read cannot be lost.
|
|
let initial: WorkspaceSetupSnapshot | null = null;
|
|
await handleWorkspaceSetupStatusRequestMessage(
|
|
{
|
|
workspaceSetupSnapshots: this.workspaceSetupObservation,
|
|
getWorkspace: (id) => this.workspaceRegistry.get(id),
|
|
emit: (message) => {
|
|
if (message.type === "workspace_setup_status_response")
|
|
initial = message.payload.snapshot;
|
|
},
|
|
},
|
|
{ ...request, type: "workspace_setup_status_request" },
|
|
);
|
|
let stop: (() => void) | undefined;
|
|
const owner = this.delivery.begin("workspace-setup", undefined, () => stop?.());
|
|
stop = this.workspaceSetupObservation.subscribe((workspaceId, snapshot) => {
|
|
if (workspaceId === request.workspaceId)
|
|
owner.emit({ type: "workspace_setup_progress", payload: { workspaceId, ...snapshot } });
|
|
});
|
|
this.emit({
|
|
type: "workspace.setup.subscribe.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
subscriptionId: owner.responseId,
|
|
workspaceId: request.workspaceId,
|
|
snapshot: this.workspaceSetupObservation.get(request.workspaceId) ?? initial,
|
|
},
|
|
});
|
|
}
|
|
|
|
private dispatchSubscriptionMessage(
|
|
msg: SessionInboundMessage,
|
|
source?: object,
|
|
): Promise<void> | undefined {
|
|
if (msg.type === "workspace.setup.subscribe.request") return this.subscribeWorkspaceSetup(msg);
|
|
if (msg.type === "browser.host.register.request") return this.registerBrowserHost(msg);
|
|
if (msg.type === "browser.automation.execute.response") {
|
|
if (source)
|
|
this.browserToolsBroker?.receiveResponse(
|
|
msg,
|
|
this.delivery.subscriptionIds(source, "browser-host"),
|
|
);
|
|
return Promise.resolve();
|
|
}
|
|
if (msg.type === "subscription.release.request") {
|
|
return this.delivery.release(msg.subscriptionId).then(() => {
|
|
this.emit({
|
|
type: "subscription.release.response",
|
|
payload: { requestId: msg.requestId, subscriptionId: msg.subscriptionId },
|
|
});
|
|
return undefined;
|
|
});
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private async dispatchInboundMessage(msg: SessionInboundMessage, source?: object): Promise<void> {
|
|
const promise =
|
|
this.connectedHostSession?.dispatch(msg) ??
|
|
this.dispatchSubscriptionMessage(msg, source) ??
|
|
this.dispatchVoiceAndControlMessage(msg) ??
|
|
this.dispatchAgentObservationMessage(msg, source) ??
|
|
this.dispatchHubExecutionMessage(msg) ??
|
|
this.dispatchCreationMessage(msg, source) ??
|
|
this.dispatchAgentLifecycleMessage(msg) ??
|
|
this.dispatchAgentConfigMessage(msg) ??
|
|
this.dispatchCheckoutMessage(msg) ??
|
|
this.dispatchWorkspaceLifecycleMessage(msg) ??
|
|
this.dispatchWorkspaceFileMessage(msg, source) ??
|
|
this.dispatchProviderMessage(msg) ??
|
|
this.dispatchOrchestrationSkillsMessage(msg) ??
|
|
this.dispatchPluginDirectoryMessage(msg) ??
|
|
this.dispatchPluginMessage(msg) ??
|
|
this.dispatchPluginRpcMessage(msg) ??
|
|
this.dispatchHostServiceMessage(msg) ??
|
|
this.dispatchMiscMessage(msg);
|
|
if (promise) await promise;
|
|
}
|
|
|
|
private dispatchAgentObservationMessage(
|
|
msg: SessionInboundMessage,
|
|
source?: object,
|
|
): Promise<void> | undefined {
|
|
return (
|
|
this.dispatchAgentRewindMessage(msg, source) ??
|
|
this.dispatchAgentRelationshipMessage(msg) ??
|
|
this.dispatchAgentTimelineMessage(msg, source)
|
|
);
|
|
}
|
|
|
|
private dispatchHostServiceMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
return (
|
|
this.dispatchTerminalMessage(msg) ??
|
|
this.dispatchScheduleMessage(msg) ??
|
|
this.speechModelsSession.dispatch(msg)
|
|
);
|
|
}
|
|
|
|
private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
return (
|
|
this.dispatchWorkspaceStateMessage(msg) ??
|
|
this.dispatchWorkspaceLabelMessage(msg) ??
|
|
this.dispatchWorkspaceSetupMessage(msg) ??
|
|
this.dispatchWorkspaceAndProjectMessage(msg)
|
|
);
|
|
}
|
|
|
|
private dispatchOrchestrationSkillsMessage(
|
|
msg: SessionInboundMessage,
|
|
): Promise<void> | undefined {
|
|
if (!this.orchestrationSkills || !msg.type.startsWith("agent.skills.")) return undefined;
|
|
const emitStatus = (
|
|
type:
|
|
| "agent.skills.get_status.response"
|
|
| "agent.skills.reconcile.response"
|
|
| "agent.skills.uninstall.response",
|
|
requestId: string,
|
|
operation: Promise<import("./orchestration-skills/index.js").SkillsSnapshot>,
|
|
) =>
|
|
operation.then((status) => {
|
|
this.emit({ type, payload: { requestId, ...status } });
|
|
return undefined;
|
|
});
|
|
switch (msg.type) {
|
|
case "agent.skills.get_status.request":
|
|
return emitStatus(
|
|
"agent.skills.get_status.response",
|
|
msg.requestId,
|
|
this.orchestrationSkills.getStatus(),
|
|
);
|
|
case "agent.skills.reconcile.request":
|
|
return emitStatus(
|
|
"agent.skills.reconcile.response",
|
|
msg.requestId,
|
|
this.orchestrationSkills.reconcile(),
|
|
);
|
|
case "agent.skills.uninstall.request":
|
|
return emitStatus(
|
|
"agent.skills.uninstall.response",
|
|
msg.requestId,
|
|
this.orchestrationSkills.uninstall(),
|
|
);
|
|
case "agent.skills.save_selection.request":
|
|
return this.orchestrationSkills
|
|
.saveSelection(msg.selection, msg.confirmedRemovals)
|
|
.then((result) => {
|
|
this.emit({
|
|
type: "agent.skills.save_selection.response",
|
|
payload: { requestId: msg.requestId, ...result },
|
|
});
|
|
return undefined;
|
|
});
|
|
case "agent.skills.import_legacy_selection.request":
|
|
return this.orchestrationSkills
|
|
.importLegacySelectionIfUnset(msg.selection)
|
|
.then((result) => {
|
|
this.emit({
|
|
type: "agent.skills.import_legacy_selection.response",
|
|
payload: { requestId: msg.requestId, ...result },
|
|
});
|
|
return undefined;
|
|
});
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private dispatchPluginMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
if (msg.type === "plugin.list.request") {
|
|
return (this.pluginRuntime?.listPlugins() ?? Promise.resolve([])).then((plugins) => {
|
|
this.emit({
|
|
type: "plugin.list.response",
|
|
payload: { requestId: msg.requestId, plugins },
|
|
});
|
|
return undefined;
|
|
});
|
|
}
|
|
if (msg.type === "plugin.logs.get.request") {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
this.emit({
|
|
type: "plugin.logs.get.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
pluginId: msg.pluginId,
|
|
entries: this.pluginRuntime.getLogs(msg.pluginId),
|
|
},
|
|
});
|
|
return undefined;
|
|
}
|
|
if (msg.type === "plugin.reload.request") {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
return this.pluginRuntime.reloadPlugin(msg.pluginId).then((plugin) => {
|
|
this.emit({
|
|
type: "plugin.reload.response",
|
|
payload: { requestId: msg.requestId, plugin },
|
|
});
|
|
return undefined;
|
|
});
|
|
}
|
|
if (msg.type === "plugin.enable.request") {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
return this.pluginRuntime.enablePlugin(msg.pluginId).then((plugin) => {
|
|
this.emit({
|
|
type: "plugin.enable.response",
|
|
payload: { requestId: msg.requestId, plugin },
|
|
});
|
|
return undefined;
|
|
});
|
|
}
|
|
if (msg.type === "plugin.disable.request") {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
return this.pluginRuntime.disablePlugin(msg.pluginId).then((plugin) => {
|
|
this.emit({
|
|
type: "plugin.disable.response",
|
|
payload: { requestId: msg.requestId, plugin },
|
|
});
|
|
return undefined;
|
|
});
|
|
}
|
|
if (msg.type === "plugin.remove.request") {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
return this.pluginRuntime.removePlugin(msg.pluginId).then(() => {
|
|
this.emit({ type: "plugin.remove.response", payload: { requestId: msg.requestId } });
|
|
return undefined;
|
|
});
|
|
}
|
|
if (msg.type === "plugin.catalog.get.request") {
|
|
const cached = new Map(
|
|
msg.cachedClientBundles?.map((entry) => [entry.id, entry.clientBundleHash]),
|
|
);
|
|
const plugins = (this.pluginRuntime?.catalog() ?? []).map(({ clientBundle, ...plugin }) =>
|
|
plugin.clientBundleHash !== undefined && cached.get(plugin.id) === plugin.clientBundleHash
|
|
? plugin
|
|
: Object.assign(plugin, { clientBundle }),
|
|
);
|
|
this.emit({
|
|
type: "plugin.catalog.get.response",
|
|
payload: { requestId: msg.requestId, plugins },
|
|
});
|
|
return undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private dispatchPluginRpcMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
if (msg.type === "plugin.rpc.invoke.request") {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
return this.pluginRuntime
|
|
.invokePluginRpc(msg.pluginId, msg.method, msg.input)
|
|
.then((output) => this.replyPluginRpc(msg, output));
|
|
}
|
|
if (msg.type === "plugin.rpc.subscribe.request") return this.subscribePluginRpc(msg);
|
|
return undefined;
|
|
}
|
|
|
|
/** A `Uint8Array` output is the binary form of `plugin.rpc.invoke.response`. */
|
|
private async replyPluginRpc(
|
|
request: Extract<SessionInboundMessage, { type: "plugin.rpc.invoke.request" }>,
|
|
output: unknown,
|
|
): Promise<void> {
|
|
const response: SessionOutboundMessage = {
|
|
type: "plugin.rpc.invoke.response",
|
|
payload: { requestId: request.requestId, output },
|
|
};
|
|
if (!(output instanceof Uint8Array)) {
|
|
this.emit(response);
|
|
return;
|
|
}
|
|
// The frame is the binary form of the response, so it needs the response's permission.
|
|
if (!this.authorization.allowsOutbound(response)) return;
|
|
if (!this.supports(CLIENT_CAPS.pluginBinaryPayloads) || this.onBinaryMessage === null)
|
|
throw new Error(
|
|
`Update Paseo to receive the binary output of plugin RPC ${request.pluginId}.${request.method}`,
|
|
);
|
|
await this.emitBinaryReply(
|
|
encodePluginOutputFrame(request.requestId, output),
|
|
this.delivery.currentSource,
|
|
);
|
|
}
|
|
|
|
private async subscribePluginRpc(
|
|
request: Extract<SessionInboundMessage, { type: "plugin.rpc.subscribe.request" }>,
|
|
): Promise<void> {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
const subscription = this.pluginRuntime.subscribePluginRpc(
|
|
request.pluginId,
|
|
request.method,
|
|
request.input,
|
|
);
|
|
const owner = this.delivery.begin("plugin-rpc", undefined, () => subscription.release());
|
|
try {
|
|
await subscription.ready;
|
|
} catch (error) {
|
|
await owner.release();
|
|
throw error;
|
|
}
|
|
if (owner.signal.aborted) return;
|
|
this.emit({
|
|
type: "plugin.rpc.subscribe.response",
|
|
payload: { requestId: request.requestId, subscriptionId: owner.responseId },
|
|
});
|
|
const close = (reason?: string) => {
|
|
owner.emit({
|
|
type: "plugin.rpc.closed",
|
|
payload: {
|
|
subscriptionId: owner.responseId,
|
|
...(reason === undefined ? {} : { error: reason }),
|
|
},
|
|
});
|
|
void owner.release().catch(() => undefined);
|
|
};
|
|
subscription.attach({
|
|
event: (event) => {
|
|
const message: SessionOutboundMessage = {
|
|
type: "plugin.rpc.event",
|
|
payload: { subscriptionId: owner.responseId, event },
|
|
};
|
|
if (!(event instanceof Uint8Array)) {
|
|
owner.emit(message);
|
|
return;
|
|
}
|
|
if (!this.authorization.allowsOutbound(message)) return;
|
|
if (this.onBinaryMessage === null) {
|
|
subscription.release();
|
|
close("This connection cannot carry binary plugin events");
|
|
return;
|
|
}
|
|
owner.emitBinary(encodePluginEventFrame(owner.responseId, event));
|
|
},
|
|
closed: close,
|
|
});
|
|
}
|
|
|
|
private dispatchPluginDirectoryMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
if (msg.type === "plugin.source.install.request") {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
return this.pluginRuntime
|
|
.installSource({
|
|
// COMPAT(plugin-source-path): accepted for v0.7 clients; remove after 2027-09-01.
|
|
source: formatPluginSourceReference(msg.source, msg.pluginPath),
|
|
...(msg.id ? { id: msg.id } : {}),
|
|
...(msg.ref ? { ref: msg.ref } : {}),
|
|
})
|
|
.then((plugin) => {
|
|
this.emit({
|
|
type: "plugin.source.install.response",
|
|
payload: { requestId: msg.requestId, plugin },
|
|
});
|
|
return undefined;
|
|
});
|
|
}
|
|
if (msg.type === "plugin.source.status.request") {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
return this.pluginRuntime.statusSources(msg.pluginId).then((plugins) => {
|
|
this.emit({
|
|
type: "plugin.source.status.response",
|
|
payload: { requestId: msg.requestId, plugins },
|
|
});
|
|
return undefined;
|
|
});
|
|
}
|
|
if (msg.type === "plugin.source.update.preview.request") {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
return this.pluginRuntime
|
|
.previewUpdates({ pluginId: msg.pluginId, target: msg.target })
|
|
.then((plugins) => {
|
|
this.emit({
|
|
type: "plugin.source.update.preview.response",
|
|
payload: { requestId: msg.requestId, plugins },
|
|
});
|
|
return undefined;
|
|
});
|
|
}
|
|
if (msg.type === "plugin.source.update.apply.request") {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
return this.pluginRuntime.applyUpdates(msg.proposals).then((plugins) => {
|
|
this.emit({
|
|
type: "plugin.source.update.apply.response",
|
|
payload: { requestId: msg.requestId, plugins },
|
|
});
|
|
return undefined;
|
|
});
|
|
}
|
|
if (msg.type === "plugin.source.update.request") {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
return this.pluginRuntime.updateSources(msg.pluginId).then((plugins) => {
|
|
this.emit({
|
|
type: "plugin.source.update.response",
|
|
payload: { requestId: msg.requestId, plugins },
|
|
});
|
|
return undefined;
|
|
});
|
|
}
|
|
if (msg.type === "plugin.directory.install.request") {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
return this.pluginRuntime.installDirectory({ path: msg.path, id: msg.id }).then((plugin) => {
|
|
this.emit({
|
|
type: "plugin.directory.install.response",
|
|
payload: { requestId: msg.requestId, plugin },
|
|
});
|
|
return undefined;
|
|
});
|
|
}
|
|
if (msg.type === "plugin.directory.inspect.request") {
|
|
if (!this.pluginRuntime) throw new Error("Plugin service is unavailable");
|
|
return this.pluginRuntime.inspectDirectory(msg.path).then(({ id }) => {
|
|
this.emit({
|
|
type: "plugin.directory.inspect.response",
|
|
payload: { requestId: msg.requestId, id },
|
|
});
|
|
return undefined;
|
|
});
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private subscribeToPluginChanges(
|
|
pluginRuntime: SessionOptions["pluginRuntime"],
|
|
): (() => void) | null {
|
|
if (!pluginRuntime) return null;
|
|
const catalog = pluginRuntime.subscribe((pluginId) => {
|
|
this.emit({ type: "status", payload: { status: "plugin_catalog_changed", pluginId } });
|
|
});
|
|
const settings = pluginRuntime.subscribeSettings?.((pluginId, settingsId) => {
|
|
this.emit({
|
|
type: "status",
|
|
payload: { status: "plugin_settings_changed", pluginId, settingsId },
|
|
});
|
|
});
|
|
return () => {
|
|
catalog();
|
|
settings?.();
|
|
};
|
|
}
|
|
|
|
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "voice_audio_chunk":
|
|
case "abort_request":
|
|
case "audio_played":
|
|
case "set_voice_mode":
|
|
case "dictation_stream_start":
|
|
case "dictation_stream_chunk":
|
|
case "dictation_stream_finish":
|
|
case "dictation_stream_cancel":
|
|
return this.voiceSessions.handleMessage(msg);
|
|
case "restart_server_request":
|
|
return this.handleRestartServerRequest(msg.requestId, msg.reason, {
|
|
keepTerminals: msg.killTerminals !== true,
|
|
});
|
|
case "shutdown_server_request":
|
|
return this.handleShutdownServerRequest(msg.requestId, {
|
|
keepTerminals: msg.keepTerminals === true,
|
|
});
|
|
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,
|
|
source?: object,
|
|
): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "agent.rewind.request":
|
|
return this.handleAgentRewindRequest(msg, source);
|
|
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, source);
|
|
case "agent.timeline.append.request":
|
|
return this.handleAgentTimelineAppendRequest(msg);
|
|
case "agent.timeline.search.request":
|
|
return this.handleAgentTimelineSearchRequest(msg, source);
|
|
case "agent.timeline.list_prompts.request":
|
|
return this.handleAgentTimelineListPromptsRequest(msg, source);
|
|
case "agent.provider_subagents.list.request":
|
|
return this.handleProviderSubagentListRequest(msg);
|
|
case "agent.provider_subagents.timeline.get.request":
|
|
return this.handleProviderSubagentTimelineRequest(msg, source);
|
|
case "session.events.set_subscription.request": {
|
|
const owner = this.delivery.begin("events", undefined, async (id) => {
|
|
this.eventSubscriptions.delete(id);
|
|
this.refreshObservationProducers();
|
|
if (msg.events.includes("checkout_status_update"))
|
|
await this.reconcileWorkspaceGitObservers();
|
|
});
|
|
this.eventSubscriptions.set(owner.id, {
|
|
owner,
|
|
events: new Set(msg.events),
|
|
notifications: msg.notifications === true,
|
|
});
|
|
this.emitForSource(
|
|
{
|
|
type: "session.events.set_subscription.response",
|
|
payload: { requestId: msg.requestId, subscriptionId: owner.responseId },
|
|
},
|
|
source,
|
|
);
|
|
this.refreshObservationProducers();
|
|
if (!msg.events.includes("checkout_status_update")) return undefined;
|
|
return this.reconcileWorkspaceGitObservers().catch(async (error) => {
|
|
await owner.release();
|
|
throw error;
|
|
});
|
|
}
|
|
case "agent.timeline.set_subscription.request": {
|
|
const agentIds = [...new Set(msg.agentIds)].sort();
|
|
const owner = this.subscribeAgentTimelines(agentIds);
|
|
this.emitForSource(
|
|
{
|
|
type: "agent.timeline.set_subscription.response",
|
|
payload: { agentIds, requestId: msg.requestId, subscriptionId: owner.responseId },
|
|
},
|
|
source,
|
|
);
|
|
return undefined;
|
|
}
|
|
case "agent.fork_context.request":
|
|
return this.handleAgentForkContextRequest(msg);
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private dispatchHubExecutionMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
if (msg.type === "hub.execution.agent.create.request") {
|
|
return this.hubExecutionController?.createAgent(msg);
|
|
}
|
|
if (msg.type === "hub.execution.agent.validate.request") {
|
|
return this.hubExecutionController?.validateAgent(msg);
|
|
}
|
|
if (msg.type === "hub.execution.control.request") {
|
|
return this.hubExecutionController?.controlExecution(msg);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private dispatchCreationMessage(
|
|
msg: SessionInboundMessage,
|
|
source?: object,
|
|
): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "agent.create.request":
|
|
return this.handleAgentCreation(msg, source);
|
|
case "creation.subscribe.request":
|
|
return this.handleCreationSubscription(msg, source);
|
|
case "workspace.create.request":
|
|
return this.handleWorkspaceCreation(msg, source);
|
|
default:
|
|
return 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 "project.icon.set.request":
|
|
return this.handleProjectIconSetRequest(msg);
|
|
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 "agent.config.apply.request":
|
|
return this.agentConfigSession.handleAgentConfigApplyRequest(msg);
|
|
case "get_daemon_config_request":
|
|
this.emit({
|
|
type: "get_daemon_config_response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
config: toWireDaemonConfig(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 "daemon.config.reload.request":
|
|
return this.daemonSession.handleConfigReloadRequest(msg);
|
|
case "hub.management.daemon.connect.request":
|
|
case "hub.management.daemon.get_status.request":
|
|
case "hub.management.daemon.disconnect.request":
|
|
case "hub.management.daemon.permissions.update.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":
|
|
return this.daemonConfigStore.patch(msg.config).then((config) => {
|
|
return this.emit({
|
|
type: "set_daemon_config_response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
config: toWireDaemonConfig(config),
|
|
},
|
|
});
|
|
});
|
|
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, this.delivery.requestSignal);
|
|
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 "checkout.diff.get.request":
|
|
return this.checkoutSession.handleReadDiffRequest(msg);
|
|
case "subscribe_checkout_diff_request":
|
|
return this.checkoutSession.handleSubscribeDiffRequest(msg, this.delivery);
|
|
case "unsubscribe_checkout_diff_request":
|
|
return this.checkoutSession.handleUnsubscribeDiffRequest(msg, this.delivery);
|
|
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.discard_changes.request":
|
|
return this.checkoutSession.handleCheckoutDiscardChangesRequest(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 "project.list.request":
|
|
return this.handleProjectListRequest(msg);
|
|
case "project.suggestions.request":
|
|
return this.handleProjectSuggestionsRequest(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);
|
|
// 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.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 dispatchWorkspaceSetupMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
if (msg.type === "workspace_setup_status_request") {
|
|
return this.handleWorkspaceSetupStatusRequest(msg);
|
|
}
|
|
if (msg.type === "workspace.setup.run.request") {
|
|
return this.handleWorkspaceSetupRunRequest(msg);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
private dispatchWorkspaceLabelMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "workspace.label.list.request":
|
|
return this.handleWorkspaceLabelList(msg);
|
|
case "workspace.label.assignment.set.request":
|
|
return this.handleWorkspaceLabelAssignment(msg);
|
|
case "workspace.label.update.request":
|
|
return this.handleWorkspaceLabelUpdate(msg);
|
|
case "workspace.label.delete.request":
|
|
return this.handleWorkspaceLabelDelete(msg);
|
|
case "workspace.label.delete.inspect.request":
|
|
return this.handleWorkspaceLabelDeleteInspection(msg);
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private dispatchWorkspaceFileMessage(
|
|
msg: SessionInboundMessage,
|
|
source?: object,
|
|
): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "file_explorer_request":
|
|
return this.workspaceFilesSession.handleFileExplorerRequest(msg, source);
|
|
case "fs.file.subscribe.request":
|
|
return this.workspaceFilesSession.handleFileSubscribeRequest(msg, this.delivery);
|
|
case "fs.file.unsubscribe.request":
|
|
return this.workspaceFilesSession.handleFileUnsubscribeRequest(msg, this.delivery);
|
|
case "fs.file.write.request":
|
|
return this.workspaceFilesSession.handleFileWriteRequest(msg);
|
|
case "fs.entry.create.request":
|
|
return this.workspaceFilesSession.handleFileEntryCreateRequest(msg);
|
|
case "fs.entry.rename.request":
|
|
return this.workspaceFilesSession.handleFileEntryRenameRequest(msg);
|
|
case "fs.entry.duplicate.request":
|
|
return this.workspaceFilesSession.handleFileEntryDuplicateRequest(msg);
|
|
case "fs.entry.delete.request":
|
|
return this.workspaceFilesSession.handleFileEntryDeleteRequest(msg);
|
|
case "project_icon_request":
|
|
return this.workspaceFilesSession.handleProjectIconRequest(msg);
|
|
case "project.icon.get.request":
|
|
return this.handleProjectIconGetRequest(msg.projectId, msg.requestId);
|
|
case "file_download_token_request":
|
|
return this.workspaceFilesSession.handleFileDownloadTokenRequest(msg);
|
|
case "file.upload.request":
|
|
this.workspaceFilesSession.handleFileUploadRequest(msg, this.delivery);
|
|
return undefined;
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private dispatchWorkspaceStateMessage(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);
|
|
case "workspace.clear_attention.request":
|
|
return this.handleWorkspaceClearAttentionRequest(msg);
|
|
case "workspace.mark_unread.request":
|
|
return this.handleWorkspaceMarkUnreadRequest(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 "provider.controls.list.request":
|
|
return this.providerCatalogSession.handleProviderControlsListRequest(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.usageSession.handleLegacyList(msg);
|
|
case "usage.list_reports.request":
|
|
return this.usageSession.handleListReports(msg);
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private dispatchTerminalMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "start_workspace_script_request":
|
|
return this.handleStartWorkspaceScriptRequest(msg);
|
|
case "workspace.script.list.request":
|
|
return this.handleWorkspaceScriptListRequest(msg);
|
|
case "workspace.script.start.request":
|
|
return this.handleWorkspaceScriptStartRequest(msg);
|
|
case "workspace.script.stop.request":
|
|
return this.handleWorkspaceScriptStopRequest(msg);
|
|
default:
|
|
return this.terminalController.dispatch(msg, this.delivery);
|
|
}
|
|
}
|
|
|
|
private dispatchScheduleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
|
switch (msg.type) {
|
|
case "schedule/create":
|
|
return this.scheduleSession.handleScheduleCreateRequest(msg);
|
|
case "schedule/list":
|
|
return this.scheduleSession.handleScheduleListRequest(msg);
|
|
case "schedule/inspect":
|
|
return this.scheduleSession.handleScheduleInspectRequest(msg);
|
|
case "schedule/logs":
|
|
return this.scheduleSession.handleScheduleLogsRequest(msg);
|
|
case "schedule/pause":
|
|
return this.scheduleSession.handleSchedulePauseRequest(msg);
|
|
case "schedule/resume":
|
|
return this.scheduleSession.handleScheduleResumeRequest(msg);
|
|
case "schedule/delete":
|
|
return this.scheduleSession.handleScheduleDeleteRequest(msg);
|
|
case "schedule/run-once":
|
|
return this.scheduleSession.handleScheduleRunOnceRequest(msg);
|
|
case "schedule/update":
|
|
return this.scheduleSession.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;
|
|
case "push.unregister.request":
|
|
this.pushNotifications.revoke(msg.token);
|
|
if (this.currentClientMetadata().pushToken?.trim() === msg.token.trim()) {
|
|
this.currentClientMetadata().pushToken = null;
|
|
}
|
|
this.emit({
|
|
type: "push.unregister.response",
|
|
payload: { requestId: msg.requestId },
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
public resetPeakInflight(): void {
|
|
this.peakInflightRequests = this.inflightRequests;
|
|
}
|
|
|
|
public getSessionId(): string {
|
|
return this.sessionId;
|
|
}
|
|
|
|
public async handleBinaryFrame(binaryFrame: BinaryFrame, source: object): Promise<void> {
|
|
if (binaryFrame.kind === "host_route") {
|
|
this.connectedHostSession?.binary(binaryFrame.frame, source);
|
|
return;
|
|
}
|
|
if (binaryFrame.kind === "plugin") {
|
|
const frame = binaryFrame.frame;
|
|
// The binary form of plugin.rpc.invoke.request; it authorizes and replies like the JSON form.
|
|
if (frame.opcode !== PluginFrameOpcode.Input) return;
|
|
await this.handleMessage(
|
|
{
|
|
type: "plugin.rpc.invoke.request",
|
|
requestId: frame.requestId,
|
|
pluginId: frame.pluginId,
|
|
method: frame.method,
|
|
input: frame.payload,
|
|
},
|
|
source,
|
|
);
|
|
return;
|
|
}
|
|
if (!this.authorization.allowsPermission("workspace.write")) {
|
|
return;
|
|
}
|
|
if (binaryFrame.kind === "file_transfer") {
|
|
await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame, source);
|
|
return;
|
|
}
|
|
this.terminalController.handleBinaryFrame(binaryFrame.frame, source);
|
|
}
|
|
|
|
private async handleRestartServerRequest(
|
|
requestId: string,
|
|
reason: string | undefined,
|
|
options: { keepTerminals: boolean },
|
|
): 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, keepTerminals: options.keepTerminals },
|
|
"Restart requested via websocket",
|
|
);
|
|
this.emit({
|
|
type: "status",
|
|
payload,
|
|
});
|
|
|
|
this.emitLifecycleIntent({
|
|
type: "restart",
|
|
clientId: this.clientId,
|
|
requestId,
|
|
reason: lifecycleReason,
|
|
keepTerminals: options.keepTerminals,
|
|
});
|
|
}
|
|
|
|
private async handleShutdownServerRequest(
|
|
requestId: string,
|
|
options: { keepTerminals: boolean },
|
|
): Promise<void> {
|
|
const reason = CLIENT_SHUTDOWN_RPC_REASON;
|
|
this.sessionLogger.warn(
|
|
{ reason, keepTerminals: options.keepTerminals },
|
|
"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,
|
|
keepTerminals: options.keepTerminals,
|
|
});
|
|
}
|
|
|
|
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 knownAgent =
|
|
this.agentManager.getAgent(agentId) ?? (await this.agentStorage.get(agentId));
|
|
|
|
// 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);
|
|
// Directory removal is daemon-wide; agent_deleted below stays a source-scoped RPC reply.
|
|
if (knownAgent) this.agentManager.notifyAgentRemoved(knownAgent);
|
|
} catch (error) {
|
|
this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`);
|
|
}
|
|
|
|
this.emit({
|
|
type: "agent_deleted",
|
|
payload: {
|
|
agentId,
|
|
requestId,
|
|
},
|
|
});
|
|
}
|
|
|
|
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 (archivedRecord && 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);
|
|
|
|
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<{
|
|
record: StoredAgentRecord;
|
|
didUnarchive: boolean;
|
|
originalArchivedAt: string | null;
|
|
} | null> {
|
|
const records = await this.agentStorage.listByProviderSession(
|
|
handle.provider,
|
|
handle.sessionId,
|
|
);
|
|
const matched = records.reduce<StoredAgentRecord | null>((latest, candidate) => {
|
|
if (!latest) {
|
|
return candidate;
|
|
}
|
|
const updatedDelta =
|
|
Date.parse(resolveStoredAgentUpdatedAt(candidate)) -
|
|
Date.parse(resolveStoredAgentUpdatedAt(latest));
|
|
if (updatedDelta !== 0) {
|
|
return updatedDelta > 0 ? candidate : latest;
|
|
}
|
|
return Date.parse(candidate.createdAt) > Date.parse(latest.createdAt) ? candidate : latest;
|
|
}, null);
|
|
if (!matched) {
|
|
return null;
|
|
}
|
|
const didUnarchive = await unarchiveAgentState(
|
|
this.agentStorage,
|
|
this.agentManager,
|
|
matched.id,
|
|
);
|
|
return {
|
|
record: matched,
|
|
didUnarchive,
|
|
originalArchivedAt: matched.archivedAt ?? null,
|
|
};
|
|
}
|
|
|
|
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;
|
|
|
|
const updated = {
|
|
...existing,
|
|
customName: nextCustomName,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
await this.projectRegistry.upsert(updated);
|
|
|
|
this.emit({
|
|
type: "project.rename.response",
|
|
payload: {
|
|
requestId,
|
|
projectId,
|
|
accepted: true,
|
|
customName: nextCustomName,
|
|
error: null,
|
|
},
|
|
});
|
|
|
|
// Emit a project.update so clients that track the project as an empty
|
|
// project (no workspaces yet) receive the resolved name immediately.
|
|
await this.emitProjectUpdate({ kind: "upsert", project: updated });
|
|
|
|
// Re-emit descriptors for every workspace under this project so the new
|
|
// resolved name lands in the UI immediately.
|
|
const workspaces = await this.workspaceRegistry.listActive();
|
|
const affectedWorkspaceIds = workspaces
|
|
.filter((workspace) => workspace.projectId === existing.projectId)
|
|
.map((workspace) => workspace.workspaceId);
|
|
if (affectedWorkspaceIds.length > 0) {
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds);
|
|
}
|
|
} 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 handleProjectIconSetRequest(
|
|
request: Extract<SessionInboundMessage, { type: "project.icon.set.request" }>,
|
|
): Promise<void> {
|
|
const { projectId, requestId } = request;
|
|
try {
|
|
const updated = await setProjectCustomIcon({
|
|
paseoHome: this.paseoHome,
|
|
projectId,
|
|
source: request.source,
|
|
projects: this.projectRegistry,
|
|
});
|
|
|
|
this.emit({
|
|
type: "project.icon.set.response",
|
|
payload: { requestId, projectId, accepted: true, error: null },
|
|
});
|
|
await this.emitProjectUpdate({ kind: "upsert", project: updated });
|
|
|
|
const affectedWorkspaceIds = (await this.workspaceRegistry.listActive())
|
|
.filter((workspace) => workspace.projectId === projectId)
|
|
.map((workspace) => workspace.workspaceId);
|
|
if (affectedWorkspaceIds.length > 0) {
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds);
|
|
}
|
|
} catch (error) {
|
|
this.emit({
|
|
type: "project.icon.set.response",
|
|
payload: {
|
|
requestId,
|
|
projectId,
|
|
accepted: false,
|
|
error: getErrorMessageOr(error, "Failed to update project icon"),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleProjectIconGetRequest(projectId: string, requestId: string): Promise<void> {
|
|
try {
|
|
const project = await this.projectRegistry.get(projectId);
|
|
if (!project) throw new Error("Project not found");
|
|
|
|
const icon = await this.projectIcons.read(project);
|
|
this.emit({
|
|
type: "project.icon.get.response",
|
|
payload: { projectId, icon, error: null, requestId },
|
|
});
|
|
} catch (error) {
|
|
this.emit({
|
|
type: "project.icon.get.response",
|
|
payload: { projectId, icon: null, error: getErrorMessage(error), requestId },
|
|
});
|
|
}
|
|
}
|
|
|
|
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 project = await this.projectRegistry.get(projectId);
|
|
const resolvedProjectId = project?.projectId ?? projectId;
|
|
const projectWorkspaces = (await this.workspaceRegistry.listActive()).filter(
|
|
(workspace) => workspace.projectId === resolvedProjectId,
|
|
);
|
|
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);
|
|
}
|
|
|
|
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(resolvedProjectId);
|
|
await removeProjectCustomIcon({
|
|
paseoHome: this.paseoHome,
|
|
projectId: resolvedProjectId,
|
|
}).catch((error) => {
|
|
this.sessionLogger.warn(
|
|
{ err: error, projectId: resolvedProjectId },
|
|
"Failed to clean up removed project icon",
|
|
);
|
|
});
|
|
} finally {
|
|
if (activeWorkspaceIds.length > 0) {
|
|
this.clearWorkspaceArchiving(activeWorkspaceIds);
|
|
}
|
|
}
|
|
|
|
const updateIds =
|
|
removedWorkspaceIds.length > 0
|
|
? removedWorkspaceIds
|
|
: [projectWorkspaces[0]?.workspaceId ?? projectId];
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds(updateIds, {
|
|
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]);
|
|
} 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]);
|
|
} 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,
|
|
// A typed or spoken message from the human answers any permission the
|
|
// agent is blocked on.
|
|
clearPendingPermissions: true,
|
|
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 creationUpdate(snapshot: CreationSnapshot): SessionOutboundMessage {
|
|
return {
|
|
type: snapshot.kind === "workspace" ? "workspace.create.update" : "agent.create.update",
|
|
payload: snapshot,
|
|
};
|
|
}
|
|
|
|
private async handleCreationSubscription(
|
|
request: Extract<SessionInboundMessage, { type: "creation.subscribe.request" }>,
|
|
source?: object,
|
|
): Promise<void> {
|
|
let unsubscribe = () => {};
|
|
const owner =
|
|
request.subscribe === false
|
|
? undefined
|
|
: this.delivery.begin(
|
|
"creation",
|
|
undefined,
|
|
() => unsubscribe(),
|
|
JSON.stringify([request.kind, request.idempotencyKey]),
|
|
);
|
|
try {
|
|
const observation = await this.creationService.subscribe(
|
|
request.kind,
|
|
request.idempotencyKey,
|
|
(snapshot) => owner?.emit(this.creationUpdate(snapshot)),
|
|
);
|
|
unsubscribe = observation.unsubscribe;
|
|
if (!owner || owner.signal.aborted) unsubscribe();
|
|
this.emitForSource(
|
|
{
|
|
type: "creation.subscribe.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
subscriptionId: owner?.responseId,
|
|
snapshot: observation.snapshot,
|
|
error: null,
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
} catch (error) {
|
|
await owner?.release();
|
|
this.emitForSource(
|
|
{
|
|
type: "creation.subscribe.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
snapshot: null,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
}
|
|
}
|
|
|
|
private creationResourceExists = async (
|
|
kind: "workspace" | "agent",
|
|
id: string,
|
|
): Promise<boolean> =>
|
|
kind === "workspace"
|
|
? (await this.workspaceRegistry.get(id)) !== null
|
|
: this.agentManager.getAgent(id) != null || (await this.agentStorage.get(id)) !== null;
|
|
|
|
private createRequestedAgent(
|
|
request: AgentCreateRequest,
|
|
observer?: (snapshot: CreationSnapshot) => void,
|
|
): Promise<CreationSnapshot> {
|
|
const { requestId, type: _type, subscribe: _subscribe, idempotencyKey, ...intent } = request;
|
|
return this.creationService.create(
|
|
{
|
|
kind: "agent",
|
|
key: idempotencyKey ?? requestId,
|
|
request: intent,
|
|
workspaceId: request.workspaceId,
|
|
agentId: request.agentId,
|
|
hasAgent: true,
|
|
hasPrompt: hasInitialCreationPrompt(request),
|
|
exists: this.creationResourceExists,
|
|
readAgent: async (id) => {
|
|
const record = await this.agentStorage.get(id);
|
|
return record ? this.buildStoredAgentPayload(record) : null;
|
|
},
|
|
createAgent: async (id, _workspace, onReady) => {
|
|
try {
|
|
return await this.createSessionAgent(
|
|
{ ...request, type: "create_agent_request" },
|
|
id,
|
|
onReady,
|
|
);
|
|
} catch (error) {
|
|
throw new WorktreeRequestError(toWorktreeWireError(error));
|
|
}
|
|
},
|
|
},
|
|
observer,
|
|
);
|
|
}
|
|
|
|
private async handleAgentCreation(request: AgentCreateRequest, source?: object): Promise<void> {
|
|
const { requestId, subscribe } = request;
|
|
const progress = subscribe
|
|
? this.delivery.operation(
|
|
(message) =>
|
|
message.type === "workspace.create.update" || message.type === "agent.create.update",
|
|
() => {},
|
|
)
|
|
: undefined;
|
|
try {
|
|
const creation = await this.createRequestedAgent(
|
|
request,
|
|
progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined,
|
|
);
|
|
this.emitForSource(
|
|
{
|
|
type: "agent.create.response",
|
|
payload: { requestId, agent: creation.agent ?? null, error: creation.error, creation },
|
|
},
|
|
source,
|
|
);
|
|
} catch (error) {
|
|
this.emitForSource(
|
|
{
|
|
type: "agent.create.response",
|
|
payload: {
|
|
requestId,
|
|
agent: null,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
} finally {
|
|
await progress?.release();
|
|
}
|
|
}
|
|
|
|
private async handleWorkspaceCreation(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
|
source?: object,
|
|
): Promise<void> {
|
|
const { requestId, subscribe, type: _type, idempotencyKey, ...intent } = request;
|
|
const progress = subscribe
|
|
? this.delivery.operation(
|
|
(message) =>
|
|
message.type === "workspace.create.update" || message.type === "agent.create.update",
|
|
() => {},
|
|
)
|
|
: undefined;
|
|
try {
|
|
if (
|
|
request.agent &&
|
|
!this.authorization.allowsPermission("workspace.write") &&
|
|
!this.authorization.allowsPermission("hub.execute")
|
|
) {
|
|
throw new Error("Session is not authorized to create an agent");
|
|
}
|
|
const agentInput = request.agent;
|
|
const creation = await this.creationService.create(
|
|
{
|
|
kind: "workspace",
|
|
key: idempotencyKey ?? requestId,
|
|
request: intent,
|
|
workspaceId: request.workspaceId,
|
|
agentId: agentInput?.agentId,
|
|
hasAgent: Boolean(agentInput),
|
|
hasPrompt: hasInitialCreationPrompt(agentInput),
|
|
exists: this.creationResourceExists,
|
|
provision: async (id) => {
|
|
const descriptor = await this.createRequestedWorkspace(
|
|
{
|
|
...request,
|
|
firstAgentContext: agentInput
|
|
? { prompt: agentInput.initialPrompt, attachments: agentInput.attachments }
|
|
: request.firstAgentContext,
|
|
},
|
|
id,
|
|
);
|
|
const workspace = await this.workspaceRegistry.get(descriptor.id);
|
|
return {
|
|
workspace: descriptor,
|
|
setupSkippedReason: workspace?.untrustedSource
|
|
? formatWorkspaceAutomationBlockedMessage(workspace.untrustedSource)
|
|
: undefined,
|
|
};
|
|
},
|
|
createAgent: agentInput
|
|
? async (id, workspace, onReady) => {
|
|
if (!workspace?.workspaceDirectory)
|
|
throw new Error("Created workspace has no directory");
|
|
const sourceCwd =
|
|
request.source.kind === "directory"
|
|
? request.source.path
|
|
: await resolveWorktreeSourceCwd(request.source, this.projectRegistry);
|
|
const relativeCwd = relative(resolve(sourceCwd), resolve(agentInput.config.cwd));
|
|
if (
|
|
relativeCwd === ".." ||
|
|
relativeCwd.startsWith(`..${sep}`) ||
|
|
isAbsolute(relativeCwd)
|
|
)
|
|
throw new Error("Agent directory must be inside the workspace source");
|
|
return this.createSessionAgent(
|
|
{
|
|
...agentInput,
|
|
type: "create_agent_request",
|
|
requestId,
|
|
config: {
|
|
...agentInput.config,
|
|
cwd: resolve(workspace.workspaceDirectory, relativeCwd),
|
|
},
|
|
workspaceId: workspace.id,
|
|
},
|
|
id,
|
|
onReady,
|
|
);
|
|
}
|
|
: undefined,
|
|
},
|
|
progress ? (snapshot) => progress.emit(this.creationUpdate(snapshot)) : undefined,
|
|
);
|
|
this.emitForSource(
|
|
{
|
|
type: "workspace.create.response",
|
|
payload: {
|
|
requestId,
|
|
workspace: creation.workspace ?? null,
|
|
agent: creation.agent,
|
|
creation,
|
|
setupSkippedReason: creation.setupSkippedReason,
|
|
error: creation.error,
|
|
errorCode: creation.errorCode,
|
|
setupTerminalId: null,
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
} catch (error) {
|
|
this.emitForSource(
|
|
{
|
|
type: "workspace.create.response",
|
|
payload: {
|
|
requestId,
|
|
workspace: null,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
errorCode:
|
|
error instanceof Error && "code" in error && typeof error.code === "string"
|
|
? error.code
|
|
: undefined,
|
|
setupTerminalId: null,
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
} finally {
|
|
await progress?.release();
|
|
}
|
|
}
|
|
|
|
private async handleCreateAgentRequest(msg: CreateAgentRequestMessage): Promise<void> {
|
|
try {
|
|
let agent: AgentSnapshotPayload;
|
|
if (msg.idempotencyKey !== undefined) {
|
|
if (msg.initialPrompt !== undefined) {
|
|
throw new Error("Idempotent creation requires sending the initial prompt separately");
|
|
}
|
|
const creation = await this.createRequestedAgent({ ...msg, type: "agent.create.request" });
|
|
if (creation.error || !creation.agent)
|
|
throw new SessionRequestError(
|
|
creation.errorCode ?? "unknown",
|
|
creation.error ?? "Agent creation failed",
|
|
);
|
|
const record = await this.agentStorage.get(creation.agent.id);
|
|
if (!record) throw new Error("Previously created agent no longer exists");
|
|
agent = this.buildStoredAgentPayload(record);
|
|
} else {
|
|
({ agent } = await this.createSessionAgent(msg));
|
|
}
|
|
this.emit({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_created",
|
|
agentId: agent.id,
|
|
requestId: msg.requestId,
|
|
agent,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const wireError = error instanceof SessionRequestError ? error : toWorktreeWireError(error);
|
|
this.sessionLogger.error({ err: error }, "Failed to create agent");
|
|
this.emit({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_create_failed",
|
|
requestId: msg.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 validatePublicAgentWorkspaceIntent(request: CreateAgentRequestMessage): void {
|
|
if (request.git !== undefined || request.worktreeName !== undefined) {
|
|
throw new Error(
|
|
"Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) to create a workspace with an agent, or create the workspace first and pass workspaceId.",
|
|
);
|
|
}
|
|
if (request.internal)
|
|
throw new Error(
|
|
"Public internal agents were removed; create a background workspace instead.",
|
|
);
|
|
}
|
|
|
|
private async createSessionAgent(
|
|
msg: CreateAgentRequestMessage,
|
|
agentId?: string,
|
|
onAgentReady?: (agent: AgentSnapshotPayload) => Promise<void>,
|
|
): Promise<CreatedAgent> {
|
|
const {
|
|
config,
|
|
worktreeName,
|
|
initialPrompt,
|
|
clientMessageId,
|
|
outputSchema,
|
|
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(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}`);
|
|
}
|
|
this.validatePublicAgentWorkspaceIntent(msg);
|
|
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,
|
|
...(await this.workspaceBackgroundIntent(msg.background, msg.callerAgentId)),
|
|
});
|
|
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, initialPromptStarted } = await createAgentCommand(
|
|
{
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
logger: this.sessionLogger,
|
|
paseoHome: this.paseoHome,
|
|
worktreesRoot: this.worktreesRoot,
|
|
providerSnapshotManager: this.providerSnapshotManager,
|
|
},
|
|
{
|
|
kind: "session",
|
|
onAgentReady: async (agent) => {
|
|
createdAgentId = agent.id;
|
|
await onAgentReady?.(await this.buildAgentPayload(agent));
|
|
},
|
|
agentId,
|
|
config: resolvedIntent.config,
|
|
workspaceId: resolvedIntent.intent.workspaceId,
|
|
background: resolvedIntent.background,
|
|
initialPrompt,
|
|
source: msg.callerAgentId
|
|
? { kind: "agent-message", agentId: msg.callerAgentId }
|
|
: undefined,
|
|
clientMessageId,
|
|
outputSchema,
|
|
images,
|
|
attachments,
|
|
labels: resolvedIntent.intent.labels,
|
|
env,
|
|
provisionalTitle,
|
|
firstAgentContext,
|
|
},
|
|
);
|
|
createdAgentId = snapshot.id;
|
|
// `snapshot` predates the initial prompt; subscribers keep the last state they receive.
|
|
await this.agentUpdates.forwardLiveAgent(
|
|
this.agentManager.getAgent(snapshot.id) ?? liveSnapshot,
|
|
);
|
|
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,
|
|
});
|
|
this.sessionLogger.info(
|
|
{ agentId: snapshot.id, provider: snapshot.provider },
|
|
"Created agent",
|
|
);
|
|
return { agent: await this.buildAgentPayload(liveSnapshot), initialPromptStarted };
|
|
} catch (error) {
|
|
await this.createAgentLifecycleDispatch.cleanupCreatedWorktreeAfterFailedAgentCreate({
|
|
createdWorktree: createdWorktreeForCleanup,
|
|
createdAgentId,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async resolveSessionCreateAgentIntent(input: {
|
|
request: CreateAgentRequestMessage;
|
|
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
|
|
workspacePromptTitle: string | null;
|
|
}): Promise<ResolvedSessionCreateAgentIntent> {
|
|
const { request, createdWorktree } = input;
|
|
const callerAgent = await this.resolveCreationCaller(request.callerAgentId);
|
|
|
|
let config = request.config;
|
|
|
|
const intent = await resolveCreateAgentIntent({
|
|
explicitWorkspaceId: createdWorktree?.workspace.workspaceId ?? request.workspaceId,
|
|
caller: callerAgent,
|
|
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,
|
|
background: inheritWorkspaceBackground(request.background, callerAgent),
|
|
callerWorkspaceId: callerAgent?.workspaceId,
|
|
}),
|
|
cwd: config.cwd,
|
|
}),
|
|
});
|
|
config = { ...config, cwd: intent.cwd };
|
|
|
|
return {
|
|
config,
|
|
intent,
|
|
background: inheritAgentBackground(request.background, callerAgent),
|
|
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 {
|
|
const matched = await this.unarchiveAgentByHandle(handle);
|
|
const effectiveOverrides = matched
|
|
? { ...buildConfigOverrides(matched.record), ...overrides }
|
|
: overrides;
|
|
let snapshot: ManagedAgent;
|
|
try {
|
|
snapshot = await this.agentManager.resumeAgentFromPersistence(handle, effectiveOverrides);
|
|
} catch (error) {
|
|
if (matched?.didUnarchive && matched.originalArchivedAt) {
|
|
await this.agentManager.archiveSnapshot(matched.record.id, matched.originalArchivedAt);
|
|
}
|
|
throw error;
|
|
}
|
|
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}'`);
|
|
}
|
|
if (!toAgentPersistenceHandle(registeredProviderIds, record.persistence)) {
|
|
throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`);
|
|
}
|
|
// Share the loader's per-agent in-flight operation with timeline fetches.
|
|
// Unarchiving publishes the record before provider resume finishes, so
|
|
// the agent pane can otherwise race this request and resume it twice.
|
|
snapshot = await ensureAgentLoaded(agentId, {
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
broadcastTimeline: true,
|
|
logger: this.sessionLogger,
|
|
});
|
|
}
|
|
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" }>,
|
|
source?: object,
|
|
): Promise<void> {
|
|
try {
|
|
this.rewindInitiators.set(msg.agentId, source);
|
|
await this.agentManager.rewind(msg.agentId, msg.messageId, msg.mode);
|
|
this.emitForSource(
|
|
{
|
|
type: "agent.rewind.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
agentId: msg.agentId,
|
|
ok: true,
|
|
error: null,
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
} catch (error) {
|
|
this.emitForSource(
|
|
{
|
|
type: "agent.rewind.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
agentId: msg.agentId,
|
|
ok: false,
|
|
error: error instanceof Error ? error.message : "Failed to rewind agent",
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
} finally {
|
|
this.rewindInitiators.delete(msg.agentId);
|
|
}
|
|
}
|
|
|
|
private deliverTimelineReplacement(agentId: string, initiatingSource?: object): void {
|
|
const agent = this.agentManager.getAgent(agentId);
|
|
if (!agent) return;
|
|
const timeline = this.agentManager.fetchTimeline(agentId, { limit: 0 });
|
|
const epoch = timeline.epoch;
|
|
|
|
if (this.clientSources.size === 0 || !this.onMessageToSource) {
|
|
if (!this.supports(CLIENT_CAPS.timelineReplacementInvalidation)) {
|
|
this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch);
|
|
}
|
|
return;
|
|
}
|
|
|
|
for (const subscription of this.timelineSubscriptions.values()) {
|
|
const source = subscription.owner.source;
|
|
if (!subscription.agentIds.has(agentId)) continue;
|
|
const modern = this.delivery.isModern(source);
|
|
if (
|
|
modern ||
|
|
(source !== initiatingSource &&
|
|
this.supportsForSource(CLIENT_CAPS.timelineReplacementInvalidation, source))
|
|
) {
|
|
subscription.owner.emit({
|
|
type: "agent.timeline.replacement",
|
|
payload: { agentId, epoch },
|
|
});
|
|
}
|
|
}
|
|
// COMPAT(timelineReplacementInvalidation): added in v0.5.0, remove reconstructed replay after 2027-02-21.
|
|
for (const [source, { capabilities }] of this.clientSources) {
|
|
if (
|
|
!this.delivery.isModern(source) &&
|
|
!capabilities.has(CLIENT_CAPS.timelineReplacementInvalidation)
|
|
) {
|
|
this.emitReconstructedTimelineRows(agentId, agent.provider, timeline.rows, epoch, source);
|
|
}
|
|
}
|
|
}
|
|
|
|
private emitReconstructedTimelineRows(
|
|
agentId: string,
|
|
provider: ManagedAgent["provider"],
|
|
rows: AgentTimelineFetchResult["rows"],
|
|
epoch: string,
|
|
source?: object,
|
|
): void {
|
|
for (const row of rows) {
|
|
if (!this.supportsTimelineItem(row.item, source)) {
|
|
continue;
|
|
}
|
|
const event = serializeAgentStreamEvent({
|
|
type: "timeline",
|
|
provider,
|
|
item: row.item,
|
|
...(row.turnId ? { turnId: row.turnId } : {}),
|
|
timestamp: row.timestamp,
|
|
});
|
|
if (!event) continue;
|
|
this.emitForSource(
|
|
{
|
|
type: "agent_stream",
|
|
payload: { agentId, event, timestamp: row.timestamp, seq: row.seq, epoch },
|
|
},
|
|
source,
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
const metadata = this.currentClientMetadata();
|
|
metadata.activity = {
|
|
deviceType: msg.deviceType,
|
|
focusedAgentId: msg.focusedAgentId,
|
|
focusedTerminalId,
|
|
lastActivityAt: new Date(msg.lastActivityAt),
|
|
appVisible: msg.appVisible,
|
|
appVisibilityChangedAt,
|
|
};
|
|
if (msg.appVisible && focusedTerminalId) {
|
|
void this.clearFocusedTerminalAttention(focusedTerminalId);
|
|
}
|
|
if (metadata.pushToken) {
|
|
this.pushNotifications.renew(metadata.pushToken);
|
|
}
|
|
}
|
|
|
|
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.currentClientMetadata().pushToken = token;
|
|
this.pushNotifications.renew(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,
|
|
});
|
|
// COMPAT(ownedSubscriptions): added in v0.8.0, remove after 2027-03-09.
|
|
// Legacy clients consume the single domain resolution; modern request outcomes
|
|
// are independent of whether this socket (or its logical Session) observes it.
|
|
if (this.delivery.isModern(this.delivery.currentSource)) {
|
|
this.delivery.reply({
|
|
type: "agent_permission_resolved",
|
|
payload: { agentId, requestId, resolution: response },
|
|
});
|
|
}
|
|
} 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 homeRoot = process.env.HOME ?? homedir();
|
|
// readdir on TCC-protected folders under ~/Library blocks forever for a process that cannot
|
|
// show a consent prompt, and each blocked call holds a libuv threadpool thread.
|
|
const excludedDiscoveryPaths =
|
|
!searchesWorkspace && process.platform === "darwin" ? [join(homeRoot, "Library")] : [];
|
|
const entries = await searchDirectoryEntries({
|
|
root: workspaceCwd ? expandTilde(workspaceCwd) : homeRoot,
|
|
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,
|
|
respectGitIgnore: searchesWorkspace,
|
|
excludedDiscoveryPaths,
|
|
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 listAgentPayloads(
|
|
filter?: Parameters<AgentDirectory["list"]>[0],
|
|
): Promise<AgentSnapshotPayload[]> {
|
|
return this.agentDirectory.list(filter);
|
|
}
|
|
|
|
private async resolveAgentIdentifier(
|
|
identifier: string,
|
|
): Promise<{ ok: true; agentId: string } | { ok: false; notFound: boolean; error: string }> {
|
|
const trimmed = identifier.trim();
|
|
if (!trimmed) {
|
|
return { ok: false, notFound: false, error: "Agent identifier cannot be empty" };
|
|
}
|
|
|
|
// An internal agent, live or recently archived, is addressable by its exact
|
|
// id and nothing else: the caller that created it holds the id, and no
|
|
// listing ever includes it.
|
|
if (this.agentManager.getAgent(trimmed) || this.agentManager.getRetiredInternalAgent(trimmed)) {
|
|
return { ok: true, agentId: trimmed };
|
|
}
|
|
|
|
// Explicit identifier resolution admits archived agents by ID prefix.
|
|
const stored = await this.agentStorage.listAll();
|
|
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,
|
|
notFound: 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,
|
|
notFound: 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, notFound: true, error: `Agent not found: ${trimmed}` };
|
|
}
|
|
|
|
/**
|
|
* An agent that is no longer live, in its settled form: the retained snapshot
|
|
* of a recently archived internal agent, else its stored record. Null when
|
|
* neither exists, and for a stored internal record, which is never served.
|
|
*/
|
|
private async readSettledAgent(
|
|
agentId: string,
|
|
): Promise<{ record: StoredAgentRecord; lastMessage: string | null } | null> {
|
|
const retired = this.agentManager.getRetiredInternalAgent(agentId);
|
|
if (retired) {
|
|
return { record: retired.record, lastMessage: retired.lastMessage };
|
|
}
|
|
const record = await this.agentStorage.get(agentId);
|
|
if (!record || record.internal) {
|
|
return null;
|
|
}
|
|
return { record, lastMessage: await this.agentManager.getLastAssistantMessage(agentId) };
|
|
}
|
|
|
|
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 settled = await this.readSettledAgent(agentId);
|
|
if (!settled) {
|
|
return null;
|
|
}
|
|
const payload = this.buildStoredAgentPayload(settled.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.listActive(),
|
|
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;
|
|
search?: string;
|
|
}): Promise<FetchAgentsResponseEntry[]> {
|
|
const { candidates, limit, getPlacement, filter, search } = 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;
|
|
}
|
|
if (search && !matchesAgentHistoryQuery(search, entry)) continue;
|
|
matchedEntries.push(entry);
|
|
if (matchedEntries.length > limit) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return matchedEntries;
|
|
}
|
|
|
|
/**
|
|
* Inclusive discovery for the requesting source. Exact-ID reads never pass through this.
|
|
* COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor
|
|
* >= v0.15.0. Sources without the capability see background workspaces' agents only.
|
|
*/
|
|
private resolveBackgroundVisibility(
|
|
includeBackground: boolean | undefined,
|
|
): BackgroundVisibility {
|
|
if (!includeBackground) return false;
|
|
return this.supports(CLIENT_CAPS.backgroundAgents) ? true : "workspaces";
|
|
}
|
|
|
|
private async listFetchAgentsEntries(
|
|
request: AgentDirectoryRequestMessage,
|
|
backgroundVisibility: BackgroundVisibility,
|
|
): Promise<{
|
|
entries: FetchAgentsResponseEntry[];
|
|
pageInfo: FetchAgentsResponsePageInfo;
|
|
searchTruncated?: boolean;
|
|
}> {
|
|
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,
|
|
includeBackground: backgroundVisibility,
|
|
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;
|
|
};
|
|
|
|
const search = agentDirectorySearchQuery(request);
|
|
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,
|
|
search,
|
|
});
|
|
|
|
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.workspaceRuntimeStore.get(workspace.cwd);
|
|
if (snapshot?.git.diffStat) {
|
|
diffStat = snapshot.git.diffStat;
|
|
}
|
|
|
|
const worktreeSlug =
|
|
workspace.isPaseoOwnedWorktree && workspace.worktreeRoot
|
|
? basename(workspace.worktreeRoot)
|
|
: undefined;
|
|
|
|
return {
|
|
id: workspace.workspaceId,
|
|
projectId: workspace.projectId,
|
|
projectDisplayName: resolvedProjectRecord
|
|
? resolveProjectDisplayName(resolvedProjectRecord)
|
|
: workspace.projectId,
|
|
projectCustomName: resolvedProjectRecord?.customName ?? null,
|
|
projectCustomIconRevision: resolvedProjectRecord?.customIconRevision ?? null,
|
|
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
|
|
workspaceDirectory: workspace.cwd,
|
|
setupStatus: this.workspaceSetupObservation.status(workspace),
|
|
worktreeSlug,
|
|
projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git",
|
|
workspaceKind: workspace.kind,
|
|
name: resolveWorkspaceDisplayName(workspace),
|
|
title: workspace.title,
|
|
pinnedAt: workspace.pinnedAt,
|
|
...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}),
|
|
background: workspace.background,
|
|
archivingAt: null,
|
|
status: "done",
|
|
statusEnteredAt: null,
|
|
activityAt: null,
|
|
createdAt: workspace.createdAt,
|
|
diffStat,
|
|
scripts: await 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.workspaceRuntimeStore.get(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,
|
|
projectCustomIconRevision: projectRecord?.customIconRevision ?? null,
|
|
projectRootPath: projectRecord?.rootPath ?? result.repoRoot,
|
|
workspaceDirectory: result.workspace.cwd,
|
|
setupStatus: this.workspaceSetupObservation.status(result.workspace),
|
|
worktreeSlug: basename(result.worktree.worktreePath),
|
|
projectKind: projectRecord?.kind ?? "git",
|
|
workspaceKind: result.workspace.kind,
|
|
background: result.workspace.background,
|
|
name: resolveWorkspaceName({
|
|
title: result.workspace.title,
|
|
derivedDisplayName: result.worktree.branchName || result.workspace.displayName,
|
|
}),
|
|
title: result.workspace.title,
|
|
pinnedAt: result.workspace.pinnedAt,
|
|
...(result.workspace.labels && result.workspace.labels.length > 0
|
|
? { labels: result.workspace.labels }
|
|
: {}),
|
|
archivingAt: null,
|
|
status: "done",
|
|
statusEnteredAt: result.workspace.createdAt,
|
|
activityAt: null,
|
|
createdAt: result.workspace.createdAt,
|
|
lastUserMessageAt: 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> {
|
|
// Explicit archive administration must prefer an archived exact match to an active ancestor.
|
|
const workspaces = await this.workspaceRegistry.listAll();
|
|
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,
|
|
filter: {
|
|
...request.filter,
|
|
...(request.includeBackground ? { includeBackground: true } : {}),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof CursorError) {
|
|
throw new SessionRequestError("invalid_cursor", error.message);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private bufferOrEmitWorkspaceUpdate(
|
|
subscription: WorkspaceUpdatesSubscriptionState,
|
|
payload: WorkspaceUpdatePayload,
|
|
): void {
|
|
if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription)
|
|
return;
|
|
if (payload.kind === "upsert") {
|
|
subscription.visibleEmptyProjectIds?.delete(payload.workspace.projectId);
|
|
} else {
|
|
if (payload.emptyProject) {
|
|
subscription.visibleEmptyProjectIds?.add(payload.emptyProject.projectId);
|
|
}
|
|
if (payload.removedProjectId) {
|
|
subscription.visibleEmptyProjectIds?.delete(payload.removedProjectId);
|
|
}
|
|
}
|
|
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);
|
|
subscription.owner.emit({ type: "workspace_update", payload });
|
|
}
|
|
|
|
private flushBootstrappedWorkspaceUpdates(
|
|
subscription: WorkspaceUpdatesSubscriptionState,
|
|
options?: {
|
|
snapshotByWorkspaceId?: Map<
|
|
string,
|
|
{ status: string; statusEnteredAt: string | null; activityAtMs: number | null }
|
|
>;
|
|
},
|
|
): void {
|
|
if (
|
|
this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== 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);
|
|
subscription.owner.emit({ type: "workspace_update", payload });
|
|
}
|
|
}
|
|
|
|
private async buildProjectDescriptor(
|
|
project: PersistedProjectRecord,
|
|
): Promise<WorkspaceProjectDescriptorPayload> {
|
|
const icon = await this.projectIcons.snapshot(project);
|
|
return {
|
|
projectId: project.projectId,
|
|
...(project.projectKey ? { projectKey: project.projectKey } : {}),
|
|
projectDisplayName: resolveProjectDisplayName(project),
|
|
projectCustomName: project.customName ?? null,
|
|
projectCustomIconRevision: project.customIconRevision ?? null,
|
|
projectIconRevision: icon.revision,
|
|
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, {
|
|
lifecycle: this.pluginRuntime,
|
|
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.listActive();
|
|
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.listActive()).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);
|
|
releaseWorkspaceServicePortPlan(workspaceId);
|
|
}
|
|
|
|
private async emitWorkspaceUpdatesForWorkspaceIds(
|
|
workspaceIds: Iterable<string>,
|
|
options?: WorkspaceUpdateOptions,
|
|
): Promise<void> {
|
|
const uniqueWorkspaceIds = new Set(workspaceIds);
|
|
if (uniqueWorkspaceIds.size === 0) return;
|
|
for (const subscription of this.workspaceUpdatesSubscriptions.values()) {
|
|
await this.enqueueWorkspaceUpdates(uniqueWorkspaceIds, subscription, options);
|
|
}
|
|
}
|
|
|
|
private enqueueWorkspaceUpdates(
|
|
workspaceIds: ReadonlySet<string>,
|
|
subscription: WorkspaceUpdatesSubscriptionState,
|
|
options: WorkspaceUpdateOptions | undefined,
|
|
): Promise<void> {
|
|
const previous = Array.from(workspaceIds, (workspaceId) =>
|
|
(this.workspaceUpdateTails.get(workspaceId) ?? Promise.resolve()).catch(() => undefined),
|
|
);
|
|
const next = Promise.all(previous).then(() =>
|
|
this.delivery.forSource(subscription.owner.source, () =>
|
|
this.emitWorkspaceUpdateBatch(workspaceIds, subscription, options),
|
|
),
|
|
);
|
|
for (const workspaceId of workspaceIds) {
|
|
this.workspaceUpdateTails.set(workspaceId, next);
|
|
}
|
|
|
|
const clearTail = () => {
|
|
for (const workspaceId of workspaceIds) {
|
|
if (this.workspaceUpdateTails.get(workspaceId) === next) {
|
|
this.workspaceUpdateTails.delete(workspaceId);
|
|
}
|
|
}
|
|
};
|
|
void next.then(clearTail, clearTail);
|
|
return next;
|
|
}
|
|
|
|
private async emitWorkspaceUpdateBatch(
|
|
workspaceIds: ReadonlySet<string>,
|
|
subscription: WorkspaceUpdatesSubscriptionState,
|
|
options: WorkspaceUpdateOptions | undefined,
|
|
): Promise<void> {
|
|
if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) {
|
|
return;
|
|
}
|
|
|
|
const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({
|
|
workspaceIds,
|
|
includeGitData: true,
|
|
});
|
|
|
|
for (const workspaceId of workspaceIds) {
|
|
if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) {
|
|
return;
|
|
}
|
|
const workspace = descriptorsByWorkspaceId.get(workspaceId);
|
|
const filteredWorkspace =
|
|
workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })
|
|
? workspace
|
|
: null;
|
|
const nextWorkspace = this.applyOptimisticWorkspaceStatus(
|
|
filteredWorkspace,
|
|
options?.optimisticStatus,
|
|
);
|
|
const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId);
|
|
this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace);
|
|
if (!nextWorkspace) {
|
|
if (this.shouldSkipWorkspaceRemoval(lastEmitted, options?.removedProjectId)) {
|
|
continue;
|
|
}
|
|
if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription) {
|
|
return;
|
|
}
|
|
subscription.lastEmittedByWorkspaceId.delete(workspaceId);
|
|
const removePayload = await this.buildWorkspaceRemoveUpdatePayload(
|
|
workspaceId,
|
|
options?.removedProjectId,
|
|
);
|
|
this.bufferOrEmitWorkspaceUpdate(
|
|
subscription,
|
|
this.directorySync.sequenceWorkspaceUpdate(
|
|
removePayload,
|
|
workspace ?? null,
|
|
workspaceId,
|
|
subscription.syncEnabled === true,
|
|
subscription.filter?.includeBackground,
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const nextPayload: WorkspaceUpdatePayload = this.directorySync.sequenceWorkspaceUpdate(
|
|
{ kind: "upsert", workspace: nextWorkspace },
|
|
workspace ?? null,
|
|
workspaceId,
|
|
subscription.syncEnabled === true,
|
|
subscription.filter?.includeBackground,
|
|
);
|
|
|
|
if (
|
|
lastEmitted &&
|
|
lastEmitted.kind === "upsert" &&
|
|
equal(lastEmitted.workspace, nextWorkspace)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
this.bufferOrEmitWorkspaceUpdate(subscription, nextPayload);
|
|
}
|
|
}
|
|
|
|
private applyOptimisticWorkspaceStatus(
|
|
workspace: WorkspaceDescriptorPayload | null,
|
|
optimisticStatus: WorkspaceDescriptorPayload["status"] | undefined,
|
|
): WorkspaceDescriptorPayload | null {
|
|
if (!workspace || !optimisticStatus) {
|
|
return workspace;
|
|
}
|
|
return { ...workspace, status: optimisticStatus };
|
|
}
|
|
|
|
private shouldSkipWorkspaceRemoval(
|
|
lastEmitted: WorkspaceUpdatePayload | undefined,
|
|
removedProjectId: string | undefined,
|
|
): boolean {
|
|
if (lastEmitted?.kind === "remove") {
|
|
return !removedProjectId || lastEmitted.removedProjectId === removedProjectId;
|
|
}
|
|
return !lastEmitted && !removedProjectId;
|
|
}
|
|
|
|
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]);
|
|
}
|
|
|
|
// 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?: {}): Promise<void> {
|
|
if (this.workspaceUpdatesSubscriptions.size === 0) return;
|
|
const workspaceIds = workspaceIdsOnCheckout(await this.workspaceRegistry.listActive(), cwd);
|
|
if (workspaceIds.length === 0) {
|
|
return;
|
|
}
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, options);
|
|
}
|
|
|
|
private async handleFetchAgents(
|
|
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
|
|
): Promise<void> {
|
|
const owner = request.subscribe
|
|
? this.delivery.begin("agents", request.subscribe.subscriptionId, (id) => {
|
|
this.agentUpdates.clearSubscription(id);
|
|
this.refreshObservationProducers();
|
|
})
|
|
: null;
|
|
const subscriptionId = owner?.responseId;
|
|
const backgroundVisibility = this.resolveBackgroundVisibility(
|
|
request.includeBackground || request.filter?.includeBackground,
|
|
);
|
|
try {
|
|
if (owner) {
|
|
this.agentUpdates.beginSubscription({
|
|
subscriptionId: owner.id,
|
|
isProviderVisible: (provider) =>
|
|
this.delivery.forSource(owner.source, () => this.isProviderVisibleToClient(provider)),
|
|
filter: request.filter,
|
|
backgroundVisibility,
|
|
syncEnabled: Boolean(request.sync),
|
|
emit: (message) => {
|
|
if (message.type === "agent_update") owner.emit(message);
|
|
},
|
|
});
|
|
this.refreshObservationProducers();
|
|
}
|
|
|
|
const payload = request.sync
|
|
? await this.readAgentDirectorySync(request, backgroundVisibility)
|
|
: await this.listFetchAgentsEntries(request, backgroundVisibility);
|
|
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(owner!.id, { snapshotUpdatedAtByAgentId });
|
|
}
|
|
} catch (error) {
|
|
if (subscriptionId) {
|
|
await owner?.release();
|
|
}
|
|
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.resolveBackgroundVisibility(request.filter?.includeBackground),
|
|
);
|
|
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 }
|
|
: {}),
|
|
...(result.providerErrors.length > 0 ? { providerErrors: result.providerErrors } : {}),
|
|
},
|
|
});
|
|
} 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 owner = request.subscribe
|
|
? this.delivery.begin("workspaces", request.subscribe.subscriptionId, async (id) => {
|
|
this.workspaceUpdatesSubscriptions.delete(id);
|
|
this.refreshObservationProducers();
|
|
await this.reconcileWorkspaceGitObservers();
|
|
})
|
|
: null;
|
|
const subscriptionId = owner?.responseId;
|
|
const subscription: WorkspaceUpdatesSubscriptionState | null = owner
|
|
? {
|
|
subscriptionId: owner.id,
|
|
owner,
|
|
syncEnabled: Boolean(request.sync),
|
|
filter: {
|
|
...request.filter,
|
|
...(request.includeBackground ? { includeBackground: true } : {}),
|
|
},
|
|
isBootstrapping: true,
|
|
pendingUpdatesByWorkspaceId: new Map(),
|
|
lastEmittedByWorkspaceId: new Map(),
|
|
visibleEmptyProjectIds: new Set(),
|
|
}
|
|
: null;
|
|
if (subscription) {
|
|
this.workspaceUpdatesSubscriptions.set(subscription.subscriptionId, subscription);
|
|
this.refreshObservationProducers();
|
|
}
|
|
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",
|
|
);
|
|
const payload = request.sync
|
|
? await this.readWorkspaceDirectorySync(request)
|
|
: await this.listFetchWorkspacesEntries(request);
|
|
|
|
this.sessionLogger.debug(
|
|
{
|
|
requestId: request.requestId,
|
|
subscriptionId,
|
|
pageInfo: payload.pageInfo,
|
|
payload: summarizeFetchWorkspacesEntries(payload.entries),
|
|
},
|
|
"fetch_workspaces_response_ready",
|
|
);
|
|
const snapshot = this.buildBootstrapSnapshot(payload.entries);
|
|
if (subscription) {
|
|
this.seedWorkspaceSubscriptionSnapshot(
|
|
subscription,
|
|
payload.entries,
|
|
payload.emptyProjects,
|
|
);
|
|
}
|
|
|
|
this.emit({
|
|
type: "fetch_workspaces_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
...(subscriptionId ? { subscriptionId } : {}),
|
|
...payload,
|
|
},
|
|
});
|
|
|
|
if (subscription) {
|
|
await this.reconcileWorkspaceGitObservers();
|
|
this.flushBootstrappedWorkspaceUpdates(subscription, snapshot);
|
|
}
|
|
} catch (error) {
|
|
await owner?.release();
|
|
const code = error instanceof SessionRequestError ? error.code : "fetch_workspaces_failed";
|
|
const message = error instanceof Error ? error.message : "Failed to fetch workspaces";
|
|
this.sessionLogger.error({ err: error }, "Failed to handle fetch_workspaces_request");
|
|
this.emit({
|
|
type: "rpc_error",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
requestType: request.type,
|
|
error: message,
|
|
code,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleProjectListRequest(
|
|
request: Extract<SessionInboundMessage, { type: "project.list.request" }>,
|
|
): Promise<void> {
|
|
try {
|
|
const projects = await Promise.all(
|
|
(await this.projectRegistry.list())
|
|
.filter((project) => !project.archivedAt)
|
|
.map((project) => this.buildProjectDescriptor(project)),
|
|
);
|
|
const synchronized = request.sync
|
|
? this.directorySync.synchronizeProjects(projects, request.sync)
|
|
: null;
|
|
this.emit({
|
|
type: "project.list.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
...(synchronized ?? { projects }),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.sessionLogger.error({ err: error }, "Failed to handle project.list.request");
|
|
this.emit({
|
|
type: "rpc_error",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
requestType: "project.list.request",
|
|
error: error instanceof Error ? error.message : "Failed to list projects",
|
|
code: "project_list_failed",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleProjectSuggestionsRequest(
|
|
request: Extract<SessionInboundMessage, { type: "project.suggestions.request" }>,
|
|
): Promise<void> {
|
|
try {
|
|
const result = await listProjectSuggestions({
|
|
request,
|
|
agentManager: this.agentManager,
|
|
projectRegistry: this.projectRegistry,
|
|
workspaceGitService: this.workspaceGitService,
|
|
});
|
|
this.emit({
|
|
type: "project.suggestions.response",
|
|
payload: { requestId: request.requestId, ...result },
|
|
});
|
|
} catch (error) {
|
|
this.sessionLogger.error({ err: error }, "Failed to handle project.suggestions.request");
|
|
this.emit({
|
|
type: "rpc_error",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
requestType: request.type,
|
|
error: error instanceof Error ? error.message : "Failed to list project suggestions",
|
|
code:
|
|
error instanceof ProjectSuggestionsRequestError
|
|
? error.code
|
|
: "project_suggestions_failed",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private requireWorkspaceLabels(): WorkspaceLabelService {
|
|
if (!this.workspaceLabelService) {
|
|
throw new SessionRequestError("workspace_labels_unavailable", "Workspace labels unavailable");
|
|
}
|
|
return this.workspaceLabelService;
|
|
}
|
|
|
|
private emitWorkspaceLabelError(
|
|
request: { requestId: string; type: string },
|
|
error: unknown,
|
|
): void {
|
|
this.emit({
|
|
type: "rpc_error",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
requestType: request.type,
|
|
code: workspaceLabelErrorCode(error),
|
|
error: error instanceof Error ? error.message : "Workspace label operation failed",
|
|
},
|
|
});
|
|
}
|
|
|
|
private async handleWorkspaceLabelList(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.label.list.request" }>,
|
|
): Promise<void> {
|
|
const service = this.requireWorkspaceLabels();
|
|
if (!request.subscribe) {
|
|
this.emit({
|
|
type: "workspace.label.list.response",
|
|
payload: { requestId: request.requestId, ...(await service.list(request.sync)) },
|
|
});
|
|
return;
|
|
}
|
|
let bootstrap: ReturnType<typeof service.subscribe> | undefined;
|
|
const owner = this.delivery.begin("labels", request.subscribe.subscriptionId, async () => {
|
|
await bootstrap?.then(
|
|
(subscription) => subscription.unsubscribe(),
|
|
() => undefined,
|
|
);
|
|
});
|
|
try {
|
|
type LiveChange = Parameters<Parameters<typeof service.subscribe>[0]["onChange"]>[0];
|
|
const pending = new Map<string, LiveChange>();
|
|
let ready = false;
|
|
const emitChange = (change: LiveChange): void => {
|
|
owner.emit({
|
|
type: "workspace.label.update",
|
|
payload: change,
|
|
});
|
|
};
|
|
bootstrap = service.subscribe({
|
|
cursor: request.sync,
|
|
onChange: (change) => {
|
|
if (owner.signal.aborted) return;
|
|
if (!ready)
|
|
pending.set(change.kind === "upsert" ? change.label.name : change.name, change);
|
|
else emitChange(change);
|
|
},
|
|
});
|
|
const subscription = await bootstrap;
|
|
if (owner.signal.aborted) return;
|
|
this.emit({
|
|
type: "workspace.label.list.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
subscriptionId: owner.responseId,
|
|
...subscription.snapshot,
|
|
},
|
|
});
|
|
ready = true;
|
|
for (const change of pending.values()) {
|
|
if (change.seq > subscription.snapshot.sync.headSeq) emitChange(change);
|
|
}
|
|
pending.clear();
|
|
} catch (error) {
|
|
await owner.release();
|
|
this.emitWorkspaceLabelError(request, error);
|
|
}
|
|
}
|
|
|
|
private async handleWorkspaceLabelAssignment(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.label.assignment.set.request" }>,
|
|
): Promise<void> {
|
|
try {
|
|
const result = await this.requireWorkspaceLabels().setAssignment(request);
|
|
this.emit({
|
|
type: "workspace.label.assignment.set.response",
|
|
payload: { requestId: request.requestId, ...result },
|
|
});
|
|
} catch (error) {
|
|
this.emitWorkspaceLabelError(request, error);
|
|
}
|
|
}
|
|
|
|
private async handleWorkspaceLabelUpdate(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.label.update.request" }>,
|
|
): Promise<void> {
|
|
try {
|
|
const result = await this.requireWorkspaceLabels().update(request);
|
|
this.emit({
|
|
type: "workspace.label.update.response",
|
|
payload: { requestId: request.requestId, ...result },
|
|
});
|
|
} catch (error) {
|
|
this.emitWorkspaceLabelError(request, error);
|
|
}
|
|
}
|
|
|
|
private async handleWorkspaceLabelDelete(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.label.delete.request" }>,
|
|
): Promise<void> {
|
|
try {
|
|
const result = await this.requireWorkspaceLabels().delete(request.name);
|
|
this.emit({
|
|
type: "workspace.label.delete.response",
|
|
payload: { requestId: request.requestId, ...result },
|
|
});
|
|
} catch (error) {
|
|
this.emitWorkspaceLabelError(request, error);
|
|
}
|
|
}
|
|
|
|
private async handleWorkspaceLabelDeleteInspection(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.label.delete.inspect.request" }>,
|
|
): Promise<void> {
|
|
try {
|
|
const affectedWorkspaceCount = await this.requireWorkspaceLabels().countAffectedWorkspaces(
|
|
request.name,
|
|
);
|
|
this.emit({
|
|
type: "workspace.label.delete.inspect.response",
|
|
payload: { requestId: request.requestId, affectedWorkspaceCount },
|
|
});
|
|
} catch (error) {
|
|
this.emitWorkspaceLabelError(request, error);
|
|
}
|
|
}
|
|
|
|
private async readAgentDirectorySync(
|
|
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
|
|
backgroundVisibility: BackgroundVisibility,
|
|
) {
|
|
if (request.scope !== "active" || request.filter) {
|
|
throw new SessionRequestError(
|
|
"invalid_request",
|
|
"Sequenced agent directory reads require scope=active and no filter.",
|
|
);
|
|
}
|
|
// The canonical collection is complete; only the read below is scoped.
|
|
const snapshot = await this.listFetchAgentsEntries(
|
|
{ ...request, page: { limit: Number.MAX_SAFE_INTEGER } },
|
|
true,
|
|
);
|
|
const result = this.directorySync.synchronizeAgents(
|
|
snapshot.entries,
|
|
request.sync ?? {},
|
|
backgroundVisibility,
|
|
);
|
|
const entries = await Promise.all(
|
|
result.entries.map(async (entry) =>
|
|
(await this.agentDirectory.includes(entry.agent, backgroundVisibility)) ? entry : null,
|
|
),
|
|
);
|
|
return { ...result, entries: entries.filter((entry) => entry !== null) };
|
|
}
|
|
|
|
private async readWorkspaceDirectorySync(
|
|
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
|
|
) {
|
|
if (request.filter)
|
|
throw new SessionRequestError(
|
|
"invalid_request",
|
|
"Sequenced workspace directory reads do not support filters.",
|
|
);
|
|
const result = this.directorySync.synchronizeWorkspaces(
|
|
await this.workspaceDirectory.listDescriptors(),
|
|
request.sync ?? {},
|
|
request.includeBackground,
|
|
);
|
|
return {
|
|
...result,
|
|
entries: result.entries.filter(
|
|
(workspace) => request.includeBackground || !workspace.background,
|
|
),
|
|
};
|
|
}
|
|
|
|
// 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 async reconcileWorkspaceGitObservers(): Promise<void> {
|
|
const all = this.wantsEvent("checkout_status_update")
|
|
? await this.workspaceDirectory.listObservationTargets()
|
|
: [];
|
|
const entries = [...this.workspaceUpdatesSubscriptions.values()].flatMap((subscription) =>
|
|
[...subscription.lastEmittedByWorkspaceId.values()].flatMap((update) =>
|
|
update.kind === "upsert" ? [update.workspace] : [],
|
|
),
|
|
);
|
|
this.workspaceGitObserver.reconcileObservers(
|
|
this.wantsEvent("checkout_status_update") ? [...entries, ...all] : entries,
|
|
);
|
|
}
|
|
|
|
private seedWorkspaceSubscriptionSnapshot(
|
|
subscription: WorkspaceUpdatesSubscriptionState,
|
|
entries: FetchWorkspacesResponseEntry[],
|
|
emptyProjects: WorkspaceProjectDescriptorPayload[],
|
|
): void {
|
|
if (this.workspaceUpdatesSubscriptions.get(subscription.subscriptionId) !== subscription)
|
|
return;
|
|
for (const entry of entries) {
|
|
subscription.lastEmittedByWorkspaceId.set(entry.id, {
|
|
kind: "upsert",
|
|
workspace: entry,
|
|
});
|
|
}
|
|
for (const project of emptyProjects) {
|
|
subscription.visibleEmptyProjectIds?.add(project.projectId);
|
|
}
|
|
}
|
|
|
|
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 resolveCreationCaller(
|
|
callerAgentId?: string,
|
|
): Promise<(CreateAgentCaller & { background?: boolean }) | null> {
|
|
if (!callerAgentId) return null;
|
|
let caller: (CreateAgentCaller & { background?: boolean }) | null =
|
|
this.agentManager.getAgent(callerAgentId);
|
|
if (!caller) {
|
|
const stored = await this.agentStorage.get(callerAgentId);
|
|
if (stored && !stored.archivedAt) caller = stored;
|
|
}
|
|
if (!caller) throw new Error(`Caller agent ${callerAgentId} not found`);
|
|
return {
|
|
id: caller.id,
|
|
cwd: caller.cwd,
|
|
workspaceId: caller.workspaceId,
|
|
background: caller.background,
|
|
};
|
|
}
|
|
|
|
private async workspaceBackgroundIntent(
|
|
background: boolean | undefined,
|
|
callerAgentId: string | undefined,
|
|
): Promise<{ background?: boolean; callerWorkspaceId?: string }> {
|
|
const caller = await this.resolveCreationCaller(callerAgentId);
|
|
return {
|
|
background: inheritWorkspaceBackground(background, caller),
|
|
callerWorkspaceId: caller?.workspaceId,
|
|
};
|
|
}
|
|
|
|
private async createRequestedWorkspace(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
|
workspaceId?: string,
|
|
): Promise<WorkspaceDescriptorPayload> {
|
|
if (request.internal)
|
|
throw new Error(
|
|
"Public internal workspaces were removed; use background workspace creation.",
|
|
);
|
|
if (request.agent?.background !== undefined)
|
|
throw new Error("Configure background on the workspace, not its initial agent.");
|
|
let creationRequest = request;
|
|
// Hooks belong to the operation: retries fingerprint the caller's input
|
|
// and must not rerun hooks or compare their potentially changing output.
|
|
if (this.pluginRuntime) {
|
|
const { type, requestId, ...input } = request;
|
|
const transformed = await this.pluginRuntime.before("workspace.create.before", input);
|
|
creationRequest = { ...transformed, type, requestId };
|
|
}
|
|
return creationRequest.source.kind === "directory"
|
|
? this.handleWorkspaceCreateLocal(creationRequest, workspaceId)
|
|
: this.handleWorkspaceCreateWorktree(creationRequest, workspaceId);
|
|
}
|
|
|
|
private async handleWorkspaceCreateLocal(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
|
workspaceId?: string,
|
|
): Promise<WorkspaceDescriptorPayload> {
|
|
if (request.source.kind !== "directory") {
|
|
throw new Error("Unexpected workspace source");
|
|
}
|
|
|
|
const cwd = expandTilde(request.source.path);
|
|
const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false);
|
|
if (!directoryExists) {
|
|
throw new SessionRequestError("directory_not_found", `Directory not found: ${cwd}`);
|
|
}
|
|
|
|
const explicitTitle = request.title?.trim() || null;
|
|
const promptTitle = resolveFirstAgentPromptTitle(request.firstAgentContext);
|
|
const workspace = await this.workspaceProvisioning.createWorkspaceForDirectory(
|
|
cwd,
|
|
explicitTitle ?? promptTitle,
|
|
request.source.projectId,
|
|
{
|
|
expectsInitialAgent: Boolean(request.firstAgentContext),
|
|
workspaceId,
|
|
...(await this.workspaceBackgroundIntent(request.background, request.callerAgentId)),
|
|
},
|
|
);
|
|
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
|
const descriptor = await this.describeWorkspaceRecord(workspace);
|
|
await this.emitCreatedWorkspaceUpdate(
|
|
descriptor,
|
|
request.firstAgentContext ? "running" : undefined,
|
|
);
|
|
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) },
|
|
);
|
|
}
|
|
return descriptor;
|
|
}
|
|
|
|
private async handleWorkspaceCreateWorktree(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
|
workspaceId?: string,
|
|
): Promise<WorkspaceDescriptorPayload> {
|
|
if (request.source.kind !== "worktree") {
|
|
throw new Error("Unexpected workspace source");
|
|
}
|
|
|
|
const source = request.source;
|
|
|
|
if (!source.cwd && !source.projectId) {
|
|
throw new SessionRequestError(
|
|
"source_required",
|
|
"cwd or projectId is required for a worktree-backed workspace",
|
|
);
|
|
}
|
|
|
|
const sourceCwd = await resolveWorktreeSourceCwd(source, this.projectRegistry);
|
|
|
|
const result = await this.createPaseoWorktreeWorkflow(
|
|
{
|
|
cwd: sourceCwd,
|
|
workspaceId,
|
|
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,
|
|
...(await this.workspaceBackgroundIntent(request.background, request.callerAgentId)),
|
|
},
|
|
source.baseBranch
|
|
? { resolveDefaultBranch: async () => source.baseBranch as string }
|
|
: undefined,
|
|
);
|
|
|
|
const descriptor = await this.describeCreatedWorktreeWorkspace(result);
|
|
await this.emitCreatedWorkspaceUpdate(
|
|
descriptor,
|
|
request.firstAgentContext ? "running" : undefined,
|
|
);
|
|
return 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.listActive()) {
|
|
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: await 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: await 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: await 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,
|
|
): Promise<WorkspaceDescriptorPayload["scripts"]> {
|
|
return this.workspaceScripts.buildSnapshot(workspace, project);
|
|
}
|
|
|
|
private handleStartWorkspaceScriptRequest(request: StartWorkspaceScriptRequest): Promise<void> {
|
|
return this.workspaceScripts.start(request);
|
|
}
|
|
|
|
private async handleWorkspaceScriptListRequest(
|
|
request: WorkspaceScriptListRequest,
|
|
): Promise<void> {
|
|
try {
|
|
const scripts = await this.workspaceScripts.list(request.workspaceId);
|
|
this.emit({
|
|
type: "workspace.script.list.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspaceId: request.workspaceId,
|
|
scripts,
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.emit({
|
|
type: "workspace.script.list.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspaceId: request.workspaceId,
|
|
scripts: [],
|
|
error: error instanceof Error ? error.message : "Failed to list workspace scripts",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleWorkspaceScriptStartRequest(
|
|
request: WorkspaceScriptStartRequest,
|
|
): Promise<void> {
|
|
try {
|
|
const script = await this.workspaceScripts.launch(request);
|
|
this.emit({
|
|
type: "workspace.script.start.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspaceId: request.workspaceId,
|
|
scriptName: request.scriptName,
|
|
script,
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.emit({
|
|
type: "workspace.script.start.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspaceId: request.workspaceId,
|
|
scriptName: request.scriptName,
|
|
script: null,
|
|
error: error instanceof Error ? error.message : "Failed to start workspace script",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleWorkspaceScriptStopRequest(
|
|
request: WorkspaceScriptStopRequest,
|
|
): Promise<void> {
|
|
try {
|
|
const script = await this.workspaceScripts.stop(request);
|
|
this.emit({
|
|
type: "workspace.script.stop.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspaceId: request.workspaceId,
|
|
scriptName: request.scriptName,
|
|
script,
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
this.emit({
|
|
type: "workspace.script.stop.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspaceId: request.workspaceId,
|
|
scriptName: request.scriptName,
|
|
script: null,
|
|
error: error instanceof Error ? error.message : "Failed to stop workspace script",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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>;
|
|
},
|
|
): 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),
|
|
}),
|
|
startWorkspaceSetup: (workspaceId, operation) =>
|
|
this.workspaceSetupRuntime.start(workspaceId, operation),
|
|
assertWorkspaceAutomationAllowed: (workspaceId) =>
|
|
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
|
|
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
|
|
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
|
|
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
|
|
this.workspaceSetupObservation.cache(workspaceId, snapshot);
|
|
},
|
|
emit: (message) => this.emit(message),
|
|
sessionLogger: this.sessionLogger,
|
|
terminalManager: this.terminalManager,
|
|
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.workspaceSetupObservation,
|
|
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
|
},
|
|
request,
|
|
);
|
|
}
|
|
|
|
private async handleWorkspaceSetupRunRequest(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.setup.run.request" }>,
|
|
): Promise<void> {
|
|
return handleWorkspaceSetupRunRequestMessage(
|
|
{
|
|
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
|
clearAutomationBlock: (workspaceId) =>
|
|
clearWorkspaceAutomationBlock(this.workspaceRegistry, workspaceId),
|
|
startWorkspaceSetup: (workspaceId, operation) =>
|
|
this.workspaceSetupRuntime.start(workspaceId, operation),
|
|
paseoHome: this.paseoHome,
|
|
worktreesRoot: this.worktreesRoot,
|
|
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
|
|
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
|
|
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
|
|
this.workspaceSetupObservation.cache(workspaceId, snapshot),
|
|
emit: (message) => this.emit(message),
|
|
sessionLogger: this.sessionLogger,
|
|
terminalManager: this.terminalManager,
|
|
serviceProxy: this.serviceProxy,
|
|
scriptRuntimeStore: this.scriptRuntimeStore,
|
|
getDaemonTcpPort: this.getDaemonTcpPort,
|
|
getDaemonTcpHost: this.getDaemonTcpHost,
|
|
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
|
|
onScriptsChanged: (workspaceId, workspaceDirectory) =>
|
|
this.workspaceScripts.emitStatusUpdate(workspaceId, workspaceDirectory),
|
|
},
|
|
request,
|
|
);
|
|
}
|
|
|
|
private async handleArchiveWorkspaceRequest(
|
|
request: Extract<SessionInboundMessage, { type: "archive_workspace_request" }>,
|
|
): Promise<void> {
|
|
try {
|
|
const existing = await this.workspaceRegistry.get(request.workspaceId);
|
|
if (!existing) {
|
|
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
|
}
|
|
|
|
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),
|
|
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
|
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),
|
|
assertWorkspaceAutomationAllowed: (workspaceId) =>
|
|
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
|
|
killTerminalsForWorkspace: (workspaceId) =>
|
|
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
|
stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(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({
|
|
workspaceIds: requestedWorkspaceIds,
|
|
includeBackground: true,
|
|
});
|
|
} 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 handleWorkspaceMarkUnreadRequest(
|
|
request: Extract<SessionInboundMessage, { type: "workspace.mark_unread.request" }>,
|
|
): Promise<void> {
|
|
const { requestId, workspaceId } = request;
|
|
let markedAgentId: string | null = null;
|
|
try {
|
|
const workspace = await this.workspaceRegistry.get(workspaceId);
|
|
if (!workspace || workspace.archivedAt) {
|
|
throw new Error(`Workspace not found: ${workspaceId}`);
|
|
}
|
|
|
|
const agents = (
|
|
await this.listAgentPayloads({
|
|
workspaceIds: [workspaceId],
|
|
includeBackground: true,
|
|
includeAncestors: true,
|
|
})
|
|
).filter((agent) => this.isProviderVisibleToClient(agent.provider));
|
|
const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const));
|
|
const candidates = agents
|
|
.filter((agent) => !agent.archivedAt && !agent.background)
|
|
.filter((agent) => agent.workspaceId === workspace.workspaceId)
|
|
.filter((agent) => resolveWorkspaceRootAgent(agent, agentsById)?.id === agent.id)
|
|
.filter((agent) => agent.status === "idle" || agent.status === "closed")
|
|
.filter((agent) => agent.requiresAttention !== true)
|
|
.filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0)
|
|
.sort(
|
|
(left, right) =>
|
|
right.updatedAt.localeCompare(left.updatedAt) || left.id.localeCompare(right.id),
|
|
);
|
|
const candidate = candidates[0];
|
|
if (!candidate) {
|
|
throw new Error(`Workspace has no finished agent to mark unread: ${workspaceId}`);
|
|
}
|
|
|
|
await this.agentManager.markAgentUnread(candidate.id);
|
|
markedAgentId = candidate.id;
|
|
this.emit({
|
|
type: "workspace.mark_unread.response",
|
|
payload: {
|
|
requestId,
|
|
workspaceId,
|
|
markedAgentId,
|
|
success: true,
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
this.sessionLogger.error({ err: error, workspaceId }, "Failed to mark workspace unread");
|
|
this.emit({
|
|
type: "workspace.mark_unread.response",
|
|
payload: {
|
|
requestId,
|
|
workspaceId,
|
|
markedAgentId,
|
|
success: false,
|
|
error: message,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise<void> {
|
|
// An unknown agent is a null agent, not an error. Errors are for empty or ambiguous identifiers.
|
|
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier);
|
|
if (!resolved.ok) {
|
|
this.emit({
|
|
type: "fetch_agent_response",
|
|
payload: {
|
|
requestId,
|
|
agent: null,
|
|
project: null,
|
|
error: resolved.notFound ? null : 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: null },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const project = agent.workspaceId
|
|
? await this.buildProjectPlacementForWorkspaceId(agent.workspaceId)
|
|
: null;
|
|
this.emit({
|
|
type: "fetch_agent_response",
|
|
payload: { requestId, agent, project, error: null },
|
|
});
|
|
}
|
|
|
|
private async handleFetchAgentTimelineRequest(
|
|
msg: Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>,
|
|
source?: object,
|
|
): Promise<void> {
|
|
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
|
|
const projection = "projected" as const;
|
|
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,
|
|
purpose: "history",
|
|
});
|
|
const agentPayload = await this.buildAgentPayload(snapshot);
|
|
|
|
const fetchedControlTimeline = this.agentManager.fetchTimeline(msg.agentId, {
|
|
direction,
|
|
cursor,
|
|
limit: msg.limit,
|
|
wholeTurns: msg.wholeTurns,
|
|
});
|
|
const selectedTimeline = {
|
|
timeline: fetchedControlTimeline,
|
|
entries: fetchedControlTimeline.rows,
|
|
startSeq: fetchedControlTimeline.startSeq,
|
|
endSeq: fetchedControlTimeline.endSeq,
|
|
hasOlder: fetchedControlTimeline.hasOlder,
|
|
hasNewer: fetchedControlTimeline.hasNewer,
|
|
};
|
|
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;
|
|
const entries = selectedTimeline.entries.filter((entry) =>
|
|
this.supportsTimelineItem(entry.item, source),
|
|
);
|
|
|
|
this.emitForSource(
|
|
{
|
|
type: "fetch_agent_timeline_response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
agentId: msg.agentId,
|
|
agent: agentPayload,
|
|
direction,
|
|
projection,
|
|
epoch: selectedTimeline.timeline.epoch,
|
|
reset: fetchedControlTimeline.reset,
|
|
staleCursor: fetchedControlTimeline.staleCursor,
|
|
gap: fetchedControlTimeline.gap,
|
|
window: selectedTimeline.timeline.window,
|
|
startCursor,
|
|
endCursor,
|
|
hasOlder: selectedTimeline.hasOlder,
|
|
hasNewer: selectedTimeline.hasNewer,
|
|
...(msg.mergeWindow === true ? { mergeWindow: true } : {}),
|
|
entries: entries.map((entry) => {
|
|
const payloadEntry = {
|
|
provider: snapshot.provider,
|
|
item: entry.item,
|
|
timestamp: entry.timestamp,
|
|
seqStart: entry.seqStart,
|
|
seqEnd: entry.seqEnd,
|
|
sourceSeqRanges: entry.sourceSeqRanges,
|
|
turnId: undefined as string | undefined,
|
|
collapsed: (
|
|
source
|
|
? this.supportsForSource(CLIENT_CAPS.reasoningMergeEnum, source)
|
|
: this.supports(CLIENT_CAPS.reasoningMergeEnum)
|
|
)
|
|
? entry.collapsed
|
|
: entry.collapsed.filter((value) => value !== "reasoning_merge"),
|
|
};
|
|
payloadEntry.turnId = entry.turnId;
|
|
return payloadEntry;
|
|
}),
|
|
error: null,
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
} catch (error) {
|
|
this.sessionLogger.error(
|
|
{ err: error, agentId: msg.agentId },
|
|
"Failed to handle fetch_agent_timeline_request",
|
|
);
|
|
this.emitForSource(
|
|
{
|
|
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,
|
|
...(msg.mergeWindow === true ? { mergeWindow: true } : {}),
|
|
entries: [],
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleAgentTimelineAppendRequest(
|
|
msg: Extract<SessionInboundMessage, { type: "agent.timeline.append.request" }>,
|
|
): Promise<void> {
|
|
const pluginId = parsePluginClientId(this.clientId);
|
|
if (!pluginId) throw new Error("Only plugin sessions can append plugin timeline items");
|
|
assertPluginTimelineDataSize(msg.item.data);
|
|
const { seq, epoch } = await this.agentManager.appendTimelineItem(msg.agentId, {
|
|
...msg.item,
|
|
pluginId,
|
|
});
|
|
this.emit({
|
|
type: "agent.timeline.append.response",
|
|
payload: { requestId: msg.requestId, seq, epoch },
|
|
});
|
|
}
|
|
|
|
private async handleAgentTimelineSearchRequest(
|
|
msg: Extract<SessionInboundMessage, { type: "agent.timeline.search.request" }>,
|
|
source?: object,
|
|
): Promise<void> {
|
|
try {
|
|
await ensureAgentLoaded(msg.agentId, {
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
logger: this.sessionLogger,
|
|
});
|
|
const rows = await this.agentManager.getTimelineRows(msg.agentId);
|
|
const { epoch } = this.agentManager.fetchTimeline(msg.agentId, {
|
|
direction: "tail",
|
|
limit: 1,
|
|
});
|
|
const result = await searchTimeline({ rows, query: msg.query, cursor: msg.cursor });
|
|
if (
|
|
this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 1 }).epoch !==
|
|
epoch
|
|
) {
|
|
throw new Error("History changed; search again");
|
|
}
|
|
this.emitForSource(
|
|
{
|
|
type: "agent.timeline.search.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
agentId: msg.agentId,
|
|
epoch,
|
|
...result,
|
|
error: null,
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
} catch (error) {
|
|
this.emitForSource(
|
|
{
|
|
type: "agent.timeline.search.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
agentId: msg.agentId,
|
|
epoch: "",
|
|
locations: [],
|
|
nextCursor: null,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handleAgentTimelineListPromptsRequest(
|
|
msg: Extract<SessionInboundMessage, { type: "agent.timeline.list_prompts.request" }>,
|
|
source?: object,
|
|
): Promise<void> {
|
|
try {
|
|
await ensureAgentLoaded(msg.agentId, {
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
logger: this.sessionLogger,
|
|
});
|
|
const rows = await this.agentManager.getTimelineRows(msg.agentId);
|
|
const timeline = this.agentManager.fetchTimeline(msg.agentId, {
|
|
direction: "tail",
|
|
limit: 1,
|
|
});
|
|
const index = buildTimelinePromptIndex(timeline.epoch, rows);
|
|
this.emitForSource(
|
|
{
|
|
type: "agent.timeline.list_prompts.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
agentId: msg.agentId,
|
|
...index,
|
|
error: null,
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
} catch (error) {
|
|
this.sessionLogger.error(
|
|
{ err: error, agentId: msg.agentId },
|
|
"Failed to handle agent.timeline.list_prompts.request",
|
|
);
|
|
this.emitForSource(
|
|
{
|
|
type: "agent.timeline.list_prompts.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
agentId: msg.agentId,
|
|
epoch: "",
|
|
prompts: [],
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
}
|
|
}
|
|
|
|
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" }>,
|
|
source?: object,
|
|
): 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");
|
|
}
|
|
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove after 2027-03-14.
|
|
const supportsProjection = source
|
|
? this.supportsForSource(CLIENT_CAPS.projectedSubagentTimeline, source)
|
|
: this.supports(CLIENT_CAPS.projectedSubagentTimeline);
|
|
const timeline = this.agentManager.fetchProviderSubagentTimeline(
|
|
msg.parentAgentId,
|
|
msg.subagentId,
|
|
{
|
|
direction,
|
|
cursor: msg.cursor,
|
|
limit: msg.limit ?? (direction === "after" ? 0 : 200),
|
|
},
|
|
);
|
|
const rows = timeline.rows.filter((row) => this.supportsTimelineItem(row.item, source));
|
|
this.emitForSource(
|
|
{
|
|
type: "agent.provider_subagents.timeline.get.response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
parentAgentId: msg.parentAgentId,
|
|
subagentId: msg.subagentId,
|
|
provider: descriptor.provider,
|
|
direction,
|
|
epoch: timeline.epoch,
|
|
projection: "projected",
|
|
startCursor:
|
|
timeline.startSeq === null ? null : { epoch: timeline.epoch, seq: timeline.startSeq },
|
|
endCursor:
|
|
timeline.endSeq === null ? null : { epoch: timeline.epoch, seq: timeline.endSeq },
|
|
reset: timeline.reset,
|
|
staleCursor: timeline.staleCursor,
|
|
gap: timeline.gap,
|
|
window: timeline.window,
|
|
hasOlder: supportsProjection && timeline.hasOlder,
|
|
hasNewer: supportsProjection && timeline.hasNewer,
|
|
rows: supportsProjection
|
|
? rows.map((row) => ({
|
|
item: row.item,
|
|
timestamp: row.timestamp,
|
|
seq: row.seqEnd,
|
|
seqStart: row.seqStart,
|
|
seqEnd: row.seqEnd,
|
|
sourceSeqRanges: row.sourceSeqRanges,
|
|
}))
|
|
: [
|
|
{
|
|
seq: timeline.window.maxSeq,
|
|
timestamp: new Date().toISOString(),
|
|
item: {
|
|
type: "assistant_message",
|
|
text: "Please upgrade the Paseo app to view this subagent conversation.",
|
|
},
|
|
},
|
|
],
|
|
error: null,
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
} catch (error) {
|
|
this.emitForSource(
|
|
{
|
|
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),
|
|
},
|
|
},
|
|
source,
|
|
);
|
|
}
|
|
}
|
|
|
|
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 prepareAgentMessage(agentId: string, text: string): Promise<void> {
|
|
await ensureAgentLoaded(agentId, {
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
logger: this.sessionLogger,
|
|
});
|
|
const stored = await this.agentStorage.get(agentId);
|
|
if (stored && !stored.title && !stored.lastUserMessageAt) {
|
|
const { provisionalTitle } = resolveCreateAgentTitles({ initialPrompt: text });
|
|
if (provisionalTitle) await this.agentManager.setTitle(agentId, provisionalTitle);
|
|
}
|
|
}
|
|
|
|
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,
|
|
activeTurnBehavior: msg.activeTurnBehavior,
|
|
textPrefix: msg.text.slice(0, 80),
|
|
},
|
|
"agent.session.send_agent_message",
|
|
);
|
|
let queued!: () => void;
|
|
const admitted = new Promise<void>((acceptAdmission) => {
|
|
queued = acceptAdmission;
|
|
});
|
|
let deliveryStatus: "queued" | undefined;
|
|
const send = async () => {
|
|
if (msg.sourceAgentId) {
|
|
const receipt = this.agentMessages.enqueue({
|
|
recipientId: agentId,
|
|
messageId: msg.messageId,
|
|
prompt,
|
|
source: {
|
|
kind: "agent-message",
|
|
agentId: msg.sourceAgentId,
|
|
serverId: msg.sourceAgentServerId,
|
|
title: !msg.sourceAgentServerId
|
|
? (this.agentManager.getAgent(msg.sourceAgentId)?.config.title ?? undefined)
|
|
: undefined,
|
|
},
|
|
activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
|
|
sessionMode: msg.sessionMode,
|
|
clearPendingPermissions: false,
|
|
});
|
|
deliveryStatus = "queued";
|
|
queued();
|
|
await receipt.dispatched;
|
|
return;
|
|
}
|
|
const result = await sendPromptToAgent({
|
|
agentManager: this.agentManager,
|
|
agentStorage: this.agentStorage,
|
|
agentId,
|
|
prompt,
|
|
messageId: msg.messageId,
|
|
activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt",
|
|
sessionMode: msg.sessionMode,
|
|
clearPendingPermissions: true,
|
|
logger: this.sessionLogger,
|
|
});
|
|
if (result.disposition === "turn_started") {
|
|
await waitForAgentRunStartWithTimeout(
|
|
this.agentManager,
|
|
agentId,
|
|
this.delivery.requestSignal,
|
|
);
|
|
}
|
|
};
|
|
const sent = msg.messageId
|
|
? this.messageReceipts.send({
|
|
agentId,
|
|
messageId: msg.messageId,
|
|
request: {
|
|
prompt,
|
|
sourceAgentId: msg.sourceAgentId,
|
|
sourceAgentServerId: msg.sourceAgentServerId,
|
|
sessionMode: msg.sessionMode,
|
|
activeTurnBehavior:
|
|
msg.activeTurnBehavior ?? (msg.sourceAgentId ? "steer" : "interrupt"),
|
|
},
|
|
prepare: async () => {
|
|
if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text);
|
|
},
|
|
send,
|
|
})
|
|
: send();
|
|
void sent.catch((error) => {
|
|
if (deliveryStatus === "queued") {
|
|
this.handleAgentRunError(agentId, error, "Failed to dispatch queued agent message");
|
|
}
|
|
});
|
|
await Promise.race([sent, admitted]);
|
|
|
|
this.emit({
|
|
type: "send_agent_message_response",
|
|
payload: {
|
|
requestId: msg.requestId,
|
|
agentId,
|
|
accepted: true,
|
|
...(deliveryStatus ? { deliveryStatus } : {}),
|
|
error: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
if (this.delivery.requestSignal.aborted) return;
|
|
this.handleAgentRunError(resolved.agentId, error, "Failed to send agent message");
|
|
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 sourceSignal = this.delivery.requestSignal;
|
|
sourceSignal.throwIfAborted();
|
|
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 abortController = new AbortController();
|
|
const hasTimeout = typeof timeoutMs === "number" && timeoutMs > 0;
|
|
const timeoutHandle = hasTimeout
|
|
? setTimeout(() => {
|
|
abortController.abort("timeout");
|
|
}, timeoutMs)
|
|
: null;
|
|
|
|
try {
|
|
await this.agentMessages.waitForDispatch(
|
|
agentId,
|
|
AbortSignal.any([abortController.signal, sourceSignal]),
|
|
);
|
|
|
|
const live = this.agentManager.getAgent(agentId);
|
|
if (!live) {
|
|
const settled = await this.readSettledAgent(agentId);
|
|
if (!settled) {
|
|
this.emit({
|
|
type: "wait_for_finish_response",
|
|
payload: {
|
|
requestId,
|
|
status: "error",
|
|
final: null,
|
|
error: `Agent not found: ${agentId}`,
|
|
lastMessage: null,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
const { record, lastMessage } = settled;
|
|
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 },
|
|
});
|
|
return;
|
|
}
|
|
|
|
let result = await this.agentManager.waitForAgentEvent(agentId, {
|
|
signal: AbortSignal.any([abortController.signal, sourceSignal]),
|
|
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) {
|
|
if (sourceSignal.aborted) return;
|
|
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
|
|
*/
|
|
// COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcasts after 2027-03-08.
|
|
private wantsEvent(event: SessionEventSubscription, source?: object): boolean {
|
|
// COMPAT(workspaceSetupObservation): added in v0.12.0, remove global setup delivery after 2027-04-19 once client floor >= v0.12.0.
|
|
if (
|
|
event === "workspace_setup_progress" &&
|
|
source &&
|
|
this.supportsForSource(CLIENT_CAPS.workspaceSetupObservation, source)
|
|
)
|
|
return false;
|
|
for (const subscription of this.eventSubscriptions.values()) {
|
|
if (
|
|
(!source || subscription.owner.source === source) &&
|
|
subscription.events.has(event) &&
|
|
!(
|
|
event === "workspace_setup_progress" &&
|
|
this.supportsForSource(CLIENT_CAPS.workspaceSetupObservation, subscription.owner.source)
|
|
)
|
|
)
|
|
return true;
|
|
}
|
|
if (!source && this.clientSources.size > 0) {
|
|
return [...this.clientSources.keys()].some((candidate) => this.wantsEvent(event, candidate));
|
|
}
|
|
if (source && this.delivery.isModern(source)) return false;
|
|
if (!source && !this.delivery.hasLegacySources()) return false;
|
|
// COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit event delivery after 2027-03-09.
|
|
const capabilities = source
|
|
? (this.clientSources.get(source)?.capabilities ?? this.clientCapabilities)
|
|
: this.clientCapabilities;
|
|
return legacyWantsEvent(event, capabilities);
|
|
}
|
|
|
|
private emitEventToOwner(
|
|
subscription: { owner: OwnedSubscription; notifications: boolean },
|
|
message: SessionOutboundMessage,
|
|
notified: Set<object>,
|
|
): void {
|
|
if (
|
|
message.type !== "agent_attention_required" &&
|
|
message.type !== "terminal_attention_required"
|
|
) {
|
|
subscription.owner.emit(message);
|
|
return;
|
|
}
|
|
const shouldNotify =
|
|
message.payload.shouldNotify &&
|
|
subscription.notifications &&
|
|
!notified.has(subscription.owner.source);
|
|
subscription.owner.emit(
|
|
message.type === "agent_attention_required"
|
|
? { ...message, payload: { ...message.payload, shouldNotify } }
|
|
: { ...message, payload: { ...message.payload, shouldNotify } },
|
|
);
|
|
if (shouldNotify) notified.add(subscription.owner.source);
|
|
}
|
|
|
|
private emitSubscribedEvent(message: SessionOutboundMessage, onlySource?: object): boolean {
|
|
const event = sessionEventCategory(message);
|
|
if (!event) return false;
|
|
if (!this.authorization.allowsOutbound(message)) return true;
|
|
const delivered = new Set<object>();
|
|
const notified = new Set<object>();
|
|
for (const subscription of this.eventSubscriptions.values()) {
|
|
if (
|
|
!subscription.events.has(event) ||
|
|
(event === "workspace_setup_progress" &&
|
|
this.supportsForSource(
|
|
CLIENT_CAPS.workspaceSetupObservation,
|
|
subscription.owner.source,
|
|
)) ||
|
|
(onlySource && subscription.owner.source !== onlySource)
|
|
)
|
|
continue;
|
|
this.emitEventToOwner(subscription, message, notified);
|
|
delivered.add(subscription.owner.source);
|
|
}
|
|
// COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit event delivery after 2027-03-09.
|
|
if (this.onMessageToSource && this.clientSources.size > 0) {
|
|
for (const source of this.clientSources.keys()) {
|
|
if (
|
|
(!onlySource || source === onlySource) &&
|
|
!delivered.has(source) &&
|
|
!this.delivery.isModern(source) &&
|
|
this.wantsEvent(event, source)
|
|
)
|
|
this.onMessageToSource(source, this.workspaceSetupMessageForClient(message, source));
|
|
}
|
|
} else if (delivered.size === 0 && this.wantsEvent(event)) this.onMessage(message);
|
|
return true;
|
|
}
|
|
|
|
private emit(msg: SessionOutboundMessage): void {
|
|
if (!this.authorization.allowsOutbound(msg)) return;
|
|
if (msg.type === "workspace_setup_progress") {
|
|
const { workspaceId, subscriptionId: _subscriptionId, ...snapshot } = msg.payload;
|
|
this.workspaceSetupObservation.publish(workspaceId, snapshot);
|
|
return;
|
|
}
|
|
if (this.delivery.reply(msg)) return;
|
|
if (this.emitSubscribedEvent(msg)) 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",
|
|
);
|
|
}
|
|
if (msg.type === "workspace_setup_status_response") {
|
|
if (this.clientSources.size > 0 && this.onMessageToSource) {
|
|
for (const [source] of this.clientSources) {
|
|
this.onMessageToSource(source, this.workspaceSetupMessageForClient(msg, source));
|
|
}
|
|
return;
|
|
}
|
|
msg = this.workspaceSetupMessageForClient(msg);
|
|
}
|
|
this.onMessage(msg);
|
|
}
|
|
|
|
// COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
|
|
private workspaceSetupMessageForClient(
|
|
message: SessionOutboundMessage,
|
|
source?: object,
|
|
): SessionOutboundMessage {
|
|
if (
|
|
message.type !== "workspace_setup_progress" &&
|
|
message.type !== "workspace_setup_status_response"
|
|
)
|
|
return message;
|
|
const supportsBlocked = source
|
|
? this.supportsForSource(CLIENT_CAPS.workspaceSetupBlocked, source)
|
|
: this.supports(CLIENT_CAPS.workspaceSetupBlocked);
|
|
const snapshot =
|
|
message.type === "workspace_setup_progress" ? message.payload : message.payload.snapshot;
|
|
if (supportsBlocked || snapshot?.status !== "blocked") return message;
|
|
const legacySnapshot = {
|
|
...snapshot,
|
|
status: "failed" as const,
|
|
error:
|
|
"Workspace setup is blocked pending approval of code from a fork pull request. Update Paseo to review and run setup.",
|
|
};
|
|
return message.type === "workspace_setup_progress"
|
|
? { ...message, payload: { ...message.payload, ...legacySnapshot } }
|
|
: { ...message, payload: { ...message.payload, snapshot: legacySnapshot } };
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|
|
|
|
private async emitBinaryReply(frame: Uint8Array, source?: object): Promise<void> {
|
|
if (source && this.onBinaryMessageToSource) {
|
|
this.delivery.authorizeBinaryReply(frame, source);
|
|
await this.onBinaryMessageToSource(source, frame);
|
|
return;
|
|
}
|
|
this.emitBinary(frame);
|
|
}
|
|
|
|
private observeSourceSend(send: () => void | Promise<void>): Promise<void> {
|
|
let pending: Promise<void>;
|
|
try {
|
|
pending = Promise.resolve(send());
|
|
} catch (error) {
|
|
pending = Promise.reject(error);
|
|
}
|
|
// Legacy producers fire and forget; owned streams await the same failure.
|
|
void pending.catch((error) =>
|
|
this.sessionLogger.warn({ err: error }, "Failed to emit source frame"),
|
|
);
|
|
return pending;
|
|
}
|
|
|
|
private emitForSource(msg: SessionOutboundMessage, source?: object): void {
|
|
if (source && this.onMessageToSource) {
|
|
msg = this.delivery.authorizeReply(msg, source);
|
|
this.onMessageToSource(source, msg);
|
|
return;
|
|
}
|
|
this.emit(msg);
|
|
}
|
|
|
|
/**
|
|
* Clean up session resources
|
|
*/
|
|
public async cleanup(): Promise<void> {
|
|
this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup");
|
|
this.isCleanedUp = true;
|
|
this.unsubscribeSetupObservation();
|
|
await this.delivery.close();
|
|
|
|
if (this.unsubscribeAgentEvents) {
|
|
this.unsubscribeAgentEvents();
|
|
this.unsubscribeAgentEvents = null;
|
|
}
|
|
this.unsubscribeProjectMutations?.();
|
|
this.unsubscribeProjectMutations = null;
|
|
this.unsubscribePluginChanges?.();
|
|
this.unsubscribePluginChanges = null;
|
|
this.unsubscribeWorkspaceMutations?.();
|
|
this.unsubscribeWorkspaceMutations = null;
|
|
this.agentUpdates.dispose();
|
|
await this.hubExecutionController?.cleanup();
|
|
if (this.unsubscribeTerminalWorkspaceContributionEvents) {
|
|
this.unsubscribeTerminalWorkspaceContributionEvents();
|
|
this.unsubscribeTerminalWorkspaceContributionEvents = null;
|
|
}
|
|
this.providerCatalogSession.dispose();
|
|
|
|
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);
|
|
}
|
|
|
|
function sessionEventCategory(message: SessionOutboundMessage): SessionEventSubscription | null {
|
|
switch (message.type) {
|
|
case "project.update":
|
|
case "providers_snapshot_update":
|
|
case "agent_attention_required":
|
|
case "agent_permission_request":
|
|
case "agent_permission_resolved":
|
|
case "checkout_status_update":
|
|
case "script_status_update":
|
|
case "workspace_setup_progress":
|
|
case "agent.provider_subagents.update":
|
|
case "terminal_attention_required":
|
|
case "activity_log":
|
|
case "hub.execution.agent.update":
|
|
case "hub.execution.agent.stream":
|
|
return message.type;
|
|
case "status":
|
|
switch (message.payload.status) {
|
|
case "server_info":
|
|
return "status.server_info";
|
|
case "daemon_config_changed":
|
|
return "status.daemon_config_changed";
|
|
case "plugin_catalog_changed":
|
|
return "status.plugin_catalog_changed";
|
|
case "plugin_settings_changed":
|
|
return "status.plugin_settings_changed";
|
|
default:
|
|
return null;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// COMPAT(ownedSubscriptions): added in v0.8.0, remove implicit event delivery after 2027-03-09.
|
|
function legacyWantsEvent(
|
|
event: SessionEventSubscription,
|
|
capabilities: ReadonlySet<ClientCapability>,
|
|
): boolean {
|
|
if (event === "project.update" && !capabilities.has(CLIENT_CAPS.projectUpdates)) return false;
|
|
switch (event) {
|
|
case "project.update":
|
|
case "providers_snapshot_update":
|
|
case "agent_attention_required":
|
|
case "agent_permission_request":
|
|
case "agent_permission_resolved":
|
|
return !capabilities.has(CLIENT_CAPS.explicitEventSubscriptions);
|
|
case "agent.provider_subagents.update":
|
|
return capabilities.has(CLIENT_CAPS.providerSubagents);
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function createHostSession(options: SessionOptions, session: Session): ConnectedHostSession | null {
|
|
if (!options.connectedHosts) return null;
|
|
return new ConnectedHostSession(
|
|
options.connectedHosts,
|
|
session.delivery,
|
|
{ sourceId: options.sourceId ?? "owner", manageSources: false },
|
|
() => session.getPermissions(),
|
|
(message) => session.publish(message),
|
|
() => session.isRouted(),
|
|
session.getSessionId(),
|
|
() => options.getConnectedHostBufferedAmount?.() ?? 0,
|
|
);
|
|
}
|
|
|
|
function hostSource(options: SessionOptions): string {
|
|
return options.sourceId ?? "owner";
|
|
}
|