mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
* fix(server): forward a created agent's current state, not its pre-prompt copy create_agent_request forwarded the snapshot createAgent returned when registration finished, after the initial prompt had already published the agent as running. Subscribers received that idle copy last, with an older updatedAt, and providers that report nothing until the turn ends left the agent listed as idle for the whole turn. Forward the agent manager's current state instead. Closes #8022 * test(server): drain agent persistence before fixture cleanup --------- Co-authored-by: luke <luopeihuan@gmail.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
8882 lines
314 KiB
TypeScript
8882 lines
314 KiB
TypeScript
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
|
import type { AgentMessageDelivery } from "./agent/agent-messages/index.js";
|
|
import { AgentDirectory } 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 { 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 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 { resolveCreateAgentIntent, type CreateAgentIntent } 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 {
|
|
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;
|
|
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>;
|
|
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.emitBinaryForFileTransfer(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(),
|
|
listDraftFeatures: (config) => this.agentManager.listDraftFeatures(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),
|
|
setFeature: (agentId, featureId, value) =>
|
|
agentManager.setAgentFeature(agentId, featureId, value),
|
|
setThinking: (agentId, thinkingOptionId) =>
|
|
agentManager.setAgentThinkingOption(agentId, thinkingOptionId),
|
|
},
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.projectConfigSession = new ProjectConfigSession({
|
|
host: {
|
|
emit: (msg) => this.emit(msg),
|
|
},
|
|
projectRegistry: this.projectRegistry,
|
|
logger: this.sessionLogger,
|
|
});
|
|
this.daemonSession = new DaemonSession({
|
|
host: {
|
|
emit: (msg) => this.emit(msg),
|
|
emitLifecycleIntent: (intent) => this.emitLifecycleIntent(intent),
|
|
},
|
|
clientId: this.clientId,
|
|
paseoHome: this.paseoHome,
|
|
serverId,
|
|
daemonVersion,
|
|
daemonRuntimeConfig,
|
|
getWebSocketRuntimeMetrics,
|
|
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(),
|
|
listWorkspaces: () => this.workspaceRegistry.list(),
|
|
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, includeBackground) =>
|
|
this.agentDirectory.includes(agent, includeBackground),
|
|
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: () => this.listAgentPayloads({ includeBackground: true }),
|
|
workspaceLastUserMessageTimes: () => this.agentDirectory.workspaceLastUserMessageTimes(),
|
|
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.list())
|
|
.filter((workspace) => workspace.projectId === mutation.projectId)
|
|
.map((workspace) => workspace.workspaceId);
|
|
|
|
if (mutation.kind === "remove") {
|
|
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 {
|
|
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"),
|
|
);
|
|
}
|
|
}
|
|
|
|
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.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;
|
|
}
|
|
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.emit({
|
|
type: "plugin.rpc.invoke.response",
|
|
payload: { requestId: msg.requestId, output },
|
|
});
|
|
return undefined;
|
|
});
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
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: 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,
|
|
},
|
|
});
|
|
});
|
|
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 "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 "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 (!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.list();
|
|
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.list())
|
|
.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.list()).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.",
|
|
);
|
|
if (
|
|
request.background !== undefined &&
|
|
(request.workspaceId || (request.callerAgentId && !request.worktree))
|
|
) {
|
|
throw new Error(
|
|
"background is workspace creation intent; configure the selected workspace at creation.",
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
background: msg.background,
|
|
callerWorkspaceId: this.getCallerWorkspaceId(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,
|
|
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 = request.callerAgentId
|
|
? this.agentManager.getAgent(request.callerAgentId)
|
|
: null;
|
|
if (request.callerAgentId && !callerAgent) {
|
|
throw new Error(`Caller agent ${request.callerAgentId} not found`);
|
|
}
|
|
|
|
let config = request.config;
|
|
|
|
const intent = await resolveCreateAgentIntent({
|
|
explicitWorkspaceId: createdWorktree?.workspace.workspaceId ?? request.workspaceId,
|
|
caller: callerAgent
|
|
? { id: callerAgent.id, cwd: callerAgent.cwd, workspaceId: callerAgent.workspaceId }
|
|
: null,
|
|
labels: request.labels,
|
|
resolveWorkspace: async (workspaceId) => {
|
|
if (createdWorktree?.workspace.workspaceId === workspaceId) {
|
|
return { workspaceId, cwd: createdWorktree.workspace.cwd };
|
|
}
|
|
const workspace = await this.workspaceRegistry.get(workspaceId);
|
|
if (!workspace || workspace.archivedAt) {
|
|
throw new Error(`Workspace ${workspaceId} not found`);
|
|
}
|
|
return { workspaceId, cwd: workspace.cwd };
|
|
},
|
|
createWorkspace: async () => ({
|
|
workspaceId: await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent({
|
|
createdWorktree: null,
|
|
cwd: config.cwd,
|
|
initialTitle: input.workspacePromptTitle,
|
|
background: request.background,
|
|
callerWorkspaceId: callerAgent?.workspaceId,
|
|
}),
|
|
cwd: config.cwd,
|
|
}),
|
|
});
|
|
config = { ...config, cwd: intent.cwd };
|
|
|
|
return {
|
|
config,
|
|
intent,
|
|
createdDirectoryWorkspace: !createdWorktree && !request.workspaceId && !callerAgent,
|
|
};
|
|
}
|
|
|
|
private async handleResumeAgentRequest(
|
|
msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>,
|
|
): Promise<void> {
|
|
const { handle, overrides, requestId } = msg;
|
|
if (!handle) {
|
|
this.sessionLogger.warn("Resume request missing persistence handle");
|
|
if (requestId) {
|
|
this.emit({
|
|
type: "rpc_error",
|
|
payload: {
|
|
requestId,
|
|
requestType: msg.type,
|
|
error: "Unable to resume agent: missing persistence handle",
|
|
code: "agent_resume_failed",
|
|
},
|
|
});
|
|
}
|
|
this.emit({
|
|
type: "activity_log",
|
|
payload: {
|
|
id: uuidv4(),
|
|
timestamp: new Date(),
|
|
type: "error",
|
|
content: "Unable to resume agent: missing persistence handle",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
this.sessionLogger.info(
|
|
{ sessionId: handle.sessionId, provider: handle.provider },
|
|
`Resuming agent ${handle.sessionId} (${handle.provider})`,
|
|
);
|
|
try {
|
|
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 };
|
|
}
|
|
|
|
const stored = await this.agentStorage.list();
|
|
const storedRecords = stored.filter((record) => !record.internal);
|
|
const knownIds = new Set<string>();
|
|
for (const record of storedRecords) {
|
|
knownIds.add(record.id);
|
|
}
|
|
for (const agent of this.agentManager.listAgents()) {
|
|
knownIds.add(agent.id);
|
|
}
|
|
|
|
if (knownIds.has(trimmed)) {
|
|
return { ok: true, agentId: trimmed };
|
|
}
|
|
|
|
const prefixMatches = Array.from(knownIds).filter((id) => id.startsWith(trimmed));
|
|
if (prefixMatches.length === 1) {
|
|
return { ok: true, agentId: prefixMatches[0] };
|
|
}
|
|
if (prefixMatches.length > 1) {
|
|
return {
|
|
ok: false,
|
|
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.list(),
|
|
this.projectRegistry.list(),
|
|
]);
|
|
const activeProjects = new Map(
|
|
persistedProjects
|
|
.filter((project) => !project.archivedAt)
|
|
.map((project) => [project.projectId, project] as const),
|
|
);
|
|
const placementsByWorkspaceId = new Map<string, ProjectPlacementPayload>();
|
|
|
|
const pairs = persistedWorkspaces.flatMap((workspace) => {
|
|
if (workspace.archivedAt) return [];
|
|
const project = activeProjects.get(workspace.projectId);
|
|
if (!project) return [];
|
|
return [{ workspace, project }];
|
|
});
|
|
const placements = await Promise.all(
|
|
pairs.map(({ workspace, project }) =>
|
|
this.buildProjectPlacementForWorkspace(workspace, project),
|
|
),
|
|
);
|
|
for (let i = 0; i < pairs.length; i += 1) {
|
|
placementsByWorkspaceId.set(pairs[i].workspace.workspaceId, placements[i]);
|
|
}
|
|
|
|
return placementsByWorkspaceId;
|
|
}
|
|
|
|
private async collectFetchAgentsEntries(params: {
|
|
candidates: AgentSnapshotPayload[];
|
|
limit: number;
|
|
getPlacement: (workspaceId: string | undefined) => Promise<ProjectPlacementPayload | null>;
|
|
filter: AgentUpdatesFilter | undefined;
|
|
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;
|
|
}
|
|
|
|
private async listFetchAgentsEntries(request: AgentDirectoryRequestMessage): 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:
|
|
filter?.includeBackground ||
|
|
(request.type === "fetch_agents_request" && request.includeBackground),
|
|
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> {
|
|
const workspaces = await this.workspaceRegistry.list();
|
|
return resolveWorkspaceIdForPath(cwd, workspaces);
|
|
}
|
|
|
|
private matchesWorkspaceFilter(input: {
|
|
workspace: WorkspaceDescriptorPayload;
|
|
filter: FetchWorkspacesRequestFilter | undefined;
|
|
}): boolean {
|
|
return this.workspaceDirectory.matchesFilter(input);
|
|
}
|
|
|
|
private async listFetchWorkspacesEntries(
|
|
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
|
|
): Promise<{
|
|
entries: FetchWorkspacesResponseEntry[];
|
|
emptyProjects: WorkspaceProjectDescriptorPayload[];
|
|
pageInfo: FetchWorkspacesResponsePageInfo;
|
|
}> {
|
|
try {
|
|
return await this.workspaceDirectory.listFetchEntries({
|
|
...request,
|
|
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.list();
|
|
return workspaces
|
|
.filter((workspace) => !workspace.archivedAt)
|
|
.map((workspace) => ({
|
|
workspaceId: workspace.workspaceId,
|
|
cwd: workspace.cwd,
|
|
kind: workspace.kind,
|
|
worktreeRoot: workspace.worktreeRoot,
|
|
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
|
|
mainRepoRoot: workspace.mainRepoRoot,
|
|
}));
|
|
}
|
|
|
|
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
|
const archiveTimestamp = archivedAt ?? new Date().toISOString();
|
|
const existingWorkspace = await archivePersistedWorkspaceRecord({
|
|
workspaceId,
|
|
archivedAt: archiveTimestamp,
|
|
workspaceRegistry: this.workspaceRegistry,
|
|
});
|
|
if (!existingWorkspace) {
|
|
this.workspaceGitObserver.removeForWorkspaceId(workspaceId);
|
|
return;
|
|
}
|
|
|
|
if (!existingWorkspace.archivedAt) {
|
|
const activeSiblings = (await this.workspaceRegistry.list()).filter(
|
|
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
|
|
);
|
|
this.sessionLogger.info(
|
|
{
|
|
workspaceId,
|
|
workspaceCwd: existingWorkspace.cwd,
|
|
projectId: existingWorkspace.projectId,
|
|
projectArchived: activeSiblings.length === 0,
|
|
archivedAt: archiveTimestamp,
|
|
},
|
|
"Workspace archived",
|
|
);
|
|
}
|
|
|
|
await this.teardownArchivedWorkspace(existingWorkspace.workspaceId);
|
|
}
|
|
|
|
private async teardownArchivedWorkspace(workspaceId: string): Promise<void> {
|
|
this.workspaceGitObserver.removeForWorkspaceId(workspaceId);
|
|
this.scriptRuntimeStore?.removeForWorkspace(workspaceId);
|
|
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.list(), cwd);
|
|
if (workspaceIds.length === 0) {
|
|
return;
|
|
}
|
|
await this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds, options);
|
|
}
|
|
|
|
private async handleFetchAgents(
|
|
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
|
|
): Promise<void> {
|
|
const owner = request.subscribe
|
|
? this.delivery.begin("agents", request.subscribe.subscriptionId, (id) => {
|
|
this.agentUpdates.clearSubscription(id);
|
|
this.refreshObservationProducers();
|
|
})
|
|
: null;
|
|
const subscriptionId = owner?.responseId;
|
|
try {
|
|
if (owner) {
|
|
this.agentUpdates.beginSubscription({
|
|
subscriptionId: owner.id,
|
|
isProviderVisible: (provider) =>
|
|
this.delivery.forSource(owner.source, () => this.isProviderVisibleToClient(provider)),
|
|
filter: {
|
|
...request.filter,
|
|
...(request.includeBackground ? { includeBackground: true } : {}),
|
|
},
|
|
syncEnabled: Boolean(request.sync),
|
|
emit: (message) => {
|
|
if (message.type === "agent_update") owner.emit(message);
|
|
},
|
|
});
|
|
this.refreshObservationProducers();
|
|
}
|
|
|
|
const payload = request.sync
|
|
? await this.readAgentDirectorySync(request)
|
|
: await this.listFetchAgentsEntries(request);
|
|
const snapshotUpdatedAtByAgentId = new Map<string, number>();
|
|
for (const entry of payload.entries) {
|
|
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt);
|
|
if (!Number.isNaN(parsedUpdatedAt)) {
|
|
snapshotUpdatedAtByAgentId.set(entry.agent.id, parsedUpdatedAt);
|
|
}
|
|
}
|
|
|
|
this.emit({
|
|
type: "fetch_agents_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
...(subscriptionId ? { subscriptionId } : {}),
|
|
...payload,
|
|
},
|
|
});
|
|
|
|
if (subscriptionId) {
|
|
this.agentUpdates.flushBootstrapped(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.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 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" }>,
|
|
) {
|
|
if (request.scope !== "active" || request.filter) {
|
|
throw new SessionRequestError(
|
|
"invalid_request",
|
|
"Sequenced agent directory reads require scope=active and no filter.",
|
|
);
|
|
}
|
|
const snapshot = await this.listFetchAgentsEntries({
|
|
...request,
|
|
includeBackground: true,
|
|
page: { limit: Number.MAX_SAFE_INTEGER },
|
|
});
|
|
const result = this.directorySync.synchronizeAgents(
|
|
snapshot.entries,
|
|
request.sync ?? {},
|
|
request.includeBackground,
|
|
);
|
|
const entries = await Promise.all(
|
|
result.entries.map(async (entry) =>
|
|
(await this.agentDirectory.includes(entry.agent, request.includeBackground)) ? 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 getCallerWorkspaceId(callerAgentId?: string): string | undefined {
|
|
if (!callerAgentId) return undefined;
|
|
const caller = this.agentManager.getAgent(callerAgentId);
|
|
if (!caller) throw new Error(`Caller agent ${callerAgentId} not found`);
|
|
return 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,
|
|
background: request.background,
|
|
callerWorkspaceId: this.getCallerWorkspaceId(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,
|
|
background: request.background,
|
|
callerWorkspaceId: this.getCallerWorkspaceId(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.list()) {
|
|
workspacesBefore.set(workspaceRecord.workspaceId, workspaceRecord);
|
|
}
|
|
const workspace = await this.workspaceProvisioning.findOrCreateWorkspaceForDirectory(cwd);
|
|
const project = await this.projectRegistry.get(workspace.projectId);
|
|
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
|
const descriptor = await this.describeWorkspaceRecord(workspace);
|
|
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
|
this.sessionLogger.info(
|
|
{
|
|
requestedCwd,
|
|
resolvedCwd: cwd,
|
|
workspaceCwd: workspace.cwd,
|
|
workspaceId: workspace.workspaceId,
|
|
workspaceKind: workspace.kind,
|
|
workspaceTransition: describeRegistryTransition(
|
|
workspacesBefore.get(workspace.workspaceId) ?? null,
|
|
),
|
|
projectId: workspace.projectId,
|
|
projectKind: project?.kind ?? null,
|
|
projectTransition: describeRegistryTransition(
|
|
projectsBefore.get(workspace.projectId) ?? null,
|
|
),
|
|
},
|
|
"Project opened",
|
|
);
|
|
this.emit({
|
|
type: "open_project_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: descriptor,
|
|
error: null,
|
|
},
|
|
});
|
|
void this.workspaceGitService
|
|
.getSnapshot(workspace.cwd, {
|
|
force: true,
|
|
includeForge: true,
|
|
reason: "open_project",
|
|
})
|
|
.catch((error) => {
|
|
this.sessionLogger.warn(
|
|
{ err: error, cwd: workspace.cwd },
|
|
"Background snapshot refresh failed after open_project",
|
|
);
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Failed to open project";
|
|
this.sessionLogger.error({ err: error, cwd }, "Failed to open project");
|
|
this.emit({
|
|
type: "open_project_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
error: message,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleProjectAddRequest(
|
|
request: Extract<SessionInboundMessage, { type: "project.add.request" }>,
|
|
): Promise<void> {
|
|
const requestedCwd = request.cwd;
|
|
const cwd = expandTilde(requestedCwd);
|
|
const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false);
|
|
if (!directoryExists) {
|
|
this.sessionLogger.info(
|
|
{ requestedCwd, resolvedCwd: cwd, reason: "directory_not_found" },
|
|
"Add project rejected",
|
|
);
|
|
this.emit({
|
|
type: "project.add.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
project: null,
|
|
error: `Directory not found: ${cwd}`,
|
|
errorCode: "directory_not_found",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const projectsBefore = new Map<string, PersistedProjectRecord>();
|
|
for (const project of await this.projectRegistry.list()) {
|
|
projectsBefore.set(project.projectId, project);
|
|
}
|
|
const project = await this.workspaceProvisioning.findOrCreateProjectForDirectory(cwd);
|
|
this.sessionLogger.info(
|
|
{
|
|
requestedCwd,
|
|
resolvedCwd: cwd,
|
|
projectId: project.projectId,
|
|
projectKind: project.kind,
|
|
projectTransition: describeRegistryTransition(
|
|
projectsBefore.get(project.projectId) ?? null,
|
|
),
|
|
},
|
|
"Project added",
|
|
);
|
|
this.emit({
|
|
type: "project.add.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
project: 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({ 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({ includeBackground: 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.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,
|
|
});
|
|
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: true,
|
|
});
|
|
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 emitBinaryForFileTransfer(frame: Uint8Array, source?: object): Promise<void> {
|
|
if (source && this.onBinaryMessageToSource) {
|
|
this.delivery.authorizeFileReply(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";
|
|
}
|