From 26b2f25050fa4aa2ec21cc36a332ce849a744f9a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 22 Jun 2026 10:44:07 +0800 Subject: [PATCH] refactor(server): decompose session.ts into per-domain subsystems (#1646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(server): move checkout write handlers into CheckoutSession The checkout read side moved into session/checkout/checkout-session.ts in #1644; the 17 inline write/PR/stash handlers stayed on the Session god-object. This carves them into the same subsystem behind an expanded CheckoutSessionHost, so dispatchCheckoutMessage becomes pure delegation and session.ts drops ~800 lines. Moved into CheckoutSession (verbatim, only this.X -> this.host.X rewiring): switch/rename branch, commit, merge, merge-from-base, pull, push, PR create/merge, github auto-merge/check-details, PR status/timeline, github search, stash save/pop/list, plus resolveCurrentPullRequest and the PR-timeline helpers. CheckoutSessionHost gains the Session-owned, non-checkout collaborators these handlers orchestrate (notifyGitMutation, emitWorkspaceUpdateForCwd, handleWorkspaceGitBranchSnapshot, renameCurrentBranch, checkoutExistingBranch, and the LLM commit/PR-text generators); paseoHome/worktreesRoot join the options bag. GitMutationRefreshReason moves to utils/checkout-git.ts so both the shell and the subsystem share one canonical type. session.test.ts checkout tests now drive the handlers through the public handleMessage boundary instead of reaching into private methods; new mock-free fake-host tests cover the moved handlers directly in checkout-session.test.ts. Behavior-preserving: full session.test.ts + checkout-session.test.ts green, typecheck/lint/format clean. * refactor(server): extract chat/schedule/loop handlers into ChatScheduleLoopSession Move the 7 chat/*, 9 schedule/*, and 5 loop/* request handlers, the three rpc-error emitters (kept separate), and toScheduleSummary out of the Session god-object into session/chat/chat-schedule-loop-session.ts, mirroring the shipped CheckoutSession/VoiceSession deep-module-with-narrow-Host pattern. The least-coupled remaining domain: stateless request/response over chatService/scheduleService/ loopService, reaching the shell only through a narrow ChatScheduleLoopSessionHost (emit + agent-roster reads + the mention-fanout send). session.ts drops ~590 lines. The two dispatch switches collapse into one: schedule/* was previously reached only via the chat dispatcher's default fall-through arm — now all 21 types are explicit delegation cases, removing that fragility. Behavior-preserving: a new routing test drives the real Session.handleMessage for all 21 types (guards against a silently-dropped case), green before and after the move; a collocated subsystem test covers the mention-fanout send seam, the fanout-limit error code, the self->agent target remap, and the toScheduleSummary runs-stripping. * refactor(server): extract provider catalog handlers into ProviderCatalogSession Move the 8 provider-catalog handlers (model/mode/feature listing, providers snapshot pull/refresh, diagnostic, usage) plus emitProviderDisabledResponse, getProviderSnapshotEntryForRead, buildDraftAgentSessionConfig, the two mode-icon downgrade helpers, and the providers_snapshot_update PUSH wiring out of session.ts into session/provider/provider-catalog-session.ts behind a narrow ProviderCatalogSessionHost seam — the same deep-module pattern as ChatScheduleLoopSession, CheckoutSession, and VoiceSession. The PUSH (start) and every PULL gate provider visibility and downgrade mode icons through the SAME injected predicates (isProviderVisibleToClient + supportsCustomModeIcons), both reading appVersion/clientCapabilities live — the COMPAT invariant the shell could only enforce by code proximity before. dispatchProviderMessage collapses to delegation; start()/dispose() wire into subscribeToOptionalManagers/cleanup at the same ordinal. session.ts drops ~372 lines. Behavior-preservation gate (provider routing through the real Session.handleMessage) green before and after; full session.test.ts 115/115; new collocated subsystem test covers the PUSH/PULL parity seam, the disabled-provider path, and the usage/feature error envelopes. * refactor(server): extract workspace file-access handlers into WorkspaceFilesSession Move the file-explorer, file-upload, file-transfer-frame, project-icon and file-download-token handlers out of session.ts into session/files/workspace-files-session.ts behind a narrow WorkspaceFilesSessionHost seam (emit/emitBinary/hasBinaryChannel), matching the deep-module-with-Host shape used by CheckoutSession/ChatScheduleLoopSession/ProviderCatalogSession. The new module owns the FileUploadStore and is injected the daemon's DownloadTokenStore; the shell's dispatch cases and the file_transfer binary route collapse to delegation. The five handlers touch no workspace-git observer, registry or subscription state, so this is a clean self-contained slice of the workspace domain. session.ts drops 240 lines (7316 -> 7076). Behavior-preservation tests (file explorer list/read/binary, download token success + empty-cwd, project icon, upload round-trip) added to session.test.ts and run green against the pre- and post-refactor Session; a collocated workspace-files-session.test.ts exercises the module via a fake host with real upload/download stores (no module mocks). * refactor(server): extract agent-config setters into AgentConfigSession Move the four agent-config setters (set_agent_mode/model/feature/thinking) out of the session god-object into session/agent-config/, collapsing their four near-identical try/log/emit envelopes into one applyConfigChange helper behind a narrow { emit } host plus an AgentConfigOperations port. Behavior-preserving: the dispatch cases delegate unchanged, setAgentModeCommand still routes the mode path, and model/feature still emit no notice. Adds the previously-absent failure-envelope coverage: 8 gate tests in session.test.ts (success + forced failure per setter, asserting the *_response envelope and the activity_log-before-response ordering) green before and after the carve, plus a collocated agent-config-session.test.ts (typed fake operations + fake host, no module mocks). session.ts drops ~173 lines. * refactor(server): extract project-config handlers into ProjectConfigSession Move read/write_project_config handlers, their failure emitters, and the root-resolution helpers (resolveKnownProjectRootForConfig, canonicalizeConfigRoot, stripTrailingPathSeparators) out of session.ts into session/project-config/, behind a narrow { emit } host plus a projectRegistry port — matching the established session// pattern. Also corrects a misfiling: project-config was dispatched inside dispatchAgentConfigMessage despite being its own domain. session.ts drops 155 lines (6903 -> 6748). The handleMessage-driven gate in session.test.ts (project config RPC authorization) is green before and after; new collocated project-config-session.test.ts covers the subsystem with a fake host and fake registry over a real filesystem (no module mocks). * refactor(server): extract daemon status/pairing handlers into DaemonSession Move the two daemon.* RPC handlers (daemon.get_status, daemon.get_pairing_offer) out of session.ts into session/daemon/daemon-session.ts behind a narrow { emit } host + injected reads (paseoHome, serverId, daemonVersion, daemonRuntimeConfig, listProviderAvailability). These were misfiled inside dispatchAgentConfigMessage despite being daemon- not agent-scoped; the slice also removes the now-dead serverId/daemonVersion/daemonRuntimeConfig fields and the getPidLockInfo/generateLocalPairingOffer imports from the god-file, and collapses the inline daemonRuntimeConfig type into a named DaemonRuntimeConfig. The daemon-config get/set handlers stay inline: daemonConfigStore is a listener-bearing, cross-domain hub (read by structured-generation, websocket-server, bootstrap, archive-if-safe), so moving it would double-own it. session.ts: 6748 -> 6662 lines. Gate: new session.test.ts daemon round-trips (status success + listing-rejects fallback, pairing relay-disabled) pass before and after; new collocated daemon-session.test.ts (fake host + injected reads over a real temp dir, no module mocks) 4/4. typecheck/lint/format clean. --- packages/server/src/server/session.test.ts | 745 ++++- packages/server/src/server/session.ts | 2715 ++--------------- .../agent-config/agent-config-session.test.ts | 260 ++ .../agent-config/agent-config-session.ts | 163 + .../chat/chat-schedule-loop-session.test.ts | 228 ++ .../chat/chat-schedule-loop-session.ts | 609 ++++ .../session/checkout/checkout-session.test.ts | 383 ++- .../session/checkout/checkout-session.ts | 846 ++++- .../session/daemon/daemon-session.test.ts | 171 ++ .../server/session/daemon/daemon-session.ts | 139 + .../files/workspace-files-session.test.ts | 272 ++ .../session/files/workspace-files-session.ts | 286 ++ .../project-config-session.test.ts | 230 ++ .../project-config/project-config-session.ts | 184 ++ .../provider/provider-catalog-session.test.ts | 184 ++ .../provider/provider-catalog-session.ts | 454 +++ packages/server/src/utils/checkout-git.ts | 22 + 17 files changed, 5300 insertions(+), 2591 deletions(-) create mode 100644 packages/server/src/server/session/agent-config/agent-config-session.test.ts create mode 100644 packages/server/src/server/session/agent-config/agent-config-session.ts create mode 100644 packages/server/src/server/session/chat/chat-schedule-loop-session.test.ts create mode 100644 packages/server/src/server/session/chat/chat-schedule-loop-session.ts create mode 100644 packages/server/src/server/session/daemon/daemon-session.test.ts create mode 100644 packages/server/src/server/session/daemon/daemon-session.ts create mode 100644 packages/server/src/server/session/files/workspace-files-session.test.ts create mode 100644 packages/server/src/server/session/files/workspace-files-session.ts create mode 100644 packages/server/src/server/session/project-config/project-config-session.test.ts create mode 100644 packages/server/src/server/session/project-config/project-config-session.ts create mode 100644 packages/server/src/server/session/provider/provider-catalog-session.test.ts create mode 100644 packages/server/src/server/session/provider/provider-catalog-session.ts diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 276a2d396..e3de3e24f 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -9,13 +9,17 @@ import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { decodeFileTransferFrame, + encodeFileTransferFrame, FileTransferOpcode, + type FileTransferFrame, } from "@getpaseo/protocol/binary-frames/index"; import { Session } from "./session.js"; +import { DownloadTokenStore } from "./file-download/token-store.js"; import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { SessionOptions } from "./session.js"; +import type { SessionInboundMessage, SessionOutboundMessage } from "./messages.js"; import { asSessionInternals as asSessionInternalsHelper, asAgentManager, @@ -212,6 +216,10 @@ interface SessionForTestOptions { stt?: SessionOptions["stt"]; voice?: SessionOptions["voice"]; paseoHome?: string; + serverId?: SessionOptions["serverId"]; + daemonVersion?: SessionOptions["daemonVersion"]; + daemonRuntimeConfig?: SessionOptions["daemonRuntimeConfig"]; + downloadTokenStore?: SessionOptions["downloadTokenStore"]; messages?: unknown[]; binaryMessages?: Uint8Array[]; } @@ -246,7 +254,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { onMessage: (message) => messages.push(message), onBinaryMessage: createBinaryMessageHandler(options.binaryMessages), logger, - downloadTokenStore: asDownloadTokenStore(), + downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(), pushTokenStore: asPushTokenStore(), paseoHome: options.paseoHome ?? "/tmp/paseo-home", agentManager: asAgentManager({ @@ -294,6 +302,9 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { getDaemonTcpPort: options.getDaemonTcpPort, getDaemonTcpHost: options.getDaemonTcpHost, voice: options.voice, + serverId: options.serverId, + daemonVersion: options.daemonVersion, + daemonRuntimeConfig: options.daemonRuntimeConfig, }); } @@ -394,6 +405,200 @@ describe("file explorer binary responses", () => { }); }); +describe("workspace file access (behavior preservation)", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function makeDir(prefix: string): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + tempDirs.push(dir); + return dir; + } + + function uploadFrame(args: Parameters[0]): FileTransferFrame { + const frame = decodeFileTransferFrame(encodeFileTransferFrame(args)); + if (!frame) { + throw new Error("Expected a file transfer frame"); + } + return frame; + } + + test("file_explorer list returns directory entries", async () => { + const cwd = makeDir("file-access-list-"); + writeFileSync(join(cwd, "a.txt"), "alpha"); + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages }); + + await session.handleMessage({ + type: "file_explorer_request", + cwd, + path: ".", + mode: "list", + requestId: "req-list", + }); + + expect(messages).toHaveLength(1); + const message = messages[0]; + if (message.type !== "file_explorer_response") { + throw new Error(`expected file_explorer_response, got ${message.type}`); + } + expect(message.payload.error).toBeNull(); + expect(message.payload.directory).not.toBeNull(); + }); + + test("file_explorer rejects an empty cwd with an error envelope", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages }); + + await session.handleMessage({ + type: "file_explorer_request", + cwd: " ", + path: ".", + mode: "list", + requestId: "req-empty", + }); + + expect(messages).toEqual([ + { + type: "file_explorer_response", + payload: expect.objectContaining({ + error: "cwd is required", + directory: null, + file: null, + requestId: "req-empty", + }), + }, + ]); + }); + + test("file_download_token issues a token for a real file", async () => { + const cwd = makeDir("file-access-token-"); + writeFileSync(join(cwd, "report.txt"), "hello world"); + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + downloadTokenStore: new DownloadTokenStore({ ttlMs: 60_000 }), + }); + + await session.handleMessage({ + type: "file_download_token_request", + cwd, + path: "report.txt", + requestId: "req-token", + }); + + expect(messages).toHaveLength(1); + const message = messages[0]; + if (message.type !== "file_download_token_response") { + throw new Error(`expected file_download_token_response, got ${message.type}`); + } + expect(message.payload.error).toBeNull(); + expect(typeof message.payload.token).toBe("string"); + expect(message.payload.fileName).toBe("report.txt"); + expect(message.payload.size).toBe(11); + }); + + test("file_download_token rejects an empty cwd with an error envelope", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages }); + + await session.handleMessage({ + type: "file_download_token_request", + cwd: "", + path: "report.txt", + requestId: "req-token-empty", + }); + + expect(messages).toEqual([ + { + type: "file_download_token_response", + payload: expect.objectContaining({ + token: null, + error: "cwd is required", + requestId: "req-token-empty", + }), + }, + ]); + }); + + test("project_icon responds for a workspace cwd", async () => { + const cwd = makeDir("file-access-icon-"); + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages }); + + await session.handleMessage({ + type: "project_icon_request", + cwd, + requestId: "req-icon", + }); + + expect(messages).toHaveLength(1); + const message = messages[0]; + if (message.type !== "project_icon_response") { + throw new Error(`expected project_icon_response, got ${message.type}`); + } + expect(message.payload.cwd).toBe(cwd); + expect(message.payload.error).toBeNull(); + }); + + test("file upload round-trips bytes through binary frames", async () => { + const paseoHome = makeDir("file-access-upload-"); + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages, paseoHome }); + + await session.handleMessage({ + type: "file.upload.request", + fileName: "notes.txt", + mimeType: "text/plain", + size: 11, + modifiedAt: "2026-05-02T00:00:00.000Z", + requestId: "req-upload", + }); + await session.handleBinaryFrame({ + kind: "file_transfer", + frame: uploadFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId: "req-upload", + metadata: { + mime: "text/plain", + size: 11, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + fileName: "notes.txt", + }, + }), + }); + await session.handleBinaryFrame({ + kind: "file_transfer", + frame: uploadFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId: "req-upload", + payload: new TextEncoder().encode("hello world"), + }), + }); + await session.handleBinaryFrame({ + kind: "file_transfer", + frame: uploadFrame({ + opcode: FileTransferOpcode.FileEnd, + requestId: "req-upload", + }), + }); + + const response = messages.find((message) => message.type === "file.upload.response"); + if (response?.type !== "file.upload.response") { + throw new Error("expected a file.upload.response message"); + } + expect(response.payload.error).toBeNull(); + expect(response.payload.file?.fileName).toBe("notes.txt"); + expect(response.payload.file?.size).toBe(11); + }); +}); + function createStoredAgentRecord( overrides: Pick & Partial, ): StoredAgentRecord { @@ -774,6 +979,132 @@ describe("project config RPC authorization", () => { }); }); +describe("daemon status + pairing RPC", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function makeHome(): string { + const home = realpathSync(mkdtempSync(join(tmpdir(), "daemon-session-test-"))); + tempDirs.push(home); + return home; + } + + test("daemon.get_status.request reports identity, runtime config, and mapped providers", async () => { + const messages: unknown[] = []; + const session = createSessionForTest({ + messages, + paseoHome: makeHome(), + serverId: "srv-test", + daemonVersion: "9.9.9", + daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null }, + agentManager: { + listProviderAvailability: vi.fn().mockResolvedValue([ + { provider: "claude", available: true }, + { provider: "codex", available: false, error: "boom" }, + ]), + }, + }); + + await session.handleMessage({ type: "daemon.get_status.request", requestId: "status-1" }); + + expect(messages).toEqual([ + { + type: "daemon.get_status.response", + payload: { + requestId: "status-1", + serverId: "srv-test", + version: "9.9.9", + pid: process.pid, + nodePath: process.execPath, + startedAt: null, + listen: "127.0.0.1:6767", + relay: null, + providers: [ + { provider: "claude", available: true, error: null }, + { provider: "codex", available: false, error: "boom" }, + ], + }, + }, + ]); + }); + + test("daemon.get_status.request falls back to a null/empty status when provider listing fails", async () => { + const messages: unknown[] = []; + const session = createSessionForTest({ + messages, + paseoHome: makeHome(), + serverId: "srv-test", + daemonVersion: "9.9.9", + daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null }, + agentManager: { + listProviderAvailability: vi.fn().mockRejectedValue(new Error("provider listing failed")), + }, + }); + + await session.handleMessage({ + type: "daemon.get_status.request", + requestId: "status-fallback-1", + }); + + expect(messages).toEqual([ + { + type: "daemon.get_status.response", + payload: { + requestId: "status-fallback-1", + serverId: "srv-test", + version: "9.9.9", + pid: process.pid, + nodePath: process.execPath, + startedAt: null, + listen: null, + relay: null, + providers: [], + }, + }, + ]); + }); + + test("daemon.get_pairing_offer.request returns an empty offer when relay is disabled", async () => { + const messages: unknown[] = []; + const session = createSessionForTest({ + messages, + paseoHome: makeHome(), + daemonRuntimeConfig: { + listen: "127.0.0.1:6767", + relay: { + enabled: false, + endpoint: "relay.paseo.sh:443", + publicEndpoint: "relay.paseo.sh:443", + useTls: true, + publicUseTls: true, + }, + }, + }); + + await session.handleMessage({ + type: "daemon.get_pairing_offer.request", + requestId: "pairing-1", + }); + + expect(messages).toEqual([ + { + type: "daemon.get_pairing_offer.response", + payload: { + requestId: "pairing-1", + url: "", + qr: null, + relayEnabled: false, + }, + }, + ]); + }); +}); + function createWorkspaceGitSnapshot( cwd: string, overrides?: { @@ -1063,7 +1394,7 @@ describe("session checkout merge handling", () => { checkoutGitMocks.mergeToBase.mockResolvedValue("/tmp/base-worktree"); - await asSessionInternals(session).handleCheckoutMergeRequest({ + await session.handleMessage({ type: "checkout_merge_request", cwd: "/tmp/request-worktree", baseRef: "main", @@ -1111,7 +1442,7 @@ describe("session checkout merge handling", () => { }; const session = createSessionForTest({ workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutMergeFromBaseRequest({ + await session.handleMessage({ type: "checkout_merge_from_base_request", cwd: "/tmp/request-worktree", baseRef: "main", @@ -1149,7 +1480,7 @@ describe("session checkout merge handling", () => { const session = createSessionForTest({ github, workspaceGitService, messages }); checkoutGitMocks.mergeFromBase.mockResolvedValue(undefined); - await asSessionInternals(session).handleCheckoutMergeFromBaseRequest({ + await session.handleMessage({ type: "checkout_merge_from_base_request", cwd: "/tmp/request-worktree", baseRef: "main", @@ -1241,7 +1572,7 @@ diff --git a/file.txt b/file.txt checkoutGitMocks.commitChanges.mockResolvedValue(undefined); const session = createSessionForTest({ workspaceGitService }); - await asSessionInternals(session).handleCheckoutCommitRequest({ + await session.handleMessage({ type: "checkout_commit_request", cwd: join(repoRoot, "nested"), message: "", @@ -1262,7 +1593,7 @@ diff --git a/file.txt b/file.txt checkoutGitMocks.commitChanges.mockResolvedValue(undefined); - await asSessionInternals(session).handleCheckoutCommitRequest({ + await session.handleMessage({ type: "checkout_commit_request", cwd: "/tmp/request-worktree", message: "Ship it", @@ -1316,7 +1647,7 @@ diff --git a/file.txt b/file.txt checkoutGitMocks.commitChanges.mockResolvedValue(undefined); const session = createSessionForTest({ workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutCommitRequest({ + await session.handleMessage({ type: "checkout_commit_request", cwd: "/tmp/request-worktree", message: "", @@ -1421,7 +1752,7 @@ diff --git a/file.txt b/file.txt checkoutGitMocks.commitChanges.mockResolvedValue(undefined); const session = createSessionForTest({ workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutCommitRequest({ + await session.handleMessage({ type: "checkout_commit_request", cwd: "/tmp/request-worktree", message: "", @@ -1450,7 +1781,7 @@ diff --git a/file.txt b/file.txt const session = createSessionForTest({ workspaceGitService, messages }); checkoutGitMocks.commitChanges.mockRejectedValue(new Error("nothing to commit")); - await asSessionInternals(session).handleCheckoutCommitRequest({ + await session.handleMessage({ type: "checkout_commit_request", cwd: "/tmp/request-worktree", message: "Ship it", @@ -1540,7 +1871,7 @@ diff --git a/file.txt b/file.txt }); const session = createSessionForTest({ workspaceGitService }); - await asSessionInternals(session).handleCheckoutPrCreateRequest({ + await session.handleMessage({ type: "checkout_pr_create_request", cwd: join(repoRoot, "nested"), baseRef: "main", @@ -1585,7 +1916,7 @@ diff --git a/file.txt b/file.txt }); const session = createSessionForTest({ workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutPrCreateRequest({ + await session.handleMessage({ type: "checkout_pr_create_request", cwd: "/tmp/request-worktree", baseRef: "main", @@ -1724,7 +2055,7 @@ diff --git a/file.txt b/file.txt }); const session = createSessionForTest({ workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutPrCreateRequest({ + await session.handleMessage({ type: "checkout_pr_create_request", cwd: "/tmp/request-worktree", baseRef: "main", @@ -1766,7 +2097,7 @@ diff --git a/file.txt b/file.txt }); const session = createSessionForTest({ github, workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutPrCreateRequest({ + await session.handleMessage({ type: "checkout_pr_create_request", cwd: "/tmp/request-worktree", baseRef: "main", @@ -1828,7 +2159,7 @@ describe("session checkout pull request merge", () => { }; const session = createSessionForTest({ github, workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutPrMergeRequest({ + await session.handleMessage({ type: "checkout_pr_merge_request", cwd: "/tmp/request-worktree", mergeMethod: "squash", @@ -1925,7 +2256,7 @@ describe("session checkout pull request merge", () => { }; const session = createSessionForTest({ github, workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutPrMergeRequest({ + await session.handleMessage({ type: "checkout_pr_merge_request", cwd: "/tmp/request-worktree", mergeMethod: "squash", @@ -1978,7 +2309,7 @@ describe("session checkout pull request merge", () => { }; const session = createSessionForTest({ github, workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutPrMergeRequest({ + await session.handleMessage({ type: "checkout_pr_merge_request", cwd: "/tmp/request-worktree", mergeMethod: "squash", @@ -2040,7 +2371,7 @@ describe("session checkout pull request merge", () => { }; const session = createSessionForTest({ github, workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutPrMergeRequest({ + await session.handleMessage({ type: "checkout_pr_merge_request", cwd: "/tmp/request-worktree", mergeMethod: "merge", @@ -2103,7 +2434,7 @@ describe("session checkout pull request auto-merge", () => { }; const session = createSessionForTest({ github, workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({ + await session.handleMessage({ type: "checkout.github.set_auto_merge.request", cwd: "/tmp/request-worktree", enabled: true, @@ -2169,7 +2500,7 @@ describe("session checkout pull request auto-merge", () => { }; const session = createSessionForTest({ github, workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({ + await session.handleMessage({ type: "checkout.github.set_auto_merge.request", cwd: "/tmp/request-worktree", enabled: false, @@ -2232,7 +2563,7 @@ describe("session checkout pull request auto-merge", () => { }; const session = createSessionForTest({ github, workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({ + await session.handleMessage({ type: "checkout.github.set_auto_merge.request", cwd: "/tmp/request-worktree", enabled: true, @@ -2281,7 +2612,7 @@ describe("session checkout pull request auto-merge", () => { }; const session = createSessionForTest({ github, workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({ + await session.handleMessage({ type: "checkout.github.set_auto_merge.request", cwd: "/tmp/request-worktree", enabled: true, @@ -2337,7 +2668,7 @@ describe("session checkout pull request auto-merge", () => { }; const session = createSessionForTest({ github, workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({ + await session.handleMessage({ type: "checkout.github.set_auto_merge.request", cwd: "/tmp/request-worktree", enabled: false, @@ -2392,7 +2723,7 @@ describe("session checkout pull request auto-merge", () => { }; const session = createSessionForTest({ github, workspaceGitService, messages }); - await asSessionInternals(session).handleCheckoutGithubSetAutoMergeRequest({ + await session.handleMessage({ type: "checkout.github.set_auto_merge.request", cwd: "/tmp/request-worktree", enabled: false, @@ -2426,7 +2757,7 @@ describe("session checkout pull and push handling", () => { const session = createSessionForTest({ github, workspaceGitService, messages }); checkoutGitMocks.pullCurrentBranch.mockResolvedValue(undefined); - await asSessionInternals(session).handleCheckoutPullRequest({ + await session.handleMessage({ type: "checkout_pull_request", cwd: "/tmp/request-worktree", requestId: "request-pull", @@ -2456,7 +2787,7 @@ describe("session checkout pull and push handling", () => { const session = createSessionForTest({ github, workspaceGitService, messages }); checkoutGitMocks.pushCurrentBranch.mockResolvedValue(undefined); - await asSessionInternals(session).handleCheckoutPushRequest({ + await session.handleMessage({ type: "checkout_push_request", cwd: "/tmp/request-worktree", requestId: "request-push", @@ -3034,7 +3365,7 @@ describe("session checkout switch branch handling", () => { const session = createSessionForTest({ github, workspaceGitService, messages }); checkoutGitMocks.checkoutResolvedBranch.mockResolvedValue({ source: "local" }); - await asSessionInternals(session).handleCheckoutSwitchBranchRequest({ + await session.handleMessage({ type: "checkout_switch_branch_request", cwd: "/tmp/repo", branch: "release", @@ -3323,7 +3654,7 @@ describe("session stash list handling", () => { }; const session = createSessionForTest({ workspaceGitService, messages }); - await asSessionInternals(session).handleStashListRequest({ + await session.handleMessage({ type: "stash_list_request", cwd: "/tmp/repo", paseoOnly: true, @@ -3354,7 +3685,7 @@ describe("session stash mutation handling", () => { truncated: false, }); - await asSessionInternals(session).handleStashSaveRequest({ + await session.handleMessage({ type: "stash_save_request", cwd: "/tmp/repo", branch: "feature", @@ -3388,7 +3719,7 @@ describe("session stash mutation handling", () => { truncated: false, }); - await asSessionInternals(session).handleStashPopRequest({ + await session.handleMessage({ type: "stash_pop_request", cwd: "/tmp/repo", stashIndex: 0, @@ -3818,3 +4149,359 @@ describe("session pull request timeline handling", () => { }); }); }); + +describe("chat/schedule/loop dispatch routing (behavior preservation)", () => { + // Each chat/*, loop/*, and schedule/* type must reach its domain handler. The + // injected service stubs are unstubbed, so every handler's own try/catch fires + // and emits its domain rpc_error code — proving the message routed (a dropped + // case would silently no-op and emit nothing). schedule/* historically routed + // via the chat dispatcher's fall-through arm; this guards that path explicitly. + // handleMessage receives already-parsed messages, so these fixtures only need to + // satisfy the TS union here — zod parsing happens upstream at the transport. + const routingCases: Array<{ msg: SessionInboundMessage; code: string }> = [ + { + msg: { type: "chat/create", requestId: "rt-chat-create", name: "room" }, + code: "chat_request_failed", + }, + { msg: { type: "chat/list", requestId: "rt-chat-list" }, code: "chat_request_failed" }, + { + msg: { type: "chat/inspect", requestId: "rt-chat-inspect", room: "room" }, + code: "chat_request_failed", + }, + { + msg: { type: "chat/delete", requestId: "rt-chat-delete", room: "room" }, + code: "chat_request_failed", + }, + { + msg: { type: "chat/post", requestId: "rt-chat-post", room: "room", body: "hi" }, + code: "chat_request_failed", + }, + { + msg: { type: "chat/read", requestId: "rt-chat-read", room: "room" }, + code: "chat_request_failed", + }, + { + msg: { type: "chat/wait", requestId: "rt-chat-wait", room: "room" }, + code: "chat_request_failed", + }, + { + msg: { type: "loop/run", requestId: "rt-loop-run", prompt: "p", cwd: "/tmp/loop" }, + code: "loop_request_failed", + }, + { msg: { type: "loop/list", requestId: "rt-loop-list" }, code: "loop_request_failed" }, + { + msg: { type: "loop/inspect", requestId: "rt-loop-inspect", id: "loop-1" }, + code: "loop_request_failed", + }, + { + msg: { type: "loop/logs", requestId: "rt-loop-logs", id: "loop-1" }, + code: "loop_request_failed", + }, + { + msg: { type: "loop/stop", requestId: "rt-loop-stop", id: "loop-1" }, + code: "loop_request_failed", + }, + { + msg: { + type: "schedule/create", + requestId: "rt-sched-create", + prompt: "p", + cadence: { type: "every", everyMs: 1000 }, + target: { type: "agent", agentId: "00000000-0000-0000-0000-000000000000" }, + }, + code: "schedule_request_failed", + }, + { msg: { type: "schedule/list", requestId: "rt-sched-list" }, code: "schedule_request_failed" }, + { + msg: { type: "schedule/inspect", requestId: "rt-sched-inspect", scheduleId: "s1" }, + code: "schedule_request_failed", + }, + { + msg: { type: "schedule/logs", requestId: "rt-sched-logs", scheduleId: "s1" }, + code: "schedule_request_failed", + }, + { + msg: { type: "schedule/pause", requestId: "rt-sched-pause", scheduleId: "s1" }, + code: "schedule_request_failed", + }, + { + msg: { type: "schedule/resume", requestId: "rt-sched-resume", scheduleId: "s1" }, + code: "schedule_request_failed", + }, + { + msg: { type: "schedule/delete", requestId: "rt-sched-delete", scheduleId: "s1" }, + code: "schedule_request_failed", + }, + { + msg: { type: "schedule/run-once", requestId: "rt-sched-run-once", scheduleId: "s1" }, + code: "schedule_request_failed", + }, + { + msg: { type: "schedule/update", requestId: "rt-sched-update", scheduleId: "s1", name: "new" }, + code: "schedule_request_failed", + }, + ]; + + test.each(routingCases)("routes $msg.type to its domain handler", async ({ msg, code }) => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ messages }); + + await session.handleMessage(msg); + + const routed = messages + .filter( + (m): m is Extract => m.type === "rpc_error", + ) + .find((m) => m.payload.requestId === msg.requestId); + expect(routed, `${msg.type} did not route to a handler (silent no-op)`).toBeDefined(); + expect(routed?.payload.code).toBe(code); + }); +}); + +describe("agent config setters", () => { + test("set_agent_mode_request: success emits accepted response carrying the notice", async () => { + const messages: SessionOutboundMessage[] = []; + const notice = { type: "info", message: "Switched to plan mode" } as const; + const session = createSessionForTest({ + messages, + agentManager: { setAgentMode: vi.fn().mockResolvedValue(notice) }, + }); + + await session.handleMessage({ + type: "set_agent_mode_request", + agentId: "agent-1", + modeId: "plan", + requestId: "req-mode-ok", + }); + + expect(messages).toEqual([ + { + type: "set_agent_mode_response", + payload: { + requestId: "req-mode-ok", + agentId: "agent-1", + accepted: true, + error: null, + notice, + }, + }, + ]); + }); + + test("set_agent_mode_request: failure emits the activity_log error frame before the rejected response", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + agentManager: { setAgentMode: vi.fn().mockRejectedValue(new Error("mode boom")) }, + }); + + await session.handleMessage({ + type: "set_agent_mode_request", + agentId: "agent-1", + modeId: "plan", + requestId: "req-mode-err", + }); + + expect(messages.map((m) => m.type)).toEqual(["activity_log", "set_agent_mode_response"]); + expect(messages[0]).toEqual({ + type: "activity_log", + payload: { + id: expect.any(String), + timestamp: expect.any(Date), + type: "error", + content: "Failed to set agent mode: mode boom", + }, + }); + expect(messages[1]).toEqual({ + type: "set_agent_mode_response", + payload: { + requestId: "req-mode-err", + agentId: "agent-1", + accepted: false, + error: "mode boom", + }, + }); + }); + + test("set_agent_model_request: success emits accepted response with no notice", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + agentManager: { setAgentModel: vi.fn().mockResolvedValue(undefined) }, + }); + + await session.handleMessage({ + type: "set_agent_model_request", + agentId: "agent-1", + modelId: "claude-opus-4-8", + requestId: "req-model-ok", + }); + + expect(messages).toEqual([ + { + type: "set_agent_model_response", + payload: { requestId: "req-model-ok", agentId: "agent-1", accepted: true, error: null }, + }, + ]); + }); + + test("set_agent_model_request: failure emits the activity_log error frame before the rejected response", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + agentManager: { setAgentModel: vi.fn().mockRejectedValue(new Error("model boom")) }, + }); + + await session.handleMessage({ + type: "set_agent_model_request", + agentId: "agent-1", + modelId: "claude-opus-4-8", + requestId: "req-model-err", + }); + + expect(messages.map((m) => m.type)).toEqual(["activity_log", "set_agent_model_response"]); + expect(messages[0]).toEqual({ + type: "activity_log", + payload: { + id: expect.any(String), + timestamp: expect.any(Date), + type: "error", + content: "Failed to set agent model: model boom", + }, + }); + expect(messages[1]).toEqual({ + type: "set_agent_model_response", + payload: { + requestId: "req-model-err", + agentId: "agent-1", + accepted: false, + error: "model boom", + }, + }); + }); + + test("set_agent_feature_request: success emits accepted response with no notice", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + agentManager: { setAgentFeature: vi.fn().mockResolvedValue(undefined) }, + }); + + await session.handleMessage({ + type: "set_agent_feature_request", + agentId: "agent-1", + featureId: "web_search", + value: true, + requestId: "req-feature-ok", + }); + + expect(messages).toEqual([ + { + type: "set_agent_feature_response", + payload: { requestId: "req-feature-ok", agentId: "agent-1", accepted: true, error: null }, + }, + ]); + }); + + test("set_agent_feature_request: failure emits the activity_log error frame before the rejected response", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + agentManager: { setAgentFeature: vi.fn().mockRejectedValue(new Error("feature boom")) }, + }); + + await session.handleMessage({ + type: "set_agent_feature_request", + agentId: "agent-1", + featureId: "web_search", + value: true, + requestId: "req-feature-err", + }); + + expect(messages.map((m) => m.type)).toEqual(["activity_log", "set_agent_feature_response"]); + expect(messages[0]).toEqual({ + type: "activity_log", + payload: { + id: expect.any(String), + timestamp: expect.any(Date), + type: "error", + content: "Failed to set agent feature: feature boom", + }, + }); + expect(messages[1]).toEqual({ + type: "set_agent_feature_response", + payload: { + requestId: "req-feature-err", + agentId: "agent-1", + accepted: false, + error: "feature boom", + }, + }); + }); + + test("set_agent_thinking_request: success emits accepted response carrying the notice", async () => { + const messages: SessionOutboundMessage[] = []; + const notice = { type: "warning", message: "Thinking budget reduced" } as const; + const session = createSessionForTest({ + messages, + agentManager: { setAgentThinkingOption: vi.fn().mockResolvedValue(notice) }, + }); + + await session.handleMessage({ + type: "set_agent_thinking_request", + agentId: "agent-1", + thinkingOptionId: "high", + requestId: "req-thinking-ok", + }); + + expect(messages).toEqual([ + { + type: "set_agent_thinking_response", + payload: { + requestId: "req-thinking-ok", + agentId: "agent-1", + accepted: true, + error: null, + notice, + }, + }, + ]); + }); + + test("set_agent_thinking_request: failure emits the activity_log error frame before the rejected response", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + agentManager: { + setAgentThinkingOption: vi.fn().mockRejectedValue(new Error("thinking boom")), + }, + }); + + await session.handleMessage({ + type: "set_agent_thinking_request", + agentId: "agent-1", + thinkingOptionId: "high", + requestId: "req-thinking-err", + }); + + expect(messages.map((m) => m.type)).toEqual(["activity_log", "set_agent_thinking_response"]); + expect(messages[0]).toEqual({ + type: "activity_log", + payload: { + id: expect.any(String), + timestamp: expect.any(Date), + type: "error", + content: "Failed to set agent thinking option: thinking boom", + }, + }); + expect(messages[1]).toEqual({ + type: "set_agent_thinking_response", + payload: { + requestId: "req-thinking-err", + agentId: "agent-1", + accepted: false, + error: "thinking boom", + }, + }); + }); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index cfdf942d2..5a4f0fffe 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1,6 +1,5 @@ import equal from "fast-deep-equal"; import { v4 as uuidv4 } from "uuid"; -import { realpathSync } from "node:fs"; import type { FSWatcher } from "node:fs"; import { stat } from "node:fs/promises"; import { basename, normalize, resolve, sep } from "path"; @@ -15,11 +14,7 @@ import { type FirstAgentContext, type SessionInboundMessage, type SessionOutboundMessage, - type FileExplorerRequest, - type FileDownloadTokenRequest, - type FileUploadRequest, type GitSetupOptions, - type CheckoutRenameBranchRequest, type StartWorkspaceScriptRequest, type CloseItemsRequest, type DirectorySuggestionsRequest, @@ -33,19 +28,11 @@ import type { } from "../terminal/terminal-manager.js"; import { TerminalSessionController } from "../terminal/terminal-session-controller.js"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; -import { - type BinaryFrame, - encodeFileTransferFrame, - FileTransferOpcode, - type FileTransferFrame, -} from "@getpaseo/protocol/binary-frames/index"; -import { FileUploadStore } from "./file-upload/index.js"; +import type { BinaryFrame } from "@getpaseo/protocol/binary-frames/index"; import { CursorError } from "./pagination/cursor.js"; import { SortablePager, type SortSpec } from "./pagination/sortable-pager.js"; import type { SpeechToTextProvider, TextToSpeechProvider } from "./speech/speech-provider.js"; import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js"; -import { getPidLockInfo } from "./pid-lock.js"; -import { generateLocalPairingOffer } from "./pairing-offer.js"; import { buildConfigOverrides, extractTimestamps, @@ -79,14 +66,10 @@ import type { DaemonConfigStore } from "./daemon-config-store.js"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; -import type { - WorkspaceGitRuntimeSnapshot, - WorkspaceGitService, - WorkspaceGitSnapshotOptions, -} from "./workspace-git-service.js"; +import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js"; import { AgentManager } from "./agent/agent-manager.js"; -import { ProviderSnapshotManager, resolveSnapshotCwd } from "./agent/provider-snapshot-manager.js"; +import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { AgentManagerEvent, AgentTimelineCursor, @@ -132,12 +115,10 @@ import { getAgentStreamEventTurnId, type AgentPersistenceHandle, type AgentPermissionResponse, - type AgentProvider, type AgentPromptContentBlock, type AgentPromptInput, type AgentRunOptions, type AgentSessionConfig, - type ProviderSnapshotEntry, } from "./agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js"; @@ -169,19 +150,14 @@ import { wrapSpokenInput } from "./voice-config.js"; import { isVoicePermissionAllowed } from "./voice-permission-policy.js"; import { VoiceSession } from "./session/voice/voice-session.js"; import { CheckoutSession } from "./session/checkout/checkout-session.js"; -import { - listDirectoryEntries, - readExplorerFile, - readExplorerFileBytes, - getDownloadableFileInfo, -} from "./file-explorer/service.js"; +import { ChatScheduleLoopSession } from "./session/chat/chat-schedule-loop-session.js"; +import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js"; +import { WorkspaceFilesSession } from "./session/files/workspace-files-session.js"; +import { AgentConfigSession } from "./session/agent-config/agent-config-session.js"; +import { ProjectConfigSession } from "./session/project-config/project-config-session.js"; +import { DaemonSession, type DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; import { DownloadTokenStore } from "./file-download/token-store.js"; import { PushTokenStore } from "./push/token-store.js"; -import { - readPaseoConfigForEdit, - writePaseoConfigForEdit, - type ProjectConfigRpcError, -} from "../utils/paseo-config-file.js"; import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js"; import { archivePersistedWorkspaceRecord, @@ -192,40 +168,20 @@ import type { ServiceProxySubsystem } from "./service-proxy.js"; import { checkoutResolvedBranch, type CheckoutExistingBranchResult, - commitChanges, - mergeToBase, - mergeFromBase, - pullCurrentBranch, - pushCurrentBranch, - createPullRequest, + type GitMutationRefreshReason, renameCurrentBranch as renameCurrentBranchDefault, } from "../utils/checkout-git.js"; -import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; -import { getProjectIcon } from "../utils/project-icon.js"; import { expandTilde } from "../utils/path.js"; import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js"; -import { toCheckoutError } from "./checkout-git-utils.js"; import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; -import { buildCheckoutPrStatusPayloadFromSnapshot } from "./checkout/status-projection.js"; import type { Resolvable } from "./speech/provider-resolver.js"; import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js"; import type pino from "pino"; -import { - ChatServiceError, - FileBackedChatService, - parseMentionAgentIds, -} from "./chat/chat-service.js"; -import { notifyChatMentions, prepareChatMentionFanout } from "./chat/chat-mentions.js"; +import { FileBackedChatService } from "./chat/chat-service.js"; import { LoopService } from "./loop-service.js"; import { ScheduleService } from "./schedule/service.js"; import { execCommand } from "../utils/spawn.js"; -import { - assertPullRequestAutoMergeDisableReady, - assertPullRequestAutoMergeEnableReady, - createGitHubService, - type GitHubService, - type PullRequestTimelineItem, -} from "../services/github-service.js"; +import { createGitHubService, type GitHubService } from "../services/github-service.js"; import type { ProviderUsageService } from "../services/quota-fetcher/service.js"; import { summarizeFetchWorkspacesEntries, @@ -268,80 +224,10 @@ import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dis const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; -type CurrentWorkspacePullRequest = NonNullable< - WorkspaceGitRuntimeSnapshot["github"]["pullRequest"] -> & { - number: number; -}; - -interface ResolveKnownProjectRootForConfigInput { - repoRoot: string; - projectRegistry: Pick; -} - -async function resolveKnownProjectRootForConfig( - input: ResolveKnownProjectRootForConfigInput, -): Promise { - const requestedRoot = canonicalizeConfigRoot(input.repoRoot); - const projects = await input.projectRegistry.list(); - for (const project of projects) { - if (project.archivedAt !== null) { - continue; - } - const projectRoot = canonicalizeConfigRoot(project.rootPath); - if (requestedRoot === projectRoot) { - return projectRoot; - } - } - return null; -} - -function canonicalizeConfigRoot(repoRoot: string): string { - const resolved = resolve(repoRoot); - try { - return stripTrailingPathSeparators(realpathSync(resolved)); - } catch { - return stripTrailingPathSeparators(resolved); - } -} - -function stripTrailingPathSeparators(path: string): string { - let normalized = path; - while (normalized.length > 1 && normalized.endsWith(sep)) { - normalized = normalized.slice(0, -1); - } - return normalized; -} - -type GitMutationRefreshReason = - | "commit-changes" - | "pull" - | "push" - | "merge-to-base" - | "merge-from-base" - | "merge-pr" - | "enable-pr-auto-merge" - | "disable-pr-auto-merge" - | "create-pr" - | "switch-branch" - | "rename-branch" - | "create-branch" - | "stash-push" - | "stash-pop" - | "create-worktree"; - // TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings. // Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject // the entire session message if they encounter an unknown provider. const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]); -// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any -// other icon name is downgraded to "ShieldCheck" for those clients. -const LEGACY_MODE_ICONS = new Set([ - "ShieldCheck", - "ShieldAlert", - "ShieldOff", - "ShieldQuestionMark", -]); const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; function errorToFriendlyMessage(error: unknown): string { @@ -608,17 +494,7 @@ export interface SessionOptions { }; serverId?: string; daemonVersion?: string; - daemonRuntimeConfig?: { - listen: string | null; - appBaseUrl?: string; - relay: { - enabled: boolean; - endpoint: string; - publicEndpoint: string; - useTls: boolean; - publicUseTls: boolean; - } | null; - }; + daemonRuntimeConfig?: DaemonRuntimeConfig; } export type SessionLifecycleIntent = @@ -634,12 +510,6 @@ export type SessionLifecycleIntent = reason?: string; }; -type PullRequestTimelinePayload = Extract< - SessionOutboundMessage, - { type: "pull_request_timeline_response" } ->["payload"]; -type PullRequestTimelinePayloadItem = PullRequestTimelinePayload["items"][number]; - function parseClientCapabilities( capabilities: Record | null | undefined, ): ReadonlySet { @@ -704,16 +574,12 @@ export class Session { private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly filesystem: SessionFileSystem; - private readonly chatService: FileBackedChatService; - private readonly scheduleService: ScheduleService; - private readonly loopService: LoopService; private readonly github: GitHubService; private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; private readonly generateWorkspaceName: typeof generateBranchNameFromFirstAgentContext; private readonly workspaceGitService: WorkspaceGitService; private readonly daemonConfigStore: DaemonConfigStore; private readonly mcpBaseUrl: string | null; - private readonly downloadTokenStore: DownloadTokenStore; private readonly pushTokenStore: PushTokenStore; private unsubscribeAgentEvents: (() => void) | null = null; private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null; @@ -729,8 +595,6 @@ export class Session { } | null = null; private readonly terminalManager: TerminalManager | null; private readonly providerSnapshotManager: ProviderSnapshotManager; - private readonly providerUsageService: ProviderUsageService; - private unsubscribeProviderSnapshotEvents: (() => void) | null = null; private readonly serviceProxy: ServiceProxySubsystem | null; private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; private readonly onBranchChanged?: ( @@ -749,13 +613,15 @@ export class Session { private readonly workspaceSetupSnapshots: Map; private readonly workspaceGitFetchSubscriptions = new Map void>(); private readonly workspaceGitSubscriptions = new Map void>(); - private readonly fileUploads: FileUploadStore; private readonly workspaceDirectory: WorkspaceDirectory; private readonly voiceSession: VoiceSession; private readonly checkoutSession: CheckoutSession; - private readonly serverId: string | undefined; - private readonly daemonVersion: string | undefined; - private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"]; + private readonly chatScheduleLoopSession: ChatScheduleLoopSession; + private readonly providerCatalogSession: ProviderCatalogSession; + private readonly workspaceFilesSession: WorkspaceFilesSession; + private readonly agentConfigSession: AgentConfigSession; + private readonly projectConfigSession: ProjectConfigSession; + private readonly daemonSession: DaemonSession; private readonly createAgentLifecycleDispatch: CreateAgentLifecycleDispatch; constructor(options: SessionOptions) { @@ -816,9 +682,7 @@ export class Session { this.onBinaryMessage = onBinaryMessage ?? null; this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0); this.onLifecycleIntent = onLifecycleIntent ?? null; - this.downloadTokenStore = downloadTokenStore; this.pushTokenStore = pushTokenStore; - this.fileUploads = new FileUploadStore({ paseoHome }); this.paseoHome = paseoHome; this.worktreesRoot = worktreesRoot; this.sessionLogger = logger.child({ @@ -826,14 +690,21 @@ export class Session { clientId: this.clientId, sessionId: this.sessionId, }); + this.workspaceFilesSession = new WorkspaceFilesSession({ + host: { + emit: (msg) => this.emit(msg), + emitBinary: (frame) => this.emitBinary(frame), + hasBinaryChannel: () => this.onBinaryMessage !== null, + }, + downloadTokenStore, + paseoHome, + logger: this.sessionLogger, + }); this.agentManager = agentManager; this.agentStorage = agentStorage; this.projectRegistry = projectRegistry; this.workspaceRegistry = workspaceRegistry; this.filesystem = filesystem ?? nodeSessionFileSystem; - this.chatService = chatService; - this.scheduleService = scheduleService; - this.loopService = loopService; this.github = github ?? createGitHubService(); this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.generateWorkspaceName = generateWorkspaceName ?? generateBranchNameFromFirstAgentContext; @@ -841,10 +712,89 @@ export class Session { this.checkoutSession = new CheckoutSession({ host: { emit: (msg) => this.emit(msg), + notifyGitMutation: (cwd, reason, mutationOptions) => + this.notifyGitMutation(cwd, reason, mutationOptions), + emitWorkspaceUpdateForCwd: (cwd) => this.emitWorkspaceUpdateForCwd(cwd), + handleWorkspaceGitBranchSnapshot: (cwd, branchName) => + this.handleWorkspaceGitBranchSnapshot(cwd, branchName), + renameCurrentBranch: (cwd, branch) => this.renameCurrentBranch(cwd, branch), + checkoutExistingBranch: (cwd, branch) => this.checkoutExistingBranch(cwd, branch), + generateCommitMessage: (cwd) => this.generateCommitMessage(cwd), + generatePullRequestText: (cwd, baseRef) => this.generatePullRequestText(cwd, baseRef), }, workspaceGitService: this.workspaceGitService, github: this.github, checkoutDiffManager, + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + logger: this.sessionLogger, + }); + this.chatScheduleLoopSession = new ChatScheduleLoopSession({ + host: { + emit: (msg) => this.emit(msg), + listStoredAgents: () => this.agentStorage.list(), + listLiveAgents: () => this.agentManager.listAgents(), + resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier), + sendAgentMessage: async (agentId, text) => { + await sendPromptToAgent({ + agentManager: this.agentManager, + agentStorage: this.agentStorage, + agentId, + prompt: formatSystemNotificationPrompt(text), + unarchive: false, + logger: this.sessionLogger, + }); + }, + }, + chatService, + scheduleService, + loopService, + clientId: this.clientId, + logger: this.sessionLogger, + }); + this.providerCatalogSession = new ProviderCatalogSession({ + host: { + emit: (msg) => this.emit(msg), + isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), + supportsCustomModeIcons: () => this.supports(CLIENT_CAPS.customModeIcons), + listProviderAvailability: () => this.agentManager.listProviderAvailability(), + listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config), + }, + providerSnapshotManager, + providerUsageService, + logger: this.sessionLogger, + }); + this.agentConfigSession = new AgentConfigSession({ + host: { + emit: (msg) => this.emit(msg), + }, + operations: { + 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), + }, + paseoHome: this.paseoHome, + serverId, + daemonVersion, + daemonRuntimeConfig, + listProviderAvailability: () => this.agentManager.listProviderAvailability(), logger: this.sessionLogger, }); this.daemonConfigStore = daemonConfigStore; @@ -894,7 +844,6 @@ export class Session { logger: this.sessionLogger, }); this.providerSnapshotManager = providerSnapshotManager; - this.providerUsageService = providerUsageService; this.serviceProxy = serviceProxy ?? null; this.scriptRuntimeStore = scriptRuntimeStore ?? null; this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map(); @@ -904,9 +853,6 @@ export class Session { this.serviceProxyPublicBaseUrl = serviceProxyPublicBaseUrl ?? null; this.resolveScriptHealth = resolveScriptHealth ?? null; this.subscribeToOptionalManagers(); - this.serverId = serverId; - this.daemonVersion = daemonVersion; - this.daemonRuntimeConfig = daemonRuntimeConfig; this.workspaceDirectory = new WorkspaceDirectory({ logger: this.sessionLogger, projectRegistry: this.projectRegistry, @@ -973,25 +919,6 @@ export class Session { return this.clientCapabilities.has(capability); } - // COMPAT(customModeIcons): rewrite icons unknown to v0.1.83 clients (whose MODE_ICONS - // map is a closed enum and would render `undefined`, crashing in render). Drop - // this and the cap gate when floor >= v0.1.84. - private downgradeModeIconsForClient(modes: T[]): T[] { - if (this.supports(CLIENT_CAPS.customModeIcons)) return modes; - return modes.map((mode) => - mode.icon && !LEGACY_MODE_ICONS.has(mode.icon) ? { ...mode, icon: "ShieldCheck" } : mode, - ); - } - - private downgradeEntryModesForClient( - entries: T[], - ): T[] { - if (this.supports(CLIENT_CAPS.customModeIcons)) return entries; - return entries.map((entry) => - entry.modes ? { ...entry, modes: this.downgradeModeIconsForClient(entry.modes) } : entry, - ); - } - async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise { const descriptor = await this.describeWorkspaceRecordWithGitData(workspace); this.syncWorkspaceGitObservers([descriptor]); @@ -1230,25 +1157,7 @@ export class Session { }); }); } - const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => { - // COMPAT(providersSnapshot): keep provider visibility gating for older clients. - const visibleEntries = entries.filter((entry) => - this.isProviderVisibleToClient(entry.provider), - ); - const snapshotCwd = cwd === resolveSnapshotCwd() ? undefined : cwd; - this.emit({ - type: "providers_snapshot_update", - payload: { - ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: this.downgradeEntryModesForClient(visibleEntries), - generatedAt: new Date().toISOString(), - }, - }); - }; - this.providerSnapshotManager.on("change", handleProviderSnapshotChange); - this.unsubscribeProviderSnapshotEvents = () => { - this.providerSnapshotManager.off("change", handleProviderSnapshotChange); - }; + this.providerCatalogSession.start(); } private subscribeToAgentEvents(): void { @@ -1847,18 +1756,13 @@ export class Session { private dispatchAgentConfigMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "set_agent_mode_request": - return this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId); + return this.agentConfigSession.handleSetAgentModeRequest(msg); case "set_agent_model_request": - return this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId); + return this.agentConfigSession.handleSetAgentModelRequest(msg); case "set_agent_feature_request": - return this.handleSetAgentFeatureRequest( - msg.agentId, - msg.featureId, - msg.value, - msg.requestId, - ); + return this.agentConfigSession.handleSetAgentFeatureRequest(msg); case "set_agent_thinking_request": - return this.handleSetAgentThinkingRequest(msg.agentId, msg.thinkingOptionId, msg.requestId); + return this.agentConfigSession.handleSetAgentThinkingRequest(msg); case "get_daemon_config_request": this.emit({ type: "get_daemon_config_response", @@ -1866,9 +1770,9 @@ export class Session { }); return undefined; case "daemon.get_status.request": - return this.handleDaemonGetStatusRequest(msg); + return this.daemonSession.handleGetStatusRequest(msg); case "daemon.get_pairing_offer.request": - return this.handleDaemonGetPairingOfferRequest(msg); + return this.daemonSession.handleGetPairingOfferRequest(msg); case "set_daemon_config_request": this.emit({ type: "set_daemon_config_response", @@ -1879,133 +1783,14 @@ export class Session { }); return undefined; case "read_project_config_request": - return this.handleReadProjectConfigRequest(msg); + return this.projectConfigSession.handleReadProjectConfigRequest(msg); case "write_project_config_request": - return this.handleWriteProjectConfigRequest(msg); + return this.projectConfigSession.handleWriteProjectConfigRequest(msg); default: return undefined; } } - private async handleReadProjectConfigRequest( - msg: Extract, - ): Promise { - const repoRoot = await resolveKnownProjectRootForConfig({ - repoRoot: msg.repoRoot, - projectRegistry: this.projectRegistry, - }); - if (!repoRoot) { - this.emitProjectConfigReadFailure(msg, { code: "project_not_found" }); - return; - } - - const result = readPaseoConfigForEdit(repoRoot); - if (!result.ok) { - this.sessionLogger.warn( - { repoRoot, requestId: msg.requestId, outcome: result.error.code }, - "Failed to read project config", - ); - this.emitProjectConfigReadFailure(msg, result.error, repoRoot); - return; - } - - if (result.config === null) { - this.sessionLogger.debug( - { repoRoot, requestId: msg.requestId, outcome: "missing_project_config" }, - "Project config missing", - ); - } - - this.emit({ - type: "read_project_config_response", - payload: { - requestId: msg.requestId, - repoRoot, - ok: true, - config: result.config, - revision: result.revision, - }, - }); - } - - private async handleWriteProjectConfigRequest( - msg: Extract, - ): Promise { - const repoRoot = await resolveKnownProjectRootForConfig({ - repoRoot: msg.repoRoot, - projectRegistry: this.projectRegistry, - }); - if (!repoRoot) { - this.emitProjectConfigWriteFailure(msg, { code: "project_not_found" }); - return; - } - - this.sessionLogger.debug( - { repoRoot, requestId: msg.requestId, outcome: "write_attempt" }, - "Writing project config", - ); - const result = writePaseoConfigForEdit({ - repoRoot, - config: msg.config, - expectedRevision: msg.expectedRevision, - }); - if (!result.ok) { - this.sessionLogger.debug( - { repoRoot, requestId: msg.requestId, outcome: result.error.code }, - "Project config write did not complete", - ); - this.emitProjectConfigWriteFailure(msg, result.error, repoRoot); - return; - } - - this.sessionLogger.debug( - { repoRoot, requestId: msg.requestId, outcome: "written" }, - "Project config written", - ); - this.emit({ - type: "write_project_config_response", - payload: { - requestId: msg.requestId, - repoRoot, - ok: true, - config: result.config, - revision: result.revision, - }, - }); - } - - private emitProjectConfigReadFailure( - msg: Extract, - error: ProjectConfigRpcError, - repoRoot = msg.repoRoot, - ): void { - this.emit({ - type: "read_project_config_response", - payload: { - requestId: msg.requestId, - repoRoot, - ok: false, - error, - }, - }); - } - - private emitProjectConfigWriteFailure( - msg: Extract, - error: ProjectConfigRpcError, - repoRoot = msg.repoRoot, - ): void { - this.emit({ - type: "write_project_config_response", - payload: { - requestId: msg.requestId, - repoRoot, - ok: false, - error, - }, - }); - } - // eslint-disable-next-line complexity private dispatchCheckoutMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { @@ -2023,41 +1808,41 @@ export class Session { this.checkoutSession.handleUnsubscribeDiffRequest(msg); return undefined; case "checkout_switch_branch_request": - return this.handleCheckoutSwitchBranchRequest(msg); + return this.checkoutSession.handleCheckoutSwitchBranchRequest(msg); case "checkout.rename_branch.request": - return this.handleCheckoutRenameBranchRequest(msg); + return this.checkoutSession.handleCheckoutRenameBranchRequest(msg); case "checkout_commit_request": - return this.handleCheckoutCommitRequest(msg); + return this.checkoutSession.handleCheckoutCommitRequest(msg); case "checkout_merge_request": - return this.handleCheckoutMergeRequest(msg); + return this.checkoutSession.handleCheckoutMergeRequest(msg); case "checkout_merge_from_base_request": - return this.handleCheckoutMergeFromBaseRequest(msg); + return this.checkoutSession.handleCheckoutMergeFromBaseRequest(msg); case "checkout_pull_request": - return this.handleCheckoutPullRequest(msg); + return this.checkoutSession.handleCheckoutPullRequest(msg); case "checkout_push_request": - return this.handleCheckoutPushRequest(msg); + return this.checkoutSession.handleCheckoutPushRequest(msg); case "checkout.refresh.request": return this.checkoutSession.handleRefreshRequest(msg); case "checkout_pr_create_request": - return this.handleCheckoutPrCreateRequest(msg); + return this.checkoutSession.handleCheckoutPrCreateRequest(msg); case "checkout_pr_merge_request": - return this.handleCheckoutPrMergeRequest(msg); + return this.checkoutSession.handleCheckoutPrMergeRequest(msg); case "checkout.github.set_auto_merge.request": - return this.handleCheckoutGithubSetAutoMergeRequest(msg); + return this.checkoutSession.handleCheckoutGithubSetAutoMergeRequest(msg); case "checkout.github.get_check_details.request": - return this.handleCheckoutGithubGetCheckDetailsRequest(msg); + return this.checkoutSession.handleCheckoutGithubGetCheckDetailsRequest(msg); case "checkout_pr_status_request": - return this.handleCheckoutPrStatusRequest(msg); + return this.checkoutSession.handleCheckoutPrStatusRequest(msg); case "pull_request_timeline_request": - return this.handlePullRequestTimelineRequest(msg); + return this.checkoutSession.handlePullRequestTimelineRequest(msg); case "github_search_request": - return this.handleGitHubSearchRequest(msg); + return this.checkoutSession.handleGitHubSearchRequest(msg); case "stash_save_request": - return this.handleStashSaveRequest(msg); + return this.checkoutSession.handleStashSaveRequest(msg); case "stash_pop_request": - return this.handleStashPopRequest(msg); + return this.checkoutSession.handleStashPopRequest(msg); case "stash_list_request": - return this.handleStashListRequest(msg); + return this.checkoutSession.handleStashListRequest(msg); default: return undefined; } @@ -2097,13 +1882,13 @@ export class Session { case "workspace.title.set.request": return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "file_explorer_request": - return this.handleFileExplorerRequest(msg); + return this.workspaceFilesSession.handleFileExplorerRequest(msg); case "project_icon_request": - return this.handleProjectIconRequest(msg); + return this.workspaceFilesSession.handleProjectIconRequest(msg); case "file_download_token_request": - return this.handleFileDownloadTokenRequest(msg); + return this.workspaceFilesSession.handleFileDownloadTokenRequest(msg); case "file.upload.request": - this.handleFileUploadRequest(msg); + this.workspaceFilesSession.handleFileUploadRequest(msg); return undefined; default: return undefined; @@ -2113,21 +1898,21 @@ export class Session { private dispatchProviderMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "list_provider_models_request": - return this.handleListProviderModelsRequest(msg); + return this.providerCatalogSession.handleListProviderModelsRequest(msg); case "list_provider_modes_request": - return this.handleListProviderModesRequest(msg); + return this.providerCatalogSession.handleListProviderModesRequest(msg); case "list_provider_features_request": - return this.handleListProviderFeaturesRequest(msg); + return this.providerCatalogSession.handleListProviderFeaturesRequest(msg); case "list_available_providers_request": - return this.handleListAvailableProvidersRequest(msg); + return this.providerCatalogSession.handleListAvailableProvidersRequest(msg); case "get_providers_snapshot_request": - return this.handleGetProvidersSnapshotRequest(msg); + return this.providerCatalogSession.handleGetProvidersSnapshotRequest(msg); case "refresh_providers_snapshot_request": - return this.handleRefreshProvidersSnapshotRequest(msg); + return this.providerCatalogSession.handleRefreshProvidersSnapshotRequest(msg); case "provider_diagnostic_request": - return this.handleProviderDiagnosticRequest(msg); + return this.providerCatalogSession.handleProviderDiagnosticRequest(msg); case "provider.usage.list.request": - return this.handleProviderUsageListRequest(msg); + return this.providerCatalogSession.handleProviderUsageListRequest(msg); default: return undefined; } @@ -2140,57 +1925,51 @@ export class Session { return this.terminalController.dispatch(msg); } + // eslint-disable-next-line complexity private dispatchChatScheduleLoopMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "chat/create": - return this.handleChatCreateRequest(msg); + return this.chatScheduleLoopSession.handleChatCreateRequest(msg); case "chat/list": - return this.handleChatListRequest(msg); + return this.chatScheduleLoopSession.handleChatListRequest(msg); case "chat/inspect": - return this.handleChatInspectRequest(msg); + return this.chatScheduleLoopSession.handleChatInspectRequest(msg); case "chat/delete": - return this.handleChatDeleteRequest(msg); + return this.chatScheduleLoopSession.handleChatDeleteRequest(msg); case "chat/post": - return this.handleChatPostRequest(msg); + return this.chatScheduleLoopSession.handleChatPostRequest(msg); case "chat/read": - return this.handleChatReadRequest(msg); + return this.chatScheduleLoopSession.handleChatReadRequest(msg); case "chat/wait": - return this.handleChatWaitRequest(msg); + return this.chatScheduleLoopSession.handleChatWaitRequest(msg); case "loop/run": - return this.handleLoopRunRequest(msg); + return this.chatScheduleLoopSession.handleLoopRunRequest(msg); case "loop/list": - return this.handleLoopListRequest(msg); + return this.chatScheduleLoopSession.handleLoopListRequest(msg); case "loop/inspect": - return this.handleLoopInspectRequest(msg); + return this.chatScheduleLoopSession.handleLoopInspectRequest(msg); case "loop/logs": - return this.handleLoopLogsRequest(msg); + return this.chatScheduleLoopSession.handleLoopLogsRequest(msg); case "loop/stop": - return this.handleLoopStopRequest(msg); - default: - return this.dispatchScheduleMessage(msg); - } - } - - private dispatchScheduleMessage(msg: SessionInboundMessage): Promise | undefined { - switch (msg.type) { + return this.chatScheduleLoopSession.handleLoopStopRequest(msg); case "schedule/create": - return this.handleScheduleCreateRequest(msg); + return this.chatScheduleLoopSession.handleScheduleCreateRequest(msg); case "schedule/list": - return this.handleScheduleListRequest(msg); + return this.chatScheduleLoopSession.handleScheduleListRequest(msg); case "schedule/inspect": - return this.handleScheduleInspectRequest(msg); + return this.chatScheduleLoopSession.handleScheduleInspectRequest(msg); case "schedule/logs": - return this.handleScheduleLogsRequest(msg); + return this.chatScheduleLoopSession.handleScheduleLogsRequest(msg); case "schedule/pause": - return this.handleSchedulePauseRequest(msg); + return this.chatScheduleLoopSession.handleSchedulePauseRequest(msg); case "schedule/resume": - return this.handleScheduleResumeRequest(msg); + return this.chatScheduleLoopSession.handleScheduleResumeRequest(msg); case "schedule/delete": - return this.handleScheduleDeleteRequest(msg); + return this.chatScheduleLoopSession.handleScheduleDeleteRequest(msg); case "schedule/run-once": - return this.handleScheduleRunOnceRequest(msg); + return this.chatScheduleLoopSession.handleScheduleRunOnceRequest(msg); case "schedule/update": - return this.handleScheduleUpdateRequest(msg); + return this.chatScheduleLoopSession.handleScheduleUpdateRequest(msg); default: return undefined; } @@ -2213,7 +1992,7 @@ export class Session { public async handleBinaryFrame(binaryFrame: BinaryFrame): Promise { if (binaryFrame.kind === "file_transfer") { - await this.handleFileTransferFrame(binaryFrame.frame); + await this.workspaceFilesSession.handleFileTransferFrame(binaryFrame.frame); return; } this.terminalController.handleBinaryFrame(binaryFrame.frame); @@ -3463,420 +3242,6 @@ export class Session { ); } - private emitProviderDisabledResponse( - kind: "models" | "modes", - provider: AgentProvider, - requestId: string, - fetchedAt: string, - ): void { - const payload = { - provider, - error: `Provider ${provider} is disabled`, - fetchedAt, - requestId, - }; - if (kind === "models") { - this.emit({ type: "list_provider_models_response", payload }); - } else { - this.emit({ type: "list_provider_modes_response", payload }); - } - } - - private async handleListProviderModelsRequest( - msg: Extract, - ): Promise { - const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined); - const fetchedAt = new Date().toISOString(); - - const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider); - - if (!entry) { - this.emit({ - type: "list_provider_models_response", - payload: { - provider: msg.provider, - error: `Unknown provider: ${msg.provider}`, - fetchedAt, - requestId: msg.requestId, - }, - }); - return; - } - - if (!entry.enabled) { - this.emitProviderDisabledResponse("models", msg.provider, msg.requestId, fetchedAt); - return; - } - - if (entry.status === "ready") { - this.emit({ - type: "list_provider_models_response", - payload: { - provider: msg.provider, - models: entry.models ?? [], - error: null, - fetchedAt: entry.fetchedAt ?? fetchedAt, - requestId: msg.requestId, - }, - }); - return; - } - - const errorMessage = - entry.status === "error" - ? (entry.error ?? `Failed to list models for ${msg.provider}`) - : `Provider ${msg.provider} is not available`; - - this.emit({ - type: "list_provider_models_response", - payload: { - provider: msg.provider, - error: errorMessage, - fetchedAt, - requestId: msg.requestId, - }, - }); - } - - private async handleListProviderModesRequest( - msg: Extract, - ): Promise { - const fetchedAt = new Date().toISOString(); - const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined); - const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider); - - if (!entry) { - this.emit({ - type: "list_provider_modes_response", - payload: { - provider: msg.provider, - error: `Unknown provider: ${msg.provider}`, - fetchedAt, - requestId: msg.requestId, - }, - }); - return; - } - - if (!entry.enabled) { - this.emitProviderDisabledResponse("modes", msg.provider, msg.requestId, fetchedAt); - return; - } - - if (entry.status === "ready") { - this.emit({ - type: "list_provider_modes_response", - payload: { - provider: msg.provider, - modes: this.downgradeModeIconsForClient(entry.modes ?? []), - error: null, - fetchedAt: entry.fetchedAt ?? fetchedAt, - requestId: msg.requestId, - }, - }); - return; - } - - const errorMessage = - entry.status === "error" - ? (entry.error ?? `Failed to list modes for ${msg.provider}`) - : `Provider ${msg.provider} is not available`; - - this.emit({ - type: "list_provider_modes_response", - payload: { - provider: msg.provider, - error: errorMessage, - fetchedAt, - requestId: msg.requestId, - }, - }); - } - - private async getProviderSnapshotEntryForRead( - cwd: string, - provider: AgentProvider, - ): Promise { - const manager = this.providerSnapshotManager; - const findEntry = () => - manager.getSnapshot(cwd).find((candidate) => candidate.provider === provider); - - let entry = findEntry(); - if (entry && !entry.enabled) { - return entry; - } - if (!entry || entry.status === "loading") { - // Awaits the in-flight warmup (deduped per-cwd) so old clients still get - // a resolved answer rather than a loading placeholder. - await manager.warmUpSnapshotForCwd({ cwd, providers: [provider] }); - entry = findEntry(); - } - return entry; - } - - private buildDraftAgentSessionConfig(draftConfig: { - provider: AgentProvider; - cwd: string; - modeId?: string; - model?: string; - thinkingOptionId?: string; - featureValues?: Record; - }): AgentSessionConfig { - return { - provider: draftConfig.provider, - cwd: expandTilde(draftConfig.cwd), - ...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}), - ...(draftConfig.model ? { model: draftConfig.model } : {}), - ...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}), - ...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}), - }; - } - - private async handleListProviderFeaturesRequest( - msg: Extract, - ): Promise { - const fetchedAt = new Date().toISOString(); - try { - const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig); - const features = await this.agentManager.listDraftFeatures(sessionConfig); - this.emit({ - type: "list_provider_features_response", - payload: { - provider: msg.draftConfig.provider, - features, - error: null, - fetchedAt, - requestId: msg.requestId, - }, - }); - } catch (error) { - this.sessionLogger.error( - { err: error, provider: msg.draftConfig.provider, draftConfig: msg.draftConfig }, - `Failed to list features for ${msg.draftConfig.provider}`, - ); - this.emit({ - type: "list_provider_features_response", - payload: { - provider: msg.draftConfig.provider, - error: getErrorMessage(error), - fetchedAt, - requestId: msg.requestId, - }, - }); - } - } - - private async handleDaemonGetStatusRequest( - msg: Extract, - ): Promise { - try { - const pidInfo = await getPidLockInfo(this.paseoHome); - const providers = (await this.agentManager.listProviderAvailability()).map((p) => ({ - provider: p.provider, - available: p.available, - error: p.error ?? null, - })); - this.emit({ - type: "daemon.get_status.response", - payload: { - requestId: msg.requestId, - serverId: this.serverId ?? "", - version: this.daemonVersion ?? null, - pid: process.pid, - nodePath: process.execPath, - startedAt: pidInfo?.startedAt ?? null, - listen: this.daemonRuntimeConfig?.listen ?? null, - relay: this.daemonRuntimeConfig?.relay ?? null, - providers, - }, - }); - } catch (error) { - this.sessionLogger.error({ err: error }, "Failed to handle daemon status request"); - this.emit({ - type: "daemon.get_status.response", - payload: { - requestId: msg.requestId, - serverId: this.serverId ?? "", - version: this.daemonVersion ?? null, - pid: process.pid, - nodePath: process.execPath, - startedAt: null, - listen: null, - relay: null, - providers: [], - }, - }); - } - } - - private async handleDaemonGetPairingOfferRequest( - msg: Extract, - ): Promise { - try { - const relay = this.daemonRuntimeConfig?.relay; - const pairing = await generateLocalPairingOffer({ - paseoHome: this.paseoHome, - relayEnabled: relay?.enabled ?? true, - relayEndpoint: relay?.endpoint, - relayPublicEndpoint: relay?.publicEndpoint, - relayUseTls: relay?.useTls, - relayPublicUseTls: relay?.publicUseTls, - appBaseUrl: this.daemonRuntimeConfig?.appBaseUrl, - includeQr: true, - logger: this.sessionLogger, - }); - this.emit({ - type: "daemon.get_pairing_offer.response", - payload: { - requestId: msg.requestId, - url: pairing.url ?? "", - qr: pairing.qr ?? null, - relayEnabled: pairing.relayEnabled, - }, - }); - } catch (error) { - this.sessionLogger.error({ err: error }, "Failed to handle daemon pairing offer request"); - this.emit({ - type: "rpc_error", - payload: { - requestId: msg.requestId, - requestType: "daemon.get_pairing_offer.request", - error: error instanceof Error ? error.message : String(error), - }, - }); - } - } - - private async handleListAvailableProvidersRequest( - msg: Extract, - ): Promise { - const fetchedAt = new Date().toISOString(); - try { - const providers = (await this.agentManager.listProviderAvailability()).filter((provider) => - this.isProviderVisibleToClient(provider.provider), - ); - this.emit({ - type: "list_available_providers_response", - payload: { - providers, - error: null, - fetchedAt, - requestId: msg.requestId, - }, - }); - } catch (error) { - this.sessionLogger.error({ err: error }, "Failed to list provider availability"); - this.emit({ - type: "list_available_providers_response", - payload: { - providers: [], - error: getErrorMessage(error), - fetchedAt, - requestId: msg.requestId, - }, - }); - } - } - - private async handleGetProvidersSnapshotRequest( - msg: Extract, - ): Promise { - // COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow. - const entries = this.providerSnapshotManager - .getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined) - .filter((entry) => this.isProviderVisibleToClient(entry.provider)); - - this.emit({ - type: "get_providers_snapshot_response", - payload: { - entries: this.downgradeEntryModesForClient(entries), - generatedAt: new Date().toISOString(), - requestId: msg.requestId, - }, - }); - } - - private async handleRefreshProvidersSnapshotRequest( - msg: Extract, - ): Promise { - if (msg.cwd) { - await this.providerSnapshotManager.refreshSnapshotForCwd({ - cwd: expandTilde(msg.cwd), - providers: msg.providers, - }); - } else { - await this.providerSnapshotManager.refreshSettingsSnapshot({ - providers: msg.providers, - }); - } - this.emit({ - type: "refresh_providers_snapshot_response", - payload: { - acknowledged: true, - requestId: msg.requestId, - }, - }); - } - - private async handleProviderDiagnosticRequest( - msg: Extract, - ): Promise { - try { - const { diagnostic } = await this.providerSnapshotManager.getProviderDiagnostic(msg.provider); - this.emit({ - type: "provider_diagnostic_response", - payload: { - provider: msg.provider, - diagnostic, - requestId: msg.requestId, - }, - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - this.sessionLogger.error( - { err, provider: msg.provider }, - `Failed to get provider diagnostic for ${msg.provider}`, - ); - this.emit({ - type: "rpc_error", - payload: { - requestId: msg.requestId, - requestType: msg.type, - error: `Failed to get provider diagnostic: ${err.message}`, - code: "provider_diagnostic_failed", - }, - }); - } - } - - private async handleProviderUsageListRequest( - msg: Extract, - ): Promise { - try { - const usage = await this.providerUsageService.listUsage(); - this.emit({ - type: "provider.usage.list.response", - payload: { - requestId: msg.requestId, - fetchedAt: usage.fetchedAt, - providers: usage.providers, - }, - }); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - this.sessionLogger.error({ err }, "Failed to list provider usage"); - this.emit({ - type: "rpc_error", - payload: { - requestId: msg.requestId, - requestType: msg.type, - error: `Failed to list provider usage: ${err.message}`, - code: "provider_usage_list_failed", - }, - }); - } - } - private assertSafeGitRef(ref: string, label: string): void { if (!/^[A-Za-z0-9._/-]+$/.test(ref)) { throw new Error(`Invalid ${label}: ${ref}`); @@ -4144,191 +3509,6 @@ export class Session { } } - /** - * Handle set agent mode request - */ - private async handleSetAgentModeRequest( - agentId: string, - modeId: string, - requestId: string, - ): Promise { - this.sessionLogger.info({ agentId, modeId, requestId }, "session: set_agent_mode_request"); - - try { - const result = await setAgentModeCommand( - { agentManager: this.agentManager }, - { agentId, modeId }, - ); - this.sessionLogger.info( - { agentId, modeId, requestId }, - "session: set_agent_mode_request success", - ); - this.emit({ - type: "set_agent_mode_response", - payload: { requestId, agentId, accepted: true, error: null, notice: result.notice }, - }); - } catch (error) { - this.sessionLogger.error( - { err: error, agentId, modeId, requestId }, - "session: set_agent_mode_request error", - ); - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "error", - content: `Failed to set agent mode: ${getErrorMessage(error)}`, - }, - }); - this.emit({ - type: "set_agent_mode_response", - payload: { - requestId, - agentId, - accepted: false, - error: getErrorMessageOr(error, "Failed to set agent mode"), - }, - }); - } - } - - private async handleSetAgentModelRequest( - agentId: string, - modelId: string | null, - requestId: string, - ): Promise { - this.sessionLogger.info({ agentId, modelId, requestId }, "session: set_agent_model_request"); - - try { - await this.agentManager.setAgentModel(agentId, modelId); - this.sessionLogger.info( - { agentId, modelId, requestId }, - "session: set_agent_model_request success", - ); - this.emit({ - type: "set_agent_model_response", - payload: { requestId, agentId, accepted: true, error: null }, - }); - } catch (error) { - this.sessionLogger.error( - { err: error, agentId, modelId, requestId }, - "session: set_agent_model_request error", - ); - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "error", - content: `Failed to set agent model: ${getErrorMessage(error)}`, - }, - }); - this.emit({ - type: "set_agent_model_response", - payload: { - requestId, - agentId, - accepted: false, - error: getErrorMessageOr(error, "Failed to set agent model"), - }, - }); - } - } - - private async handleSetAgentFeatureRequest( - agentId: string, - featureId: string, - value: unknown, - requestId: string, - ): Promise { - this.sessionLogger.info( - { agentId, featureId, value, requestId }, - "session: set_agent_feature_request", - ); - - try { - await this.agentManager.setAgentFeature(agentId, featureId, value); - this.sessionLogger.info( - { agentId, featureId, value, requestId }, - "session: set_agent_feature_request success", - ); - this.emit({ - type: "set_agent_feature_response", - payload: { requestId, agentId, accepted: true, error: null }, - }); - } catch (error) { - this.sessionLogger.error( - { err: error, agentId, featureId, value, requestId }, - "session: set_agent_feature_request error", - ); - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "error", - content: `Failed to set agent feature: ${getErrorMessage(error)}`, - }, - }); - this.emit({ - type: "set_agent_feature_response", - payload: { - requestId, - agentId, - accepted: false, - error: getErrorMessageOr(error, "Failed to set agent feature"), - }, - }); - } - } - - private async handleSetAgentThinkingRequest( - agentId: string, - thinkingOptionId: string | null, - requestId: string, - ): Promise { - this.sessionLogger.info( - { agentId, thinkingOptionId, requestId }, - "session: set_agent_thinking_request", - ); - - try { - const notice = await this.agentManager.setAgentThinkingOption(agentId, thinkingOptionId); - this.sessionLogger.info( - { agentId, thinkingOptionId, requestId }, - "session: set_agent_thinking_request success", - ); - this.emit({ - type: "set_agent_thinking_response", - payload: { requestId, agentId, accepted: true, error: null, notice }, - }); - } catch (error) { - this.sessionLogger.error( - { err: error, agentId, thinkingOptionId, requestId }, - "session: set_agent_thinking_request error", - ); - this.emit({ - type: "activity_log", - payload: { - id: uuidv4(), - timestamp: new Date(), - type: "error", - content: `Failed to set agent thinking option: ${getErrorMessage(error)}`, - }, - }); - this.emit({ - type: "set_agent_thinking_response", - payload: { - requestId, - agentId, - accepted: false, - error: getErrorMessageOr(error, "Failed to set agent thinking option"), - }, - }); - } - } - /** * Handle clearing agent attention flag */ @@ -4523,41 +3703,6 @@ export class Session { } } - private async handleGitHubSearchRequest( - msg: Extract, - ): Promise { - const { cwd, query, limit, kinds, requestId } = msg; - - try { - const resolvedCwd = expandTilde(cwd); - const result = await this.github.searchIssuesAndPrs({ - cwd: resolvedCwd, - query, - limit, - kinds, - }); - this.emit({ - type: "github_search_response", - payload: { - items: result.items, - githubFeaturesEnabled: result.githubFeaturesEnabled, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "github_search_response", - payload: { - items: [], - githubFeaturesEnabled: true, - error: error instanceof Error ? error.message : String(error), - requestId, - }, - }); - } - } - private async handleDirectorySuggestionsRequest(msg: DirectorySuggestionsRequest): Promise { const { query, limit, requestId, cwd, includeFiles, includeDirectories, matchMode } = msg; @@ -4756,716 +3901,6 @@ export class Session { this.workspaceGitSubscriptions.set(normalizedCwd, subscription.unsubscribe); } - private async handleCheckoutSwitchBranchRequest( - msg: Extract, - ): Promise { - const { cwd, branch, requestId } = msg; - - try { - const checkoutResult = await this.checkoutExistingBranch(cwd, branch); - this.checkoutSession.scheduleDiffRefresh(cwd); - - // Push a workspace_update immediately so the sidebar/header reflect - // the new branch name without waiting for the background git watcher. - await this.emitWorkspaceUpdateForCwd(cwd); - - this.emit({ - type: "checkout_switch_branch_response", - payload: { - cwd, - success: true, - branch, - source: checkoutResult.source, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "checkout_switch_branch_response", - payload: { - cwd, - success: false, - branch, - error: toCheckoutError(error), - requestId, - }, - }); - } - } - - private async handleCheckoutRenameBranchRequest(msg: CheckoutRenameBranchRequest): Promise { - const { cwd, branch, requestId } = msg; - const validation = validateBranchSlug(branch); - - if (!validation.valid) { - this.emit({ - type: "checkout.rename_branch.response", - payload: { - cwd, - success: false, - currentBranch: null, - error: toCheckoutError(new Error(validation.error ?? "Invalid branch name")), - requestId, - }, - }); - return; - } - - try { - const result = await this.renameCurrentBranch(cwd, branch); - await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true }); - this.checkoutSession.scheduleDiffRefresh(cwd); - this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); - - // Branch is a git fact derived per-descriptor from each workspace's own - // live git snapshot (id → cwd); the reconciliation pass re-persists the - // `branch` field per workspace from its own cwd. No cwd → ids fan-out here. - // TODO(K10): PR-binding on branch rename is deferred — see plan K10. - - // Push a workspace_update immediately so the sidebar/header reflect - // the new branch name without waiting for the background git watcher. - await this.emitWorkspaceUpdateForCwd(cwd); - - this.emit({ - type: "checkout.rename_branch.response", - payload: { - cwd, - success: true, - currentBranch: result.currentBranch, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "checkout.rename_branch.response", - payload: { - cwd, - success: false, - currentBranch: null, - error: toCheckoutError(error), - requestId, - }, - }); - } - } - - // --------------------------------------------------------------------------- - // Stash handlers - // --------------------------------------------------------------------------- - - private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:"; - - private async handleStashSaveRequest( - msg: Extract, - ): Promise { - const { cwd, requestId } = msg; - try { - const branchLabel = msg.branch?.trim() ?? ""; - const message = branchLabel - ? `${Session.PASEO_STASH_PREFIX} ${branchLabel}` - : `${Session.PASEO_STASH_PREFIX} unnamed`; - await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], { - cwd, - }); - await this.notifyGitMutation(cwd, "stash-push"); - this.checkoutSession.scheduleDiffRefresh(cwd); - this.emit({ - type: "stash_save_response", - payload: { cwd, success: true, error: null, requestId }, - }); - } catch (error) { - this.emit({ - type: "stash_save_response", - payload: { cwd, success: false, error: toCheckoutError(error), requestId }, - }); - } - } - - private async handleStashPopRequest( - msg: Extract, - ): Promise { - const { cwd, stashIndex, requestId } = msg; - try { - await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], { - cwd, - }); - await this.notifyGitMutation(cwd, "stash-pop"); - this.checkoutSession.scheduleDiffRefresh(cwd); - this.emit({ - type: "stash_pop_response", - payload: { cwd, success: true, error: null, requestId }, - }); - } catch (error) { - this.emit({ - type: "stash_pop_response", - payload: { cwd, success: false, error: toCheckoutError(error), requestId }, - }); - } - } - - private async handleStashListRequest( - msg: Extract, - ): Promise { - const { cwd, requestId } = msg; - const paseoOnly = msg.paseoOnly !== false; - try { - const entries = await this.workspaceGitService.listStashes(cwd, { paseoOnly }); - - this.emit({ - type: "stash_list_response", - payload: { cwd, entries, error: null, requestId }, - }); - } catch (error) { - this.emit({ - type: "stash_list_response", - payload: { cwd, entries: [], error: toCheckoutError(error), requestId }, - }); - } - } - - private async handleCheckoutCommitRequest( - msg: Extract, - ): Promise { - const { cwd, requestId } = msg; - - try { - let message = msg.message?.trim() ?? ""; - if (!message) { - message = await this.generateCommitMessage(cwd); - } - if (!message) { - throw new Error("Commit message is required"); - } - - await commitChanges(cwd, { - message, - addAll: msg.addAll ?? true, - }); - await this.notifyGitMutation(cwd, "commit-changes"); - this.checkoutSession.scheduleDiffRefresh(cwd); - - this.emit({ - type: "checkout_commit_response", - payload: { - cwd, - success: true, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "checkout_commit_response", - payload: { - cwd, - success: false, - error: toCheckoutError(error), - requestId, - }, - }); - } - } - - private async handleCheckoutMergeRequest( - msg: Extract, - ): Promise { - const { cwd, requestId } = msg; - - try { - const snapshot = await this.workspaceGitService.getSnapshot(cwd); - if (!snapshot.git.isGit) { - throw new Error(`Not a git repository: ${cwd}`); - } - - if (msg.requireCleanTarget) { - if (snapshot.git.isDirty) { - throw new Error("Working directory has uncommitted changes."); - } - } - - let baseRef = msg.baseRef ?? snapshot.git.baseRef; - if (!baseRef) { - throw new Error("Base branch is required for merge"); - } - if (baseRef.startsWith("origin/")) { - baseRef = baseRef.slice("origin/".length); - } - - const mutatedCwd = await mergeToBase( - cwd, - { - baseRef, - mode: msg.strategy === "squash" ? "squash" : "merge", - }, - { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot }, - ); - await Promise.all([ - this.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }), - ...(mutatedCwd !== cwd ? [this.notifyGitMutation(cwd, "merge-to-base")] : []), - ]); - this.checkoutSession.scheduleDiffRefresh(cwd); - - this.emit({ - type: "checkout_merge_response", - payload: { - cwd, - success: true, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "checkout_merge_response", - payload: { - cwd, - success: false, - error: toCheckoutError(error), - requestId, - }, - }); - } - } - - private async handleCheckoutMergeFromBaseRequest( - msg: Extract, - ): Promise { - const { cwd, requestId } = msg; - - try { - if (msg.requireCleanTarget ?? true) { - const snapshot = await this.workspaceGitService.getSnapshot(cwd); - if (snapshot.git.isDirty) { - throw new Error("Working directory has uncommitted changes."); - } - } - - await mergeFromBase(cwd, { - baseRef: msg.baseRef, - requireCleanTarget: msg.requireCleanTarget ?? true, - }); - await this.notifyGitMutation(cwd, "merge-from-base", { invalidateGithub: true }); - this.checkoutSession.scheduleDiffRefresh(cwd); - - this.emit({ - type: "checkout_merge_from_base_response", - payload: { - cwd, - success: true, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "checkout_merge_from_base_response", - payload: { - cwd, - success: false, - error: toCheckoutError(error), - requestId, - }, - }); - } - } - - private async handleCheckoutPullRequest( - msg: Extract, - ): Promise { - const { cwd, requestId } = msg; - - try { - await pullCurrentBranch(cwd); - await this.notifyGitMutation(cwd, "pull", { invalidateGithub: true }); - this.checkoutSession.scheduleDiffRefresh(cwd); - - this.emit({ - type: "checkout_pull_response", - payload: { - cwd, - success: true, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "checkout_pull_response", - payload: { - cwd, - success: false, - error: toCheckoutError(error), - requestId, - }, - }); - } - } - - private async handleCheckoutPushRequest( - msg: Extract, - ): Promise { - const { cwd, requestId } = msg; - - try { - await pushCurrentBranch(cwd); - await this.notifyGitMutation(cwd, "push", { invalidateGithub: true }); - this.emit({ - type: "checkout_push_response", - payload: { - cwd, - success: true, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "checkout_push_response", - payload: { - cwd, - success: false, - error: toCheckoutError(error), - requestId, - }, - }); - } - } - - private async handleCheckoutPrCreateRequest( - msg: Extract, - ): Promise { - const { cwd, requestId } = msg; - - try { - let title = msg.title?.trim() ?? ""; - let body = msg.body?.trim() ?? ""; - - if (!title || !body) { - const generated = await this.generatePullRequestText(cwd, msg.baseRef); - if (!title) title = generated.title; - if (!body) body = generated.body; - } - - const result = await createPullRequest( - cwd, - { - title, - body, - base: msg.baseRef, - }, - this.github, - ); - await this.notifyGitMutation(cwd, "create-pr", { invalidateGithub: true }); - - this.emit({ - type: "checkout_pr_create_response", - payload: { - cwd, - url: result.url ?? null, - number: result.number ?? null, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "checkout_pr_create_response", - payload: { - cwd, - url: null, - number: null, - error: toCheckoutError(error), - requestId, - }, - }); - } - } - - private async handleCheckoutPrMergeRequest( - msg: Extract, - ): Promise { - const { cwd, requestId } = msg; - - try { - const pullRequest = await this.resolveCurrentPullRequest(cwd, "merge", { - force: true, - includeGitHub: true, - reason: "merge-pr-validation", - }); - this.assertCurrentPullRequestHasGithubMergeFacts(pullRequest); - await this.github.mergePullRequest({ - cwd, - prNumber: pullRequest.number, - mergeMethod: msg.mergeMethod, - status: pullRequest, - }); - await this.notifyGitMutation(cwd, "merge-pr", { invalidateGithub: true }); - - this.emit({ - type: "checkout_pr_merge_response", - payload: { - cwd, - success: true, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "checkout_pr_merge_response", - payload: { - cwd, - success: false, - error: toCheckoutError(error), - requestId, - }, - }); - } - } - - private assertCurrentPullRequestHasGithubMergeFacts( - pullRequest: CurrentWorkspacePullRequest, - ): void { - if (!pullRequest.github) { - throw new Error("GitHub merge facts are unavailable for this pull request"); - } - } - - private async handleCheckoutGithubSetAutoMergeRequest( - msg: Extract, - ): Promise { - const { cwd, requestId } = msg; - - try { - const pullRequest = await this.resolveCurrentPullRequest(cwd, "auto-merge", { - force: true, - includeGitHub: true, - reason: "auto-merge-validation", - }); - if (msg.enabled) { - const mergeMethod = msg.mergeMethod; - if (!mergeMethod) { - throw new Error("mergeMethod is required when enabling auto-merge"); - } - assertPullRequestAutoMergeEnableReady({ - mergeMethod, - status: pullRequest, - }); - await this.github.enablePullRequestAutoMerge({ - cwd, - prNumber: pullRequest.number, - mergeMethod, - status: pullRequest, - }); - } else { - if (msg.mergeMethod) { - throw new Error("mergeMethod is not allowed when disabling auto-merge"); - } - assertPullRequestAutoMergeDisableReady({ status: pullRequest }); - await this.github.disablePullRequestAutoMerge({ - cwd, - prNumber: pullRequest.number, - status: pullRequest, - }); - } - await this.notifyGitMutation( - cwd, - msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge", - { - invalidateGithub: true, - }, - ); - - this.emit({ - type: "checkout.github.set_auto_merge.response", - payload: { - cwd, - enabled: msg.enabled, - success: true, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "checkout.github.set_auto_merge.response", - payload: { - cwd, - enabled: msg.enabled, - success: false, - error: toCheckoutError(error), - requestId, - }, - }); - } - } - - private async resolveCurrentPullRequest( - cwd: string, - operation: "merge" | "auto-merge", - options?: WorkspaceGitSnapshotOptions, - ): Promise { - const snapshot = await this.workspaceGitService.getSnapshot(cwd, options); - const pullRequest = snapshot.github.pullRequest; - if (!pullRequest || typeof pullRequest.number !== "number") { - throw new Error(`Unable to determine GitHub pull request number for ${operation}`); - } - return { ...pullRequest, number: pullRequest.number }; - } - - private async handleCheckoutPrStatusRequest( - msg: Extract, - ): Promise { - const { cwd, requestId } = msg; - - try { - const snapshot = await this.workspaceGitService.getSnapshot(cwd); - this.emit({ - type: "checkout_pr_status_response", - payload: buildCheckoutPrStatusPayloadFromSnapshot({ - cwd, - requestId, - snapshot, - }), - }); - } catch (error) { - this.emit({ - type: "checkout_pr_status_response", - payload: { - cwd, - status: null, - githubFeaturesEnabled: true, - error: toCheckoutError(error), - requestId, - }, - }); - } - } - - private async handlePullRequestTimelineRequest( - msg: Extract, - ): Promise { - const { cwd, prNumber, repoOwner, repoName, requestId } = msg; - - if (!isValidPullRequestTimelineIdentity({ prNumber, repoOwner, repoName })) { - this.emit({ - type: "pull_request_timeline_response", - payload: { - cwd, - prNumber, - items: [], - truncated: false, - error: { - kind: "unknown", - message: "Pull request timeline request has invalid PR identity", - }, - requestId, - githubFeaturesEnabled: true, - }, - }); - return; - } - - const githubFeaturesEnabled = await this.github.isAuthenticated({ cwd }); - if (!githubFeaturesEnabled) { - this.emit({ - type: "pull_request_timeline_response", - payload: { - cwd, - prNumber, - items: [], - truncated: false, - error: { - kind: "unknown", - message: "GitHub CLI is unavailable or not authenticated", - }, - requestId, - githubFeaturesEnabled: false, - }, - }); - return; - } - - try { - const timeline = await this.github.getPullRequestTimeline({ - cwd, - prNumber, - repoOwner, - repoName, - }); - this.emit({ - type: "pull_request_timeline_response", - payload: { - cwd, - prNumber: timeline.prNumber, - items: timeline.items.map(toPullRequestTimelinePayloadItem), - truncated: timeline.truncated, - error: timeline.error, - requestId, - githubFeaturesEnabled: true, - }, - }); - } catch (error) { - this.emit({ - type: "pull_request_timeline_response", - payload: { - cwd, - prNumber, - items: [], - truncated: false, - error: { - kind: "unknown", - message: error instanceof Error ? error.message : String(error), - }, - requestId, - githubFeaturesEnabled: true, - }, - }); - } - } - - private async handleCheckoutGithubGetCheckDetailsRequest( - msg: Extract, - ): Promise { - const { cwd, repoOwner, repoName, checkRunId, workflowRunId, requestId } = msg; - - try { - const details = await this.github.getGitHubCheckDetails({ - cwd, - repoOwner, - repoName, - checkRunId, - workflowRunId, - }); - this.emit({ - type: "checkout.github.get_check_details.response", - payload: { - cwd, - success: true, - details, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "checkout.github.get_check_details.response", - payload: { - cwd, - success: false, - details: null, - error: { - code: "UNKNOWN", - message: error instanceof Error ? error.message : String(error), - }, - requestId, - }, - }); - } - } - private async handlePaseoWorktreeListRequest( msg: Extract, ): Promise { @@ -5507,238 +3942,6 @@ export class Session { ); } - /** - * Handle read-only file explorer requests scoped to a workspace cwd - */ - private async handleFileExplorerRequest(request: FileExplorerRequest): Promise { - const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request; - const cwd = workspaceCwd.trim(); - if (!cwd) { - this.emit({ - type: "file_explorer_response", - payload: { - cwd: workspaceCwd, - path: requestedPath, - mode, - directory: null, - file: null, - error: "cwd is required", - requestId, - }, - }); - return; - } - - try { - if (mode === "list") { - const directory = await listDirectoryEntries({ - root: cwd, - relativePath: requestedPath, - }); - - this.emit({ - type: "file_explorer_response", - payload: { - cwd, - path: directory.path, - mode, - directory, - file: null, - error: null, - requestId, - }, - }); - } else { - if (request.acceptBinary && this.onBinaryMessage) { - const file = await readExplorerFileBytes({ - root: cwd, - relativePath: requestedPath, - }); - - this.emitBinary( - encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileBegin, - requestId, - metadata: { - mime: file.mimeType, - size: file.size, - encoding: file.encoding, - modifiedAt: file.modifiedAt, - }, - }), - ); - this.emitBinary( - encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileChunk, - requestId, - payload: file.bytes, - }), - ); - this.emitBinary( - encodeFileTransferFrame({ - opcode: FileTransferOpcode.FileEnd, - requestId, - }), - ); - } else { - const file = await readExplorerFile({ - root: cwd, - relativePath: requestedPath, - }); - - this.emit({ - type: "file_explorer_response", - payload: { - cwd, - path: file.path, - mode, - directory: null, - file, - error: null, - requestId, - }, - }); - } - } - } catch (error) { - this.sessionLogger.error( - { err: error, cwd, path: requestedPath }, - `Failed to fulfill file explorer request for workspace ${cwd}`, - ); - this.emit({ - type: "file_explorer_response", - payload: { - cwd, - path: requestedPath, - mode, - directory: null, - file: null, - error: getErrorMessage(error), - requestId, - }, - }); - } - } - - private handleFileUploadRequest(request: FileUploadRequest): void { - this.fileUploads.beginUpload(request); - } - - private async handleFileTransferFrame(frame: FileTransferFrame): Promise { - const response = await this.fileUploads.receiveFrame(frame); - if (response) { - this.emit(response); - } - } - - /** - * Handle project icon request for a given cwd - */ - private async handleProjectIconRequest( - request: Extract, - ): Promise { - const { cwd, requestId } = request; - - try { - const icon = await getProjectIcon(cwd); - this.emit({ - type: "project_icon_response", - payload: { - cwd, - icon, - error: null, - requestId, - }, - }); - } catch (error) { - this.emit({ - type: "project_icon_response", - payload: { - cwd, - icon: null, - error: getErrorMessage(error), - requestId, - }, - }); - } - } - - /** - * Handle file download token request scoped to a workspace cwd - */ - private async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise { - const { cwd: workspaceCwd, path: requestedPath, requestId } = request; - const cwd = workspaceCwd.trim(); - if (!cwd) { - this.emit({ - type: "file_download_token_response", - payload: { - cwd: workspaceCwd, - path: requestedPath, - token: null, - fileName: null, - mimeType: null, - size: null, - error: "cwd is required", - requestId, - }, - }); - return; - } - - this.sessionLogger.debug( - { cwd, path: requestedPath }, - `Handling file download token request for workspace ${cwd} (${requestedPath})`, - ); - - try { - const info = await getDownloadableFileInfo({ - root: cwd, - relativePath: requestedPath, - }); - - const entry = this.downloadTokenStore.issueToken({ - path: info.path, - absolutePath: info.absolutePath, - fileName: info.fileName, - mimeType: info.mimeType, - size: info.size, - }); - - this.emit({ - type: "file_download_token_response", - payload: { - cwd, - path: info.path, - token: entry.token, - fileName: entry.fileName, - mimeType: entry.mimeType, - size: entry.size, - error: null, - requestId, - }, - }); - } catch (error) { - this.sessionLogger.error( - { err: error, cwd, path: requestedPath }, - `Failed to issue download token for workspace ${cwd}`, - ); - this.emit({ - type: "file_download_token_response", - payload: { - cwd, - path: requestedPath, - token: null, - fileName: null, - mimeType: null, - size: null, - error: getErrorMessage(error), - requestId, - }, - }); - } - } - private async listTerminalActivityContributions(): Promise< Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }> > { @@ -8432,10 +6635,7 @@ export class Session { this.unsubscribeTerminalWorkspaceContributionEvents(); this.unsubscribeTerminalWorkspaceContributionEvents = null; } - if (this.unsubscribeProviderSnapshotEvents) { - this.unsubscribeProviderSnapshotEvents(); - this.unsubscribeProviderSnapshotEvents = null; - } + this.providerCatalogSession.dispose(); await this.voiceSession.cleanup(); @@ -8459,579 +6659,4 @@ export class Session { } this.workspaceGitSubscriptions.clear(); } - - private emitChatRpcError(request: { requestId: string; type: string }, error: unknown): void { - const message = error instanceof Error ? error.message : "Chat request failed"; - const code = error instanceof ChatServiceError ? error.code : "chat_request_failed"; - this.sessionLogger.error({ err: error, requestType: request.type }, "Chat request failed"); - this.emit({ - type: "rpc_error", - payload: { - requestId: request.requestId, - requestType: request.type, - error: message, - code, - }, - }); - } - - private async handleChatCreateRequest( - request: Extract, - ): Promise { - try { - const room = await this.chatService.createRoom({ - name: request.name, - purpose: request.purpose, - }); - this.emit({ - type: "chat/create/response", - payload: { - requestId: request.requestId, - room, - error: null, - }, - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - private async handleChatListRequest( - request: Extract, - ): Promise { - try { - const rooms = await this.chatService.listRooms(); - this.emit({ - type: "chat/list/response", - payload: { - requestId: request.requestId, - rooms, - error: null, - }, - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - private async handleChatInspectRequest( - request: Extract, - ): Promise { - try { - const result = await this.chatService.inspectRoom({ - room: request.room, - }); - this.emit({ - type: "chat/inspect/response", - payload: { - requestId: request.requestId, - room: result.room, - error: null, - }, - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - private async handleChatDeleteRequest( - request: Extract, - ): Promise { - try { - const result = await this.chatService.deleteRoom({ - room: request.room, - }); - this.emit({ - type: "chat/delete/response", - payload: { - requestId: request.requestId, - room: result.room, - error: null, - }, - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - private async handleChatPostRequest( - request: Extract, - ): Promise { - try { - const authorAgentId = request.authorAgentId?.trim() || this.clientId; - const mentionAgentIds = parseMentionAgentIds(request.body); - const storedAgents = await this.agentStorage.list(); - const liveAgents = this.agentManager.listAgents(); - const fanout = await prepareChatMentionFanout({ - authorAgentId, - mentionAgentIds, - storedAgents, - liveAgents, - listRoomPosterAgentIds: () => - this.chatService.listRoomPosterAgentIds({ room: request.room }), - }); - if (!fanout.ok) { - throw new ChatServiceError("chat_mention_fanout_limit_exceeded", fanout.error); - } - const message = await this.chatService.dispatchMessage({ - room: request.room, - authorAgentId, - body: request.body, - replyToMessageId: request.replyToMessageId, - }); - this.emit({ - type: "chat/post/response", - payload: { - requestId: request.requestId, - message, - error: null, - }, - }); - void notifyChatMentions({ - room: request.room, - authorAgentId, - body: request.body, - mentionAgentIds: message.mentionAgentIds, - logger: this.sessionLogger, - storedAgents, - liveAgents, - prepared: fanout.prepared, - resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier), - sendAgentMessage: async (agentId, text) => { - await sendPromptToAgent({ - agentManager: this.agentManager, - agentStorage: this.agentStorage, - agentId, - prompt: formatSystemNotificationPrompt(text), - unarchive: false, - logger: this.sessionLogger, - }); - }, - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - private async handleChatReadRequest( - request: Extract, - ): Promise { - try { - const messages = await this.chatService.readMessages({ - room: request.room, - limit: request.limit, - since: request.since, - authorAgentId: request.authorAgentId, - }); - this.emit({ - type: "chat/read/response", - payload: { - requestId: request.requestId, - messages, - error: null, - }, - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - private async handleChatWaitRequest( - request: Extract, - ): Promise { - try { - const messages = await this.chatService.waitForMessages({ - room: request.room, - afterMessageId: request.afterMessageId, - timeoutMs: request.timeoutMs, - }); - this.emit({ - type: "chat/wait/response", - payload: { - requestId: request.requestId, - messages, - timedOut: messages.length === 0, - error: null, - }, - }); - } catch (error) { - this.emitChatRpcError(request, error); - } - } - - private toScheduleSummary( - schedule: Awaited>, - ): Extract< - SessionOutboundMessage, - { type: "schedule/list/response" } - >["payload"]["schedules"][number] { - const { runs: _runs, ...summary } = schedule; - return summary; - } - - private emitScheduleRpcError( - request: Extract< - SessionInboundMessage, - { - type: - | "schedule/create" - | "schedule/list" - | "schedule/inspect" - | "schedule/logs" - | "schedule/pause" - | "schedule/resume" - | "schedule/delete" - | "schedule/run-once" - | "schedule/update"; - } - >, - error: unknown, - ): void { - const message = error instanceof Error ? error.message : String(error); - this.sessionLogger.error({ err: error, requestType: request.type }, "Schedule request failed"); - this.emit({ - type: "rpc_error", - payload: { - requestId: request.requestId, - requestType: request.type, - error: message, - code: "schedule_request_failed", - }, - }); - } - - private async handleScheduleCreateRequest( - request: Extract, - ): Promise { - try { - const target = - request.target.type === "self" - ? { type: "agent" as const, agentId: request.target.agentId } - : request.target; - const schedule = await this.scheduleService.create({ - prompt: request.prompt, - name: request.name, - cadence: request.cadence, - target, - maxRuns: request.maxRuns, - expiresAt: request.expiresAt, - runOnCreate: request.runOnCreate, - }); - this.emit({ - type: "schedule/create/response", - payload: { - requestId: request.requestId, - schedule: this.toScheduleSummary(schedule), - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - private async handleScheduleListRequest( - request: Extract, - ): Promise { - try { - const schedules = await this.scheduleService.list(); - this.emit({ - type: "schedule/list/response", - payload: { - requestId: request.requestId, - schedules: schedules.map((schedule) => this.toScheduleSummary(schedule)), - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - private async handleScheduleInspectRequest( - request: Extract, - ): Promise { - try { - const schedule = await this.scheduleService.inspect(request.scheduleId); - this.emit({ - type: "schedule/inspect/response", - payload: { - requestId: request.requestId, - schedule, - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - private async handleScheduleLogsRequest( - request: Extract, - ): Promise { - try { - const runs = await this.scheduleService.logs(request.scheduleId); - this.emit({ - type: "schedule/logs/response", - payload: { - requestId: request.requestId, - runs, - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - private async handleSchedulePauseRequest( - request: Extract, - ): Promise { - try { - const schedule = await this.scheduleService.pause(request.scheduleId); - this.emit({ - type: "schedule/pause/response", - payload: { - requestId: request.requestId, - schedule: this.toScheduleSummary(schedule), - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - private async handleScheduleResumeRequest( - request: Extract, - ): Promise { - try { - const schedule = await this.scheduleService.resume(request.scheduleId); - this.emit({ - type: "schedule/resume/response", - payload: { - requestId: request.requestId, - schedule: this.toScheduleSummary(schedule), - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - private async handleScheduleDeleteRequest( - request: Extract, - ): Promise { - try { - await this.scheduleService.delete(request.scheduleId); - this.emit({ - type: "schedule/delete/response", - payload: { - requestId: request.requestId, - scheduleId: request.scheduleId, - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - private async handleScheduleRunOnceRequest( - request: Extract, - ): Promise { - try { - const schedule = await this.scheduleService.runOnce(request.scheduleId); - this.emit({ - type: "schedule/run-once/response", - payload: { - requestId: request.requestId, - schedule, - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - private async handleScheduleUpdateRequest( - request: Extract, - ): Promise { - try { - const schedule = await this.scheduleService.update({ - id: request.scheduleId, - ...(request.name !== undefined ? { name: request.name } : {}), - ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), - ...(request.cadence !== undefined ? { cadence: request.cadence } : {}), - ...(request.newAgentConfig !== undefined ? { newAgentConfig: request.newAgentConfig } : {}), - ...(request.maxRuns !== undefined ? { maxRuns: request.maxRuns } : {}), - ...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}), - }); - this.emit({ - type: "schedule/update/response", - payload: { - requestId: request.requestId, - schedule, - error: null, - }, - }); - } catch (error) { - this.emitScheduleRpcError(request, error); - } - } - - private emitLoopRpcError( - request: Extract< - SessionInboundMessage, - { - type: "loop/run" | "loop/list" | "loop/inspect" | "loop/logs" | "loop/stop"; - } - >, - error: unknown, - ): void { - const message = error instanceof Error ? error.message : String(error); - this.sessionLogger.error({ err: error, requestType: request.type }, "Loop request failed"); - this.emit({ - type: "rpc_error", - payload: { - requestId: request.requestId, - requestType: request.type, - error: message, - code: "loop_request_failed", - }, - }); - } - - private async handleLoopRunRequest( - request: Extract, - ): Promise { - try { - const loop = await this.loopService.runLoop({ - prompt: request.prompt, - cwd: request.cwd, - provider: request.provider, - model: request.model, - modeId: request.modeId, - workerProvider: request.workerProvider, - workerModel: request.workerModel, - verifierProvider: request.verifierProvider, - verifierModel: request.verifierModel, - verifierModeId: request.verifierModeId, - verifyPrompt: request.verifyPrompt, - verifyChecks: request.verifyChecks, - archive: request.archive, - name: request.name, - sleepMs: request.sleepMs, - maxIterations: request.maxIterations, - maxTimeMs: request.maxTimeMs, - }); - this.emit({ - type: "loop/run/response", - payload: { - requestId: request.requestId, - loop, - error: null, - }, - }); - } catch (error) { - this.emitLoopRpcError(request, error); - } - } - - private async handleLoopListRequest( - request: Extract, - ): Promise { - try { - const loops = await this.loopService.listLoops(); - this.emit({ - type: "loop/list/response", - payload: { - requestId: request.requestId, - loops, - error: null, - }, - }); - } catch (error) { - this.emitLoopRpcError(request, error); - } - } - - private async handleLoopInspectRequest( - request: Extract, - ): Promise { - try { - const loop = await this.loopService.inspectLoop(request.id); - this.emit({ - type: "loop/inspect/response", - payload: { - requestId: request.requestId, - loop, - error: null, - }, - }); - } catch (error) { - this.emitLoopRpcError(request, error); - } - } - - private async handleLoopLogsRequest( - request: Extract, - ): Promise { - try { - const result = await this.loopService.getLoopLogs(request.id, request.afterSeq ?? 0); - this.emit({ - type: "loop/logs/response", - payload: { - requestId: request.requestId, - loop: result.loop, - entries: result.entries, - nextCursor: result.nextCursor, - error: null, - }, - }); - } catch (error) { - this.emitLoopRpcError(request, error); - } - } - - private async handleLoopStopRequest( - request: Extract, - ): Promise { - try { - const loop = await this.loopService.stopLoop(request.id); - this.emit({ - type: "loop/stop/response", - payload: { - requestId: request.requestId, - loop, - error: null, - }, - }); - } catch (error) { - this.emitLoopRpcError(request, error); - } - } -} - -function isValidPullRequestTimelineIdentity(options: { - prNumber: number; - repoOwner: string; - repoName: string; -}): boolean { - if (!Number.isInteger(options.prNumber) || options.prNumber <= 0) { - return false; - } - return isValidGitHubRepoSegment(options.repoOwner) && isValidGitHubRepoSegment(options.repoName); -} - -function isValidGitHubRepoSegment(value: string): boolean { - return /^[A-Za-z0-9._-]+$/.test(value); -} - -function toPullRequestTimelinePayloadItem( - item: PullRequestTimelineItem, -): PullRequestTimelinePayloadItem { - return item; } diff --git a/packages/server/src/server/session/agent-config/agent-config-session.test.ts b/packages/server/src/server/session/agent-config/agent-config-session.test.ts new file mode 100644 index 000000000..d9ae7470c --- /dev/null +++ b/packages/server/src/server/session/agent-config/agent-config-session.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, test } from "vitest"; +import pino from "pino"; +import { + AgentConfigSession, + type AgentConfigOperations, + type AgentConfigSessionHost, +} from "./agent-config-session.js"; +import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js"; +import type { SessionOutboundMessage } from "../../messages.js"; + +class FakeAgentConfigOperations implements AgentConfigOperations { + readonly modeCalls: Array<{ agentId: string; modeId: string }> = []; + readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = []; + readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = []; + readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = []; + modeNotice: AgentProviderNotice | null = null; + thinkingNotice: AgentProviderNotice | null = null; + failWith: Error | null = null; + + async setMode(agentId: string, modeId: string): Promise { + this.modeCalls.push({ agentId, modeId }); + if (this.failWith) throw this.failWith; + return this.modeNotice; + } + + async setModel(agentId: string, modelId: string | null): Promise { + this.modelCalls.push({ agentId, modelId }); + if (this.failWith) throw this.failWith; + } + + async setFeature(agentId: string, featureId: string, value: unknown): Promise { + this.featureCalls.push({ agentId, featureId, value }); + if (this.failWith) throw this.failWith; + } + + async setThinking( + agentId: string, + thinkingOptionId: string | null, + ): Promise { + this.thinkingCalls.push({ agentId, thinkingOptionId }); + if (this.failWith) throw this.failWith; + return this.thinkingNotice; + } +} + +function makeSubsystem() { + const emitted: SessionOutboundMessage[] = []; + const operations = new FakeAgentConfigOperations(); + const host: AgentConfigSessionHost = { emit: (msg) => emitted.push(msg) }; + const subsystem = new AgentConfigSession({ + host, + operations, + logger: pino({ level: "silent" }), + }); + return { subsystem, emitted, operations }; +} + +describe("AgentConfigSession", () => { + test("set mode: forwards the args and emits an accepted response carrying the notice", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.modeNotice = { type: "info", message: "Switched to plan mode" }; + + await subsystem.handleSetAgentModeRequest({ + type: "set_agent_mode_request", + agentId: "agent-1", + modeId: "plan", + requestId: "req-1", + }); + + expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]); + expect(emitted).toEqual([ + { + type: "set_agent_mode_response", + payload: { + requestId: "req-1", + agentId: "agent-1", + accepted: true, + error: null, + notice: { type: "info", message: "Switched to plan mode" }, + }, + }, + ]); + }); + + test("set mode: a failed mutation emits the activity_log error frame before the rejected response", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.failWith = new Error("mode boom"); + + await subsystem.handleSetAgentModeRequest({ + type: "set_agent_mode_request", + agentId: "agent-1", + modeId: "plan", + requestId: "req-1", + }); + + expect(emitted.map((m) => m.type)).toEqual(["activity_log", "set_agent_mode_response"]); + expect(emitted[0]).toEqual({ + type: "activity_log", + payload: { + id: expect.any(String), + timestamp: expect.any(Date), + type: "error", + content: "Failed to set agent mode: mode boom", + }, + }); + expect(emitted[1]).toEqual({ + type: "set_agent_mode_response", + payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "mode boom" }, + }); + }); + + test("set model: emits an accepted response with no notice", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + + await subsystem.handleSetAgentModelRequest({ + type: "set_agent_model_request", + agentId: "agent-1", + modelId: "claude-opus-4-8", + requestId: "req-1", + }); + + expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: "claude-opus-4-8" }]); + expect(emitted).toEqual([ + { + type: "set_agent_model_response", + payload: { requestId: "req-1", agentId: "agent-1", accepted: true, error: null }, + }, + ]); + }); + + test("set model: a failed mutation reports the model-specific failure text", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.failWith = new Error("model boom"); + + await subsystem.handleSetAgentModelRequest({ + type: "set_agent_model_request", + agentId: "agent-1", + modelId: "claude-opus-4-8", + requestId: "req-1", + }); + + expect(emitted.map((m) => m.type)).toEqual(["activity_log", "set_agent_model_response"]); + expect(emitted[0]).toEqual({ + type: "activity_log", + payload: { + id: expect.any(String), + timestamp: expect.any(Date), + type: "error", + content: "Failed to set agent model: model boom", + }, + }); + expect(emitted[1]).toEqual({ + type: "set_agent_model_response", + payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "model boom" }, + }); + }); + + test("set feature: forwards the feature value and emits an accepted response with no notice", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + + await subsystem.handleSetAgentFeatureRequest({ + type: "set_agent_feature_request", + agentId: "agent-1", + featureId: "web_search", + value: true, + requestId: "req-1", + }); + + expect(operations.featureCalls).toEqual([ + { agentId: "agent-1", featureId: "web_search", value: true }, + ]); + expect(emitted).toEqual([ + { + type: "set_agent_feature_response", + payload: { requestId: "req-1", agentId: "agent-1", accepted: true, error: null }, + }, + ]); + }); + + test("set feature: a failed mutation reports the feature-specific failure text", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.failWith = new Error("feature boom"); + + await subsystem.handleSetAgentFeatureRequest({ + type: "set_agent_feature_request", + agentId: "agent-1", + featureId: "web_search", + value: true, + requestId: "req-1", + }); + + expect(emitted.map((m) => m.type)).toEqual(["activity_log", "set_agent_feature_response"]); + expect(emitted[0]).toEqual({ + type: "activity_log", + payload: { + id: expect.any(String), + timestamp: expect.any(Date), + type: "error", + content: "Failed to set agent feature: feature boom", + }, + }); + expect(emitted[1]).toEqual({ + type: "set_agent_feature_response", + payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "feature boom" }, + }); + }); + + test("set thinking: forwards the args and emits an accepted response carrying the notice", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.thinkingNotice = { type: "warning", message: "Thinking budget reduced" }; + + await subsystem.handleSetAgentThinkingRequest({ + type: "set_agent_thinking_request", + agentId: "agent-1", + thinkingOptionId: "high", + requestId: "req-1", + }); + + expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]); + expect(emitted).toEqual([ + { + type: "set_agent_thinking_response", + payload: { + requestId: "req-1", + agentId: "agent-1", + accepted: true, + error: null, + notice: { type: "warning", message: "Thinking budget reduced" }, + }, + }, + ]); + }); + + test("set thinking: a failed mutation reports the thinking-specific failure text", async () => { + const { subsystem, emitted, operations } = makeSubsystem(); + operations.failWith = new Error("thinking boom"); + + await subsystem.handleSetAgentThinkingRequest({ + type: "set_agent_thinking_request", + agentId: "agent-1", + thinkingOptionId: "high", + requestId: "req-1", + }); + + expect(emitted.map((m) => m.type)).toEqual(["activity_log", "set_agent_thinking_response"]); + expect(emitted[0]).toEqual({ + type: "activity_log", + payload: { + id: expect.any(String), + timestamp: expect.any(Date), + type: "error", + content: "Failed to set agent thinking option: thinking boom", + }, + }); + expect(emitted[1]).toEqual({ + type: "set_agent_thinking_response", + payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "thinking boom" }, + }); + }); +}); diff --git a/packages/server/src/server/session/agent-config/agent-config-session.ts b/packages/server/src/server/session/agent-config/agent-config-session.ts new file mode 100644 index 000000000..ef8630c57 --- /dev/null +++ b/packages/server/src/server/session/agent-config/agent-config-session.ts @@ -0,0 +1,163 @@ +import type pino from "pino"; +import { v4 as uuidv4 } from "uuid"; +import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; +import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; + +/** + * The four agent-config response messages share one payload shape; deriving the + * type keeps it pinned to the protocol schema instead of restating it. + */ +type AgentActionResponsePayload = Extract< + SessionOutboundMessage, + { type: "set_agent_mode_response" } +>["payload"]; + +export interface AgentConfigSessionHost { + emit(msg: SessionOutboundMessage): void; +} + +/** + * The per-agent config mutations this subsystem drives. The shell adapts these + * onto the live AgentManager (mode still routes through setAgentModeCommand); + * tests wire an in-memory fake. Mode and thinking yield a provider notice; model + * and feature do not. + */ +export interface AgentConfigOperations { + setMode(agentId: string, modeId: string): Promise; + setModel(agentId: string, modelId: string | null): Promise; + setFeature(agentId: string, featureId: string, value: unknown): Promise; + setThinking( + agentId: string, + thinkingOptionId: string | null, + ): Promise; +} + +export interface AgentConfigSessionOptions { + host: AgentConfigSessionHost; + operations: AgentConfigOperations; + logger: pino.Logger; +} + +interface ConfigChange { + agentId: string; + requestId: string; + logLabel: string; + logFields: Record; + failureText: string; + run: () => Promise; + emitResponse: (payload: AgentActionResponsePayload) => void; +} + +/** + * A client's per-agent config surface: set mode, model, feature, and thinking + * option. Each request shares one envelope — log, run the mutation, then emit the + * accepted response, or on failure emit an activity_log error frame followed by + * the rejected response. Reaches no state beyond the injected operations and the + * outbound channel. + */ +export class AgentConfigSession { + private readonly host: AgentConfigSessionHost; + private readonly operations: AgentConfigOperations; + private readonly logger: pino.Logger; + + constructor(options: AgentConfigSessionOptions) { + this.host = options.host; + this.operations = options.operations; + this.logger = options.logger; + } + + handleSetAgentModeRequest( + msg: Extract, + ): Promise { + const { agentId, modeId, requestId } = msg; + return this.applyConfigChange({ + agentId, + requestId, + logLabel: "set_agent_mode_request", + logFields: { agentId, modeId, requestId }, + failureText: "Failed to set agent mode", + run: () => this.operations.setMode(agentId, modeId), + emitResponse: (payload) => this.host.emit({ type: "set_agent_mode_response", payload }), + }); + } + + handleSetAgentModelRequest( + msg: Extract, + ): Promise { + const { agentId, modelId, requestId } = msg; + return this.applyConfigChange({ + agentId, + requestId, + logLabel: "set_agent_model_request", + logFields: { agentId, modelId, requestId }, + failureText: "Failed to set agent model", + run: async () => { + await this.operations.setModel(agentId, modelId); + return undefined; + }, + emitResponse: (payload) => this.host.emit({ type: "set_agent_model_response", payload }), + }); + } + + handleSetAgentFeatureRequest( + msg: Extract, + ): Promise { + const { agentId, featureId, value, requestId } = msg; + return this.applyConfigChange({ + agentId, + requestId, + logLabel: "set_agent_feature_request", + logFields: { agentId, featureId, value, requestId }, + failureText: "Failed to set agent feature", + run: async () => { + await this.operations.setFeature(agentId, featureId, value); + return undefined; + }, + emitResponse: (payload) => this.host.emit({ type: "set_agent_feature_response", payload }), + }); + } + + handleSetAgentThinkingRequest( + msg: Extract, + ): Promise { + const { agentId, thinkingOptionId, requestId } = msg; + return this.applyConfigChange({ + agentId, + requestId, + logLabel: "set_agent_thinking_request", + logFields: { agentId, thinkingOptionId, requestId }, + failureText: "Failed to set agent thinking option", + run: () => this.operations.setThinking(agentId, thinkingOptionId), + emitResponse: (payload) => this.host.emit({ type: "set_agent_thinking_response", payload }), + }); + } + + private async applyConfigChange(change: ConfigChange): Promise { + const { agentId, requestId, logLabel, logFields, failureText, run, emitResponse } = change; + this.logger.info(logFields, `session: ${logLabel}`); + + try { + const notice = await run(); + this.logger.info(logFields, `session: ${logLabel} success`); + emitResponse({ requestId, agentId, accepted: true, error: null, notice }); + } catch (error) { + this.logger.error({ err: error, ...logFields }, `session: ${logLabel} error`); + this.host.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `${failureText}: ${getErrorMessage(error)}`, + }, + }); + emitResponse({ + requestId, + agentId, + accepted: false, + error: getErrorMessageOr(error, failureText), + }); + } + } +} diff --git a/packages/server/src/server/session/chat/chat-schedule-loop-session.test.ts b/packages/server/src/server/session/chat/chat-schedule-loop-session.test.ts new file mode 100644 index 000000000..a121d967e --- /dev/null +++ b/packages/server/src/server/session/chat/chat-schedule-loop-session.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import pino from "pino"; +import { + ChatScheduleLoopSession, + type ChatScheduleLoopSessionHost, +} from "./chat-schedule-loop-session.js"; +import { createStub } from "../../test-utils/class-mocks.js"; +import { findByType } from "../../test-utils/session-stubs.js"; +import type { SessionOutboundMessage } from "../../messages.js"; +import type { FileBackedChatService } from "../../chat/chat-service.js"; +import type { ScheduleService } from "../../schedule/service.js"; +import type { LoopService } from "../../loop-service.js"; + +type ChatMessageFixture = Awaited>; + +interface MakeOptions { + chat?: { [K in keyof FileBackedChatService]?: unknown }; + schedule?: { [K in keyof ScheduleService]?: unknown }; + loop?: { [K in keyof LoopService]?: unknown }; + host?: Partial; +} + +function makeSubsystem(options: MakeOptions = {}) { + const emitted: SessionOutboundMessage[] = []; + const sentAgentMessages: Array<{ agentId: string; text: string }> = []; + let onSend: (() => void) | null = null; + const host: ChatScheduleLoopSessionHost = { + emit: (msg) => emitted.push(msg), + listStoredAgents: async () => [], + listLiveAgents: () => [], + resolveAgentIdentifier: async (identifier) => ({ ok: true, agentId: identifier }), + sendAgentMessage: async (agentId, text) => { + sentAgentMessages.push({ agentId, text }); + onSend?.(); + }, + ...options.host, + }; + const subsystem = new ChatScheduleLoopSession({ + host, + chatService: createStub(options.chat ?? {}), + scheduleService: createStub(options.schedule ?? {}), + loopService: createStub(options.loop ?? {}), + clientId: "client-1", + logger: pino({ level: "silent" }), + }); + // notifyChatMentions is fire-and-forget; arm the signal before dispatching so the + // mentioned-agent send is observed deterministically without polling. + function waitForSend(): Promise { + return new Promise((resolve) => { + onSend = resolve; + }); + } + return { subsystem, emitted, sentAgentMessages, waitForSend }; +} + +describe("ChatScheduleLoopSession", () => { + it("chat/post emits the stored message and does not fan out without mentions", async () => { + const message: ChatMessageFixture = { + id: "m1", + roomId: "r1", + authorAgentId: "client-1", + body: "hello", + replyToMessageId: null, + mentionAgentIds: [], + createdAt: "2026-01-01T00:00:00.000Z", + }; + const { subsystem, emitted, sentAgentMessages } = makeSubsystem({ + chat: { dispatchMessage: async () => message, listRoomPosterAgentIds: async () => [] }, + }); + + await subsystem.handleChatPostRequest({ + type: "chat/post", + requestId: "p1", + room: "r1", + body: "hello", + }); + + const res = findByType(emitted, "chat/post/response"); + expect(res?.payload.message).toEqual(message); + expect(res?.payload.error).toBeNull(); + expect(sentAgentMessages).toEqual([]); + }); + + it("chat/post notifies a mentioned agent through the host send seam", async () => { + const message: ChatMessageFixture = { + id: "m2", + roomId: "r1", + authorAgentId: "client-1", + body: "@agent-2 ping", + replyToMessageId: null, + mentionAgentIds: ["agent-2"], + createdAt: "2026-01-01T00:00:00.000Z", + }; + const { subsystem, emitted, sentAgentMessages, waitForSend } = makeSubsystem({ + chat: { dispatchMessage: async () => message, listRoomPosterAgentIds: async () => [] }, + }); + + const sent = waitForSend(); + await subsystem.handleChatPostRequest({ + type: "chat/post", + requestId: "p2", + room: "r1", + body: "@agent-2 ping", + }); + await sent; + + expect(findByType(emitted, "chat/post/response")?.payload.error).toBeNull(); + expect(sentAgentMessages).toHaveLength(1); + expect(sentAgentMessages[0]?.agentId).toBe("agent-2"); + expect(sentAgentMessages[0]?.text).toContain('in room "r1"'); + }); + + it("chat/post rejects @everyone past the fanout limit with the chat error code", async () => { + const posters = Array.from({ length: 26 }, (_, i) => `poster-${i}`); + const { subsystem, emitted } = makeSubsystem({ + chat: { listRoomPosterAgentIds: async () => posters }, + }); + + await subsystem.handleChatPostRequest({ + type: "chat/post", + requestId: "p3", + room: "r1", + body: "@everyone go", + }); + + const err = findByType(emitted, "rpc_error"); + expect(err?.payload.code).toBe("chat_mention_fanout_limit_exceeded"); + expect(err?.payload.requestId).toBe("p3"); + }); + + it("schedule/create returns a summary with the runs stripped", async () => { + const stored = { + id: "s1", + name: null, + prompt: "p", + cadence: { type: "every" as const, everyMs: 1000 }, + target: { type: "agent" as const, agentId: "a" }, + status: "active" as const, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [ + { + id: "run-1", + scheduledFor: "2026-01-01T00:00:00.000Z", + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: null, + status: "running" as const, + agentId: null, + output: null, + error: null, + }, + ], + }; + const { subsystem, emitted } = makeSubsystem({ schedule: { create: async () => stored } }); + + await subsystem.handleScheduleCreateRequest({ + type: "schedule/create", + requestId: "sc1", + prompt: "p", + cadence: { type: "every", everyMs: 1000 }, + target: { type: "agent", agentId: "a" }, + }); + + const res = findByType(emitted, "schedule/create/response"); + expect(res?.payload.schedule).toBeDefined(); + expect(res?.payload.schedule).not.toHaveProperty("runs"); + expect(res?.payload.schedule.id).toBe("s1"); + }); + + it("schedule/create remaps a self target to an agent target before creating", async () => { + let received: Parameters[0] | undefined; + const stored = { + id: "s2", + name: null, + prompt: "p", + cadence: { type: "every" as const, everyMs: 1000 }, + target: { type: "agent" as const, agentId: "agent-9" }, + status: "active" as const, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + nextRunAt: null, + lastRunAt: null, + pausedAt: null, + expiresAt: null, + maxRuns: null, + runs: [], + }; + const { subsystem, emitted } = makeSubsystem({ + schedule: { + create: async (input: Parameters[0]) => { + received = input; + return stored; + }, + }, + }); + + await subsystem.handleScheduleCreateRequest({ + type: "schedule/create", + requestId: "sc2", + prompt: "p", + cadence: { type: "every", everyMs: 1000 }, + target: { type: "self", agentId: "agent-9" }, + }); + + expect(received?.target).toEqual({ type: "agent", agentId: "agent-9" }); + expect(findByType(emitted, "schedule/create/response")?.payload.error).toBeNull(); + }); + + it("loop/run emits the loop summary from the loop service", async () => { + const loop = { id: "loop-1", status: "running" }; + const { subsystem, emitted } = makeSubsystem({ loop: { runLoop: async () => loop } }); + + await subsystem.handleLoopRunRequest({ + type: "loop/run", + requestId: "l1", + prompt: "p", + cwd: "/tmp/loop", + }); + + const res = findByType(emitted, "loop/run/response"); + expect(res?.payload.loop).toEqual(loop); + }); +}); diff --git a/packages/server/src/server/session/chat/chat-schedule-loop-session.ts b/packages/server/src/server/session/chat/chat-schedule-loop-session.ts new file mode 100644 index 000000000..70a2417a5 --- /dev/null +++ b/packages/server/src/server/session/chat/chat-schedule-loop-session.ts @@ -0,0 +1,609 @@ +import type pino from "pino"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import type { StoredAgentRecord } from "../../agent/agent-storage.js"; +import type { ManagedAgent } from "../../agent/agent-manager.js"; +import { + ChatServiceError, + type FileBackedChatService, + parseMentionAgentIds, +} from "../../chat/chat-service.js"; +import { notifyChatMentions, prepareChatMentionFanout } from "../../chat/chat-mentions.js"; +import type { LoopService } from "../../loop-service.js"; +import type { ScheduleService } from "../../schedule/service.js"; + +/** + * The collaborators a chat command reaches that are NOT part of the chat/schedule/loop + * domain: the agent roster reads and the agent-message send used only by chat/post + * mention fanout. The Session shell owns the agent lifecycle; this subsystem orchestrates + * a notification through it but does not own it. + */ +export interface ChatScheduleLoopSessionHost { + emit(msg: SessionOutboundMessage): void; + listStoredAgents(): Promise; + listLiveAgents(): ManagedAgent[]; + resolveAgentIdentifier( + identifier: string, + ): Promise<{ ok: true; agentId: string } | { ok: false; error: string }>; + sendAgentMessage(agentId: string, text: string): Promise; +} + +export interface ChatScheduleLoopSessionOptions { + host: ChatScheduleLoopSessionHost; + chatService: FileBackedChatService; + scheduleService: ScheduleService; + loopService: LoopService; + clientId: string; + logger: pino.Logger; +} + +/** + * A client's chat, schedule, and loop request surface. The three families are the + * least-coupled in the session: each is a stateless request/response over its own + * service (chat rooms, cron routines, autonomous loops), with no shared observer, + * git, or voice state and no subscriptions to tear down. They live in one subsystem + * because they are dispatched together — schedule/* was historically reached through + * the chat dispatcher's fall-through arm. The three rpc-error emitters stay separate: + * they differ by default code, and only the chat one reads ChatServiceError.code. + */ +export class ChatScheduleLoopSession { + private readonly host: ChatScheduleLoopSessionHost; + private readonly chatService: FileBackedChatService; + private readonly scheduleService: ScheduleService; + private readonly loopService: LoopService; + private readonly clientId: string; + private readonly logger: pino.Logger; + + constructor(options: ChatScheduleLoopSessionOptions) { + this.host = options.host; + this.chatService = options.chatService; + this.scheduleService = options.scheduleService; + this.loopService = options.loopService; + this.clientId = options.clientId; + this.logger = options.logger; + } + + private emitChatRpcError(request: { requestId: string; type: string }, error: unknown): void { + const message = error instanceof Error ? error.message : "Chat request failed"; + const code = error instanceof ChatServiceError ? error.code : "chat_request_failed"; + this.logger.error({ err: error, requestType: request.type }, "Chat request failed"); + this.host.emit({ + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: message, + code, + }, + }); + } + + async handleChatCreateRequest( + request: Extract, + ): Promise { + try { + const room = await this.chatService.createRoom({ + name: request.name, + purpose: request.purpose, + }); + this.host.emit({ + type: "chat/create/response", + payload: { + requestId: request.requestId, + room, + error: null, + }, + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + async handleChatListRequest( + request: Extract, + ): Promise { + try { + const rooms = await this.chatService.listRooms(); + this.host.emit({ + type: "chat/list/response", + payload: { + requestId: request.requestId, + rooms, + error: null, + }, + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + async handleChatInspectRequest( + request: Extract, + ): Promise { + try { + const result = await this.chatService.inspectRoom({ + room: request.room, + }); + this.host.emit({ + type: "chat/inspect/response", + payload: { + requestId: request.requestId, + room: result.room, + error: null, + }, + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + async handleChatDeleteRequest( + request: Extract, + ): Promise { + try { + const result = await this.chatService.deleteRoom({ + room: request.room, + }); + this.host.emit({ + type: "chat/delete/response", + payload: { + requestId: request.requestId, + room: result.room, + error: null, + }, + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + async handleChatPostRequest( + request: Extract, + ): Promise { + try { + const authorAgentId = request.authorAgentId?.trim() || this.clientId; + const mentionAgentIds = parseMentionAgentIds(request.body); + const storedAgents = await this.host.listStoredAgents(); + const liveAgents = this.host.listLiveAgents(); + const fanout = await prepareChatMentionFanout({ + authorAgentId, + mentionAgentIds, + storedAgents, + liveAgents, + listRoomPosterAgentIds: () => + this.chatService.listRoomPosterAgentIds({ room: request.room }), + }); + if (!fanout.ok) { + throw new ChatServiceError("chat_mention_fanout_limit_exceeded", fanout.error); + } + const message = await this.chatService.dispatchMessage({ + room: request.room, + authorAgentId, + body: request.body, + replyToMessageId: request.replyToMessageId, + }); + this.host.emit({ + type: "chat/post/response", + payload: { + requestId: request.requestId, + message, + error: null, + }, + }); + void notifyChatMentions({ + room: request.room, + authorAgentId, + body: request.body, + mentionAgentIds: message.mentionAgentIds, + logger: this.logger, + storedAgents, + liveAgents, + prepared: fanout.prepared, + resolveAgentIdentifier: (identifier) => this.host.resolveAgentIdentifier(identifier), + sendAgentMessage: (agentId, text) => this.host.sendAgentMessage(agentId, text), + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + async handleChatReadRequest( + request: Extract, + ): Promise { + try { + const messages = await this.chatService.readMessages({ + room: request.room, + limit: request.limit, + since: request.since, + authorAgentId: request.authorAgentId, + }); + this.host.emit({ + type: "chat/read/response", + payload: { + requestId: request.requestId, + messages, + error: null, + }, + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + async handleChatWaitRequest( + request: Extract, + ): Promise { + try { + const messages = await this.chatService.waitForMessages({ + room: request.room, + afterMessageId: request.afterMessageId, + timeoutMs: request.timeoutMs, + }); + this.host.emit({ + type: "chat/wait/response", + payload: { + requestId: request.requestId, + messages, + timedOut: messages.length === 0, + error: null, + }, + }); + } catch (error) { + this.emitChatRpcError(request, error); + } + } + + private toScheduleSummary( + schedule: Awaited>, + ): Extract< + SessionOutboundMessage, + { type: "schedule/list/response" } + >["payload"]["schedules"][number] { + const { runs: _runs, ...summary } = schedule; + return summary; + } + + private emitScheduleRpcError( + request: Extract< + SessionInboundMessage, + { + type: + | "schedule/create" + | "schedule/list" + | "schedule/inspect" + | "schedule/logs" + | "schedule/pause" + | "schedule/resume" + | "schedule/delete" + | "schedule/run-once" + | "schedule/update"; + } + >, + error: unknown, + ): void { + const message = error instanceof Error ? error.message : String(error); + this.logger.error({ err: error, requestType: request.type }, "Schedule request failed"); + this.host.emit({ + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: message, + code: "schedule_request_failed", + }, + }); + } + + async handleScheduleCreateRequest( + request: Extract, + ): Promise { + try { + const target = + request.target.type === "self" + ? { type: "agent" as const, agentId: request.target.agentId } + : request.target; + const schedule = await this.scheduleService.create({ + prompt: request.prompt, + name: request.name, + cadence: request.cadence, + target, + maxRuns: request.maxRuns, + expiresAt: request.expiresAt, + runOnCreate: request.runOnCreate, + }); + this.host.emit({ + type: "schedule/create/response", + payload: { + requestId: request.requestId, + schedule: this.toScheduleSummary(schedule), + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleListRequest( + request: Extract, + ): Promise { + try { + const schedules = await this.scheduleService.list(); + this.host.emit({ + type: "schedule/list/response", + payload: { + requestId: request.requestId, + schedules: schedules.map((schedule) => this.toScheduleSummary(schedule)), + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleInspectRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.inspect(request.scheduleId); + this.host.emit({ + type: "schedule/inspect/response", + payload: { + requestId: request.requestId, + schedule, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleLogsRequest( + request: Extract, + ): Promise { + try { + const runs = await this.scheduleService.logs(request.scheduleId); + this.host.emit({ + type: "schedule/logs/response", + payload: { + requestId: request.requestId, + runs, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleSchedulePauseRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.pause(request.scheduleId); + this.host.emit({ + type: "schedule/pause/response", + payload: { + requestId: request.requestId, + schedule: this.toScheduleSummary(schedule), + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleResumeRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.resume(request.scheduleId); + this.host.emit({ + type: "schedule/resume/response", + payload: { + requestId: request.requestId, + schedule: this.toScheduleSummary(schedule), + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleDeleteRequest( + request: Extract, + ): Promise { + try { + await this.scheduleService.delete(request.scheduleId); + this.host.emit({ + type: "schedule/delete/response", + payload: { + requestId: request.requestId, + scheduleId: request.scheduleId, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleRunOnceRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.runOnce(request.scheduleId); + this.host.emit({ + type: "schedule/run-once/response", + payload: { + requestId: request.requestId, + schedule, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + async handleScheduleUpdateRequest( + request: Extract, + ): Promise { + try { + const schedule = await this.scheduleService.update({ + id: request.scheduleId, + ...(request.name !== undefined ? { name: request.name } : {}), + ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), + ...(request.cadence !== undefined ? { cadence: request.cadence } : {}), + ...(request.newAgentConfig !== undefined ? { newAgentConfig: request.newAgentConfig } : {}), + ...(request.maxRuns !== undefined ? { maxRuns: request.maxRuns } : {}), + ...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}), + }); + this.host.emit({ + type: "schedule/update/response", + payload: { + requestId: request.requestId, + schedule, + error: null, + }, + }); + } catch (error) { + this.emitScheduleRpcError(request, error); + } + } + + private emitLoopRpcError( + request: Extract< + SessionInboundMessage, + { + type: "loop/run" | "loop/list" | "loop/inspect" | "loop/logs" | "loop/stop"; + } + >, + error: unknown, + ): void { + const message = error instanceof Error ? error.message : String(error); + this.logger.error({ err: error, requestType: request.type }, "Loop request failed"); + this.host.emit({ + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: request.type, + error: message, + code: "loop_request_failed", + }, + }); + } + + async handleLoopRunRequest( + request: Extract, + ): Promise { + try { + const loop = await this.loopService.runLoop({ + prompt: request.prompt, + cwd: request.cwd, + provider: request.provider, + model: request.model, + modeId: request.modeId, + workerProvider: request.workerProvider, + workerModel: request.workerModel, + verifierProvider: request.verifierProvider, + verifierModel: request.verifierModel, + verifierModeId: request.verifierModeId, + verifyPrompt: request.verifyPrompt, + verifyChecks: request.verifyChecks, + archive: request.archive, + name: request.name, + sleepMs: request.sleepMs, + maxIterations: request.maxIterations, + maxTimeMs: request.maxTimeMs, + }); + this.host.emit({ + type: "loop/run/response", + payload: { + requestId: request.requestId, + loop, + error: null, + }, + }); + } catch (error) { + this.emitLoopRpcError(request, error); + } + } + + async handleLoopListRequest( + request: Extract, + ): Promise { + try { + const loops = await this.loopService.listLoops(); + this.host.emit({ + type: "loop/list/response", + payload: { + requestId: request.requestId, + loops, + error: null, + }, + }); + } catch (error) { + this.emitLoopRpcError(request, error); + } + } + + async handleLoopInspectRequest( + request: Extract, + ): Promise { + try { + const loop = await this.loopService.inspectLoop(request.id); + this.host.emit({ + type: "loop/inspect/response", + payload: { + requestId: request.requestId, + loop, + error: null, + }, + }); + } catch (error) { + this.emitLoopRpcError(request, error); + } + } + + async handleLoopLogsRequest( + request: Extract, + ): Promise { + try { + const result = await this.loopService.getLoopLogs(request.id, request.afterSeq ?? 0); + this.host.emit({ + type: "loop/logs/response", + payload: { + requestId: request.requestId, + loop: result.loop, + entries: result.entries, + nextCursor: result.nextCursor, + error: null, + }, + }); + } catch (error) { + this.emitLoopRpcError(request, error); + } + } + + async handleLoopStopRequest( + request: Extract, + ): Promise { + try { + const loop = await this.loopService.stopLoop(request.id); + this.host.emit({ + type: "loop/stop/response", + payload: { + requestId: request.requestId, + loop, + error: null, + }, + }); + } catch (error) { + this.emitLoopRpcError(request, error); + } + } +} diff --git a/packages/server/src/server/session/checkout/checkout-session.test.ts b/packages/server/src/server/session/checkout/checkout-session.test.ts index 11a8bb52f..9fcde7335 100644 --- a/packages/server/src/server/session/checkout/checkout-session.test.ts +++ b/packages/server/src/server/session/checkout/checkout-session.test.ts @@ -5,7 +5,7 @@ import { CheckoutSession, type CheckoutSessionHost, } from "./checkout-session.js"; -import { createGitHubService } from "../../../services/github-service.js"; +import { createGitHubService, type GitHubService } from "../../../services/github-service.js"; import type { SessionOutboundMessage } from "../../messages.js"; import type { CheckoutDiffCompareInput, @@ -54,24 +54,84 @@ function createFakeDiffSubscriber(initial: CheckoutDiffSnapshotPayload) { return { subscriber, subscriptions, refreshedCwds }; } +interface RecordedHostCalls { + notifyGitMutation: Array<{ + cwd: string; + reason: string; + options?: { invalidateGithub?: boolean }; + }>; + emitWorkspaceUpdateForCwd: string[]; + handleWorkspaceGitBranchSnapshot: Array<{ cwd: string; branchName: string | null }>; + renameCurrentBranch: Array<{ cwd: string; branch: string }>; + checkoutExistingBranch: Array<{ cwd: string; branch: string }>; + generateCommitMessage: string[]; + generatePullRequestText: Array<{ cwd: string; baseRef?: string }>; +} + function makeCheckoutSession(options?: { git?: Partial; diff?: CheckoutDiffSubscriber; + github?: Partial; + host?: Partial; }) { const emitted: SessionOutboundMessage[] = []; - const host: CheckoutSessionHost = { emit: (msg) => emitted.push(msg) }; + const hostCalls: RecordedHostCalls = { + notifyGitMutation: [], + emitWorkspaceUpdateForCwd: [], + handleWorkspaceGitBranchSnapshot: [], + renameCurrentBranch: [], + checkoutExistingBranch: [], + generateCommitMessage: [], + generatePullRequestText: [], + }; + const host: CheckoutSessionHost = { + emit: (msg) => emitted.push(msg), + notifyGitMutation: async (cwd, reason, opts) => { + hostCalls.notifyGitMutation.push({ cwd, reason, options: opts }); + }, + emitWorkspaceUpdateForCwd: async (cwd) => { + hostCalls.emitWorkspaceUpdateForCwd.push(cwd); + }, + handleWorkspaceGitBranchSnapshot: (cwd, branchName) => { + hostCalls.handleWorkspaceGitBranchSnapshot.push({ cwd, branchName }); + }, + renameCurrentBranch: async (cwd, branch) => { + hostCalls.renameCurrentBranch.push({ cwd, branch }); + return { previousBranch: null, currentBranch: branch }; + }, + checkoutExistingBranch: async (cwd, branch) => { + hostCalls.checkoutExistingBranch.push({ cwd, branch }); + return { source: "local" }; + }, + generateCommitMessage: async (cwd) => { + hostCalls.generateCommitMessage.push(cwd); + return ""; + }, + generatePullRequestText: async (cwd, baseRef) => { + hostCalls.generatePullRequestText.push({ cwd, baseRef }); + return { title: "", body: "" }; + }, + ...options?.host, + }; + const github: GitHubService = { ...createGitHubService(), ...options?.github }; const checkout = new CheckoutSession({ host, workspaceGitService: createNoopWorkspaceGitService(options?.git), - github: createGitHubService(), + github, checkoutDiffManager: options?.diff ?? createFakeDiffSubscriber({ cwd: "", files: [], error: null }).subscriber, + paseoHome: "/tmp/paseo-home", + worktreesRoot: undefined, logger: pino({ level: "silent" }), }); - return { checkout, emitted }; + return { checkout, emitted, hostCalls }; } -function createGitSnapshot(cwd: string, currentBranch: string): WorkspaceGitRuntimeSnapshot { +function createGitSnapshot( + cwd: string, + currentBranch: string, + overrides?: { isDirty?: boolean }, +): WorkspaceGitRuntimeSnapshot { return { cwd, git: { @@ -81,7 +141,7 @@ function createGitSnapshot(cwd: string, currentBranch: string): WorkspaceGitRunt currentBranch, remoteUrl: null, isPaseoOwnedWorktree: false, - isDirty: false, + isDirty: overrides?.isDirty ?? false, baseRef: null, aheadBehind: null, aheadOfOrigin: null, @@ -455,4 +515,315 @@ describe("CheckoutSession", () => { ]); }); }); + + describe("switch branch", () => { + it("checks out the branch, refreshes the diff and workspace, then confirms success", async () => { + const { subscriber, refreshedCwds } = createFakeDiffSubscriber({ + cwd: "", + files: [], + error: null, + }); + const { checkout, emitted, hostCalls } = makeCheckoutSession({ diff: subscriber }); + + await checkout.handleCheckoutSwitchBranchRequest({ + type: "checkout_switch_branch_request", + cwd: "/repo", + branch: "feature", + requestId: "sw1", + }); + + expect(hostCalls.checkoutExistingBranch).toEqual([{ cwd: "/repo", branch: "feature" }]); + expect(refreshedCwds).toEqual(["/repo"]); + expect(hostCalls.emitWorkspaceUpdateForCwd).toEqual(["/repo"]); + expect(emitted).toEqual([ + { + type: "checkout_switch_branch_response", + payload: { + cwd: "/repo", + success: true, + branch: "feature", + source: "local", + error: null, + requestId: "sw1", + }, + }, + ]); + }); + + it("emits an error response when the checkout fails", async () => { + const { checkout, emitted } = makeCheckoutSession({ + host: { + checkoutExistingBranch: async () => { + throw new Error("branch missing"); + }, + }, + }); + + await checkout.handleCheckoutSwitchBranchRequest({ + type: "checkout_switch_branch_request", + cwd: "/repo", + branch: "ghost", + requestId: "sw2", + }); + + expect(emitted).toEqual([ + { + type: "checkout_switch_branch_response", + payload: { + cwd: "/repo", + success: false, + branch: "ghost", + error: { code: "UNKNOWN", message: "branch missing" }, + requestId: "sw2", + }, + }, + ]); + }); + }); + + describe("rename branch", () => { + it("rejects an invalid slug without renaming", async () => { + const { checkout, emitted, hostCalls } = makeCheckoutSession(); + + await checkout.handleCheckoutRenameBranchRequest({ + type: "checkout.rename_branch.request", + cwd: "/repo", + branch: "bad branch!", + requestId: "rn1", + }); + + expect(hostCalls.renameCurrentBranch).toEqual([]); + expect(emitted).toHaveLength(1); + expect(emitted[0]).toMatchObject({ + type: "checkout.rename_branch.response", + payload: { cwd: "/repo", success: false, currentBranch: null, requestId: "rn1" }, + }); + }); + + it("renames, refreshes git state, and confirms the new branch", async () => { + const { subscriber, refreshedCwds } = createFakeDiffSubscriber({ + cwd: "", + files: [], + error: null, + }); + const { checkout, emitted, hostCalls } = makeCheckoutSession({ diff: subscriber }); + + await checkout.handleCheckoutRenameBranchRequest({ + type: "checkout.rename_branch.request", + cwd: "/repo", + branch: "feature-renamed", + requestId: "rn2", + }); + + expect(hostCalls.renameCurrentBranch).toEqual([{ cwd: "/repo", branch: "feature-renamed" }]); + expect(hostCalls.notifyGitMutation).toEqual([ + { cwd: "/repo", reason: "rename-branch", options: { invalidateGithub: true } }, + ]); + expect(refreshedCwds).toEqual(["/repo"]); + expect(hostCalls.handleWorkspaceGitBranchSnapshot).toEqual([ + { cwd: "/repo", branchName: "feature-renamed" }, + ]); + expect(hostCalls.emitWorkspaceUpdateForCwd).toEqual(["/repo"]); + expect(emitted).toEqual([ + { + type: "checkout.rename_branch.response", + payload: { + cwd: "/repo", + success: true, + currentBranch: "feature-renamed", + error: null, + requestId: "rn2", + }, + }, + ]); + }); + }); + + describe("commit", () => { + it("fails when no message is supplied and none can be generated", async () => { + const { checkout, emitted, hostCalls } = makeCheckoutSession(); + + await checkout.handleCheckoutCommitRequest({ + type: "checkout_commit_request", + cwd: "/repo", + message: "", + addAll: true, + requestId: "c1", + }); + + expect(hostCalls.generateCommitMessage).toEqual(["/repo"]); + expect(emitted).toEqual([ + { + type: "checkout_commit_response", + payload: { + cwd: "/repo", + success: false, + error: { code: "UNKNOWN", message: "Commit message is required" }, + requestId: "c1", + }, + }, + ]); + }); + }); + + describe("merge preflight", () => { + it("fails when the target is not a git repository", async () => { + const { checkout, emitted } = makeCheckoutSession({ + git: { getSnapshot: async (cwd) => createNoGitWorkspaceRuntimeSnapshot(cwd) }, + }); + + await checkout.handleCheckoutMergeRequest({ + type: "checkout_merge_request", + cwd: "/repo", + baseRef: "main", + requestId: "m1", + }); + + expect(emitted).toEqual([ + { + type: "checkout_merge_response", + payload: { + cwd: "/repo", + success: false, + error: { code: "UNKNOWN", message: "Not a git repository: /repo" }, + requestId: "m1", + }, + }, + ]); + }); + + it("fails a clean-required merge when the working tree is dirty", async () => { + const { checkout, emitted } = makeCheckoutSession({ + git: { getSnapshot: async () => createGitSnapshot("/repo", "feature", { isDirty: true }) }, + }); + + await checkout.handleCheckoutMergeRequest({ + type: "checkout_merge_request", + cwd: "/repo", + baseRef: "main", + requireCleanTarget: true, + requestId: "m2", + }); + + expect(emitted).toEqual([ + { + type: "checkout_merge_response", + payload: { + cwd: "/repo", + success: false, + error: { code: "UNKNOWN", message: "Working directory has uncommitted changes." }, + requestId: "m2", + }, + }, + ]); + }); + }); + + describe("pr merge", () => { + it("fails when no pull request number can be determined", async () => { + const { checkout, emitted } = makeCheckoutSession({ + git: { getSnapshot: async (cwd) => createGitSnapshot(cwd, "feature") }, + }); + + await checkout.handleCheckoutPrMergeRequest({ + type: "checkout_pr_merge_request", + cwd: "/repo", + mergeMethod: "merge", + requestId: "pm1", + }); + + expect(emitted).toEqual([ + { + type: "checkout_pr_merge_response", + payload: { + cwd: "/repo", + success: false, + error: { + code: "UNKNOWN", + message: "Unable to determine GitHub pull request number for merge", + }, + requestId: "pm1", + }, + }, + ]); + }); + }); + + describe("stash list", () => { + it("returns stash entries scoped to paseo stashes by default", async () => { + const listStashesCalls: Array<{ cwd: string; paseoOnly: boolean | undefined }> = []; + const { checkout, emitted } = makeCheckoutSession({ + git: { + listStashes: async (cwd, opts) => { + listStashesCalls.push({ cwd, paseoOnly: opts?.paseoOnly }); + return []; + }, + }, + }); + + await checkout.handleStashListRequest({ + type: "stash_list_request", + cwd: "/repo", + requestId: "sl1", + }); + + expect(listStashesCalls).toEqual([{ cwd: "/repo", paseoOnly: true }]); + expect(emitted).toEqual([ + { + type: "stash_list_response", + payload: { cwd: "/repo", entries: [], error: null, requestId: "sl1" }, + }, + ]); + }); + }); + + describe("pr status", () => { + it("builds a pr status response from the git snapshot", async () => { + const { checkout, emitted } = makeCheckoutSession({ + git: { getSnapshot: async (cwd) => createGitSnapshot(cwd, "main") }, + }); + + await checkout.handleCheckoutPrStatusRequest({ + type: "checkout_pr_status_request", + cwd: "/repo", + requestId: "ps1", + }); + + expect(emitted).toEqual([ + { + type: "checkout_pr_status_response", + payload: expect.objectContaining({ cwd: "/repo", requestId: "ps1" }), + }, + ]); + }); + }); + + describe("github search", () => { + it("returns search results and the github-features flag", async () => { + const { checkout, emitted } = makeCheckoutSession({ + github: { + searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: false }), + }, + }); + + await checkout.handleGitHubSearchRequest({ + type: "github_search_request", + cwd: "/repo", + query: "fix", + requestId: "gs1", + }); + + expect(emitted).toEqual([ + { + type: "github_search_response", + payload: { + items: [], + githubFeaturesEnabled: false, + error: null, + requestId: "gs1", + }, + }, + ]); + }); + }); }); diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 058bcfef6..068d0af22 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -1,9 +1,12 @@ import type pino from "pino"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; +import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; import type { BranchSuggestionsRequest, CheckoutRefreshRequest, + CheckoutRenameBranchRequest, CheckoutStatusRequest, + SessionInboundMessage, SessionOutboundMessage, SubscribeCheckoutDiffRequest, UnsubscribeCheckoutDiffRequest, @@ -21,15 +24,59 @@ import { import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService, + WorkspaceGitSnapshotOptions, } from "../../workspace-git-service.js"; import { assertSafeGitRef } from "../../worktree-session.js"; -import type { GitHubService } from "../../../services/github-service.js"; +import { + assertPullRequestAutoMergeDisableReady, + assertPullRequestAutoMergeEnableReady, + type GitHubService, + type PullRequestTimelineItem, +} from "../../../services/github-service.js"; +import { + type CheckoutExistingBranchResult, + commitChanges, + createPullRequest, + type GitMutationRefreshReason, + mergeFromBase, + mergeToBase, + pullCurrentBranch, + pushCurrentBranch, +} from "../../../utils/checkout-git.js"; +import { execCommand } from "../../../utils/spawn.js"; import { expandTilde } from "../../../utils/path.js"; +/** + * The collaborators a checkout command reaches that are NOT part of the checkout + * domain: the git-mutation refresh primitive and workspace-update emitters owned + * by the Session shell (also used by worktree/workspace creation), the injected + * branch operations, and the LLM-backed commit/PR text generators. CheckoutSession + * orchestrates them but does not own them. + */ export interface CheckoutSessionHost { emit(msg: SessionOutboundMessage): void; + notifyGitMutation( + cwd: string, + reason: GitMutationRefreshReason, + options?: { invalidateGithub?: boolean }, + ): Promise; + emitWorkspaceUpdateForCwd(cwd: string): Promise; + handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void; + renameCurrentBranch( + cwd: string, + branch: string, + ): Promise<{ previousBranch: string | null; currentBranch: string | null }>; + checkoutExistingBranch(cwd: string, branch: string): Promise; + generateCommitMessage(cwd: string): Promise; + generatePullRequestText(cwd: string, baseRef?: string): Promise<{ title: string; body: string }>; } +type CurrentWorkspacePullRequest = NonNullable< + WorkspaceGitRuntimeSnapshot["github"]["pullRequest"] +> & { + number: number; +}; + /** * The slice of CheckoutDiffManager that CheckoutSession needs: open a live diff * subscription, and nudge open subscriptions to recompute after a mutation. The @@ -48,24 +95,30 @@ export interface CheckoutSessionOptions { workspaceGitService: WorkspaceGitService; github: GitHubService; checkoutDiffManager: CheckoutDiffSubscriber; + paseoHome: string; + worktreesRoot: string | undefined; logger: pino.Logger; } /** - * The read & live-stream side of a client's checkout view: status queries, - * branch validation/suggestions, manual refresh, and the live git-diff and - * checkout-status subscriptions. + * A client's checkout view, both sides: the read & live-stream side (status + * queries, branch validation/suggestions, manual refresh, live git-diff and + * checkout-status subscriptions) and the command side (switch/rename/commit/ + * merge/pull/push/stash and the GitHub-PR operations). * - * The command operations (switch/rename/commit/merge/pull/push/stash) and the - * GitHub-PR operations still live on Session; they keep the diff in sync by - * calling scheduleDiffRefresh(), and the workspace git observer streams branch - * changes through emitStatusUpdate(). + * Command operations keep the live diff in sync by calling scheduleDiffRefresh() + * and refresh the workspace git snapshot through host.notifyGitMutation(); the + * workspace git observer streams branch changes through emitStatusUpdate(). */ export class CheckoutSession { + private static readonly PASEO_STASH_PREFIX = "paseo-auto-stash:"; + private readonly host: CheckoutSessionHost; private readonly workspaceGitService: WorkspaceGitService; private readonly github: GitHubService; private readonly checkoutDiffManager: CheckoutDiffSubscriber; + private readonly paseoHome: string; + private readonly worktreesRoot: string | undefined; private readonly logger: pino.Logger; private readonly diffSubscriptions = new Map void>(); @@ -74,6 +127,8 @@ export class CheckoutSession { this.workspaceGitService = options.workspaceGitService; this.github = options.github; this.checkoutDiffManager = options.checkoutDiffManager; + this.paseoHome = options.paseoHome; + this.worktreesRoot = options.worktreesRoot; this.logger = options.logger; } @@ -301,13 +356,755 @@ export class CheckoutSession { /** * Notify the live diff subscriptions that the working tree at `cwd` changed. - * Called by the command handlers that still live on Session after they mutate - * the repository. + * Called by the command handlers below after they mutate the repository. */ - scheduleDiffRefresh(cwd: string): void { + private scheduleDiffRefresh(cwd: string): void { this.checkoutDiffManager.scheduleRefreshForCwd(cwd); } + // --------------------------------------------------------------------------- + // Command operations (writes) and GitHub-PR operations + // --------------------------------------------------------------------------- + + async handleCheckoutSwitchBranchRequest( + msg: Extract, + ): Promise { + const { cwd, branch, requestId } = msg; + + try { + const checkoutResult = await this.host.checkoutExistingBranch(cwd, branch); + this.scheduleDiffRefresh(cwd); + + // Push a workspace_update immediately so the sidebar/header reflect + // the new branch name without waiting for the background git watcher. + await this.host.emitWorkspaceUpdateForCwd(cwd); + + this.host.emit({ + type: "checkout_switch_branch_response", + payload: { + cwd, + success: true, + branch, + source: checkoutResult.source, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "checkout_switch_branch_response", + payload: { + cwd, + success: false, + branch, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + async handleCheckoutRenameBranchRequest(msg: CheckoutRenameBranchRequest): Promise { + const { cwd, branch, requestId } = msg; + const validation = validateBranchSlug(branch); + + if (!validation.valid) { + this.host.emit({ + type: "checkout.rename_branch.response", + payload: { + cwd, + success: false, + currentBranch: null, + error: toCheckoutError(new Error(validation.error ?? "Invalid branch name")), + requestId, + }, + }); + return; + } + + try { + const result = await this.host.renameCurrentBranch(cwd, branch); + await this.host.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true }); + this.scheduleDiffRefresh(cwd); + this.host.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); + + // Branch is a git fact derived per-descriptor from each workspace's own + // live git snapshot (id → cwd); the reconciliation pass re-persists the + // `branch` field per workspace from its own cwd. No cwd → ids fan-out here. + // TODO(K10): PR-binding on branch rename is deferred — see plan K10. + + // Push a workspace_update immediately so the sidebar/header reflect + // the new branch name without waiting for the background git watcher. + await this.host.emitWorkspaceUpdateForCwd(cwd); + + this.host.emit({ + type: "checkout.rename_branch.response", + payload: { + cwd, + success: true, + currentBranch: result.currentBranch, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "checkout.rename_branch.response", + payload: { + cwd, + success: false, + currentBranch: null, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + async handleStashSaveRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + try { + const branchLabel = msg.branch?.trim() ?? ""; + const message = branchLabel + ? `${CheckoutSession.PASEO_STASH_PREFIX} ${branchLabel}` + : `${CheckoutSession.PASEO_STASH_PREFIX} unnamed`; + await execCommand("git", ["stash", "push", "--include-untracked", "-m", message], { + cwd, + }); + await this.host.notifyGitMutation(cwd, "stash-push"); + this.scheduleDiffRefresh(cwd); + this.host.emit({ + type: "stash_save_response", + payload: { cwd, success: true, error: null, requestId }, + }); + } catch (error) { + this.host.emit({ + type: "stash_save_response", + payload: { cwd, success: false, error: toCheckoutError(error), requestId }, + }); + } + } + + async handleStashPopRequest( + msg: Extract, + ): Promise { + const { cwd, stashIndex, requestId } = msg; + try { + await execCommand("git", ["stash", "pop", `stash@{${stashIndex}}`], { + cwd, + }); + await this.host.notifyGitMutation(cwd, "stash-pop"); + this.scheduleDiffRefresh(cwd); + this.host.emit({ + type: "stash_pop_response", + payload: { cwd, success: true, error: null, requestId }, + }); + } catch (error) { + this.host.emit({ + type: "stash_pop_response", + payload: { cwd, success: false, error: toCheckoutError(error), requestId }, + }); + } + } + + async handleStashListRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + const paseoOnly = msg.paseoOnly !== false; + try { + const entries = await this.workspaceGitService.listStashes(cwd, { paseoOnly }); + + this.host.emit({ + type: "stash_list_response", + payload: { cwd, entries, error: null, requestId }, + }); + } catch (error) { + this.host.emit({ + type: "stash_list_response", + payload: { cwd, entries: [], error: toCheckoutError(error), requestId }, + }); + } + } + + async handleCheckoutCommitRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + let message = msg.message?.trim() ?? ""; + if (!message) { + message = await this.host.generateCommitMessage(cwd); + } + if (!message) { + throw new Error("Commit message is required"); + } + + await commitChanges(cwd, { + message, + addAll: msg.addAll ?? true, + }); + await this.host.notifyGitMutation(cwd, "commit-changes"); + this.scheduleDiffRefresh(cwd); + + this.host.emit({ + type: "checkout_commit_response", + payload: { + cwd, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "checkout_commit_response", + payload: { + cwd, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + async handleCheckoutMergeRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + const snapshot = await this.workspaceGitService.getSnapshot(cwd); + if (!snapshot.git.isGit) { + throw new Error(`Not a git repository: ${cwd}`); + } + + if (msg.requireCleanTarget) { + if (snapshot.git.isDirty) { + throw new Error("Working directory has uncommitted changes."); + } + } + + let baseRef = msg.baseRef ?? snapshot.git.baseRef; + if (!baseRef) { + throw new Error("Base branch is required for merge"); + } + if (baseRef.startsWith("origin/")) { + baseRef = baseRef.slice("origin/".length); + } + + const mutatedCwd = await mergeToBase( + cwd, + { + baseRef, + mode: msg.strategy === "squash" ? "squash" : "merge", + }, + { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot }, + ); + await Promise.all([ + this.host.notifyGitMutation(mutatedCwd, "merge-to-base", { invalidateGithub: true }), + ...(mutatedCwd !== cwd ? [this.host.notifyGitMutation(cwd, "merge-to-base")] : []), + ]); + this.scheduleDiffRefresh(cwd); + + this.host.emit({ + type: "checkout_merge_response", + payload: { + cwd, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "checkout_merge_response", + payload: { + cwd, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + async handleCheckoutMergeFromBaseRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + if (msg.requireCleanTarget ?? true) { + const snapshot = await this.workspaceGitService.getSnapshot(cwd); + if (snapshot.git.isDirty) { + throw new Error("Working directory has uncommitted changes."); + } + } + + await mergeFromBase(cwd, { + baseRef: msg.baseRef, + requireCleanTarget: msg.requireCleanTarget ?? true, + }); + await this.host.notifyGitMutation(cwd, "merge-from-base", { invalidateGithub: true }); + this.scheduleDiffRefresh(cwd); + + this.host.emit({ + type: "checkout_merge_from_base_response", + payload: { + cwd, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "checkout_merge_from_base_response", + payload: { + cwd, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + async handleCheckoutPullRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + await pullCurrentBranch(cwd); + await this.host.notifyGitMutation(cwd, "pull", { invalidateGithub: true }); + this.scheduleDiffRefresh(cwd); + + this.host.emit({ + type: "checkout_pull_response", + payload: { + cwd, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "checkout_pull_response", + payload: { + cwd, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + async handleCheckoutPushRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + await pushCurrentBranch(cwd); + await this.host.notifyGitMutation(cwd, "push", { invalidateGithub: true }); + this.host.emit({ + type: "checkout_push_response", + payload: { + cwd, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "checkout_push_response", + payload: { + cwd, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + async handleCheckoutPrCreateRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + let title = msg.title?.trim() ?? ""; + let body = msg.body?.trim() ?? ""; + + if (!title || !body) { + const generated = await this.host.generatePullRequestText(cwd, msg.baseRef); + if (!title) title = generated.title; + if (!body) body = generated.body; + } + + const result = await createPullRequest( + cwd, + { + title, + body, + base: msg.baseRef, + }, + this.github, + ); + await this.host.notifyGitMutation(cwd, "create-pr", { invalidateGithub: true }); + + this.host.emit({ + type: "checkout_pr_create_response", + payload: { + cwd, + url: result.url ?? null, + number: result.number ?? null, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "checkout_pr_create_response", + payload: { + cwd, + url: null, + number: null, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + async handleCheckoutPrMergeRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + const pullRequest = await this.resolveCurrentPullRequest(cwd, "merge", { + force: true, + includeGitHub: true, + reason: "merge-pr-validation", + }); + this.assertCurrentPullRequestHasGithubMergeFacts(pullRequest); + await this.github.mergePullRequest({ + cwd, + prNumber: pullRequest.number, + mergeMethod: msg.mergeMethod, + status: pullRequest, + }); + await this.host.notifyGitMutation(cwd, "merge-pr", { invalidateGithub: true }); + + this.host.emit({ + type: "checkout_pr_merge_response", + payload: { + cwd, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "checkout_pr_merge_response", + payload: { + cwd, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + private assertCurrentPullRequestHasGithubMergeFacts( + pullRequest: CurrentWorkspacePullRequest, + ): void { + if (!pullRequest.github) { + throw new Error("GitHub merge facts are unavailable for this pull request"); + } + } + + async handleCheckoutGithubSetAutoMergeRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + const pullRequest = await this.resolveCurrentPullRequest(cwd, "auto-merge", { + force: true, + includeGitHub: true, + reason: "auto-merge-validation", + }); + if (msg.enabled) { + const mergeMethod = msg.mergeMethod; + if (!mergeMethod) { + throw new Error("mergeMethod is required when enabling auto-merge"); + } + assertPullRequestAutoMergeEnableReady({ + mergeMethod, + status: pullRequest, + }); + await this.github.enablePullRequestAutoMerge({ + cwd, + prNumber: pullRequest.number, + mergeMethod, + status: pullRequest, + }); + } else { + if (msg.mergeMethod) { + throw new Error("mergeMethod is not allowed when disabling auto-merge"); + } + assertPullRequestAutoMergeDisableReady({ status: pullRequest }); + await this.github.disablePullRequestAutoMerge({ + cwd, + prNumber: pullRequest.number, + status: pullRequest, + }); + } + await this.host.notifyGitMutation( + cwd, + msg.enabled ? "enable-pr-auto-merge" : "disable-pr-auto-merge", + { + invalidateGithub: true, + }, + ); + + this.host.emit({ + type: "checkout.github.set_auto_merge.response", + payload: { + cwd, + enabled: msg.enabled, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "checkout.github.set_auto_merge.response", + payload: { + cwd, + enabled: msg.enabled, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + private async resolveCurrentPullRequest( + cwd: string, + operation: "merge" | "auto-merge", + options?: WorkspaceGitSnapshotOptions, + ): Promise { + const snapshot = await this.workspaceGitService.getSnapshot(cwd, options); + const pullRequest = snapshot.github.pullRequest; + if (!pullRequest || typeof pullRequest.number !== "number") { + throw new Error(`Unable to determine GitHub pull request number for ${operation}`); + } + return { ...pullRequest, number: pullRequest.number }; + } + + async handleCheckoutPrStatusRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + const snapshot = await this.workspaceGitService.getSnapshot(cwd); + this.host.emit({ + type: "checkout_pr_status_response", + payload: buildCheckoutPrStatusPayloadFromSnapshot({ + cwd, + requestId, + snapshot, + }), + }); + } catch (error) { + this.host.emit({ + type: "checkout_pr_status_response", + payload: { + cwd, + status: null, + githubFeaturesEnabled: true, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + + async handlePullRequestTimelineRequest( + msg: Extract, + ): Promise { + const { cwd, prNumber, repoOwner, repoName, requestId } = msg; + + if (!isValidPullRequestTimelineIdentity({ prNumber, repoOwner, repoName })) { + this.host.emit({ + type: "pull_request_timeline_response", + payload: { + cwd, + prNumber, + items: [], + truncated: false, + error: { + kind: "unknown", + message: "Pull request timeline request has invalid PR identity", + }, + requestId, + githubFeaturesEnabled: true, + }, + }); + return; + } + + const githubFeaturesEnabled = await this.github.isAuthenticated({ cwd }); + if (!githubFeaturesEnabled) { + this.host.emit({ + type: "pull_request_timeline_response", + payload: { + cwd, + prNumber, + items: [], + truncated: false, + error: { + kind: "unknown", + message: "GitHub CLI is unavailable or not authenticated", + }, + requestId, + githubFeaturesEnabled: false, + }, + }); + return; + } + + try { + const timeline = await this.github.getPullRequestTimeline({ + cwd, + prNumber, + repoOwner, + repoName, + }); + this.host.emit({ + type: "pull_request_timeline_response", + payload: { + cwd, + prNumber: timeline.prNumber, + items: timeline.items.map(toPullRequestTimelinePayloadItem), + truncated: timeline.truncated, + error: timeline.error, + requestId, + githubFeaturesEnabled: true, + }, + }); + } catch (error) { + this.host.emit({ + type: "pull_request_timeline_response", + payload: { + cwd, + prNumber, + items: [], + truncated: false, + error: { + kind: "unknown", + message: error instanceof Error ? error.message : String(error), + }, + requestId, + githubFeaturesEnabled: true, + }, + }); + } + } + + async handleCheckoutGithubGetCheckDetailsRequest( + msg: Extract, + ): Promise { + const { cwd, repoOwner, repoName, checkRunId, workflowRunId, requestId } = msg; + + try { + const details = await this.github.getGitHubCheckDetails({ + cwd, + repoOwner, + repoName, + checkRunId, + workflowRunId, + }); + this.host.emit({ + type: "checkout.github.get_check_details.response", + payload: { + cwd, + success: true, + details, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "checkout.github.get_check_details.response", + payload: { + cwd, + success: false, + details: null, + error: { + code: "UNKNOWN", + message: error instanceof Error ? error.message : String(error), + }, + requestId, + }, + }); + } + } + + async handleGitHubSearchRequest( + msg: Extract, + ): Promise { + const { cwd, query, limit, kinds, requestId } = msg; + + try { + const resolvedCwd = expandTilde(cwd); + const result = await this.github.searchIssuesAndPrs({ + cwd: resolvedCwd, + query, + limit, + kinds, + }); + this.host.emit({ + type: "github_search_response", + payload: { + items: result.items, + githubFeaturesEnabled: result.githubFeaturesEnabled, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "github_search_response", + payload: { + items: [], + githubFeaturesEnabled: true, + error: error instanceof Error ? error.message : String(error), + requestId, + }, + }); + } + } + cleanup(): void { for (const unsubscribe of this.diffSubscriptions.values()) { unsubscribe(); @@ -315,3 +1112,30 @@ export class CheckoutSession { this.diffSubscriptions.clear(); } } + +type PullRequestTimelinePayload = Extract< + SessionOutboundMessage, + { type: "pull_request_timeline_response" } +>["payload"]; +type PullRequestTimelinePayloadItem = PullRequestTimelinePayload["items"][number]; + +function isValidPullRequestTimelineIdentity(options: { + prNumber: number; + repoOwner: string; + repoName: string; +}): boolean { + if (!Number.isInteger(options.prNumber) || options.prNumber <= 0) { + return false; + } + return isValidGitHubRepoSegment(options.repoOwner) && isValidGitHubRepoSegment(options.repoName); +} + +function isValidGitHubRepoSegment(value: string): boolean { + return /^[A-Za-z0-9._-]+$/.test(value); +} + +function toPullRequestTimelinePayloadItem( + item: PullRequestTimelineItem, +): PullRequestTimelinePayloadItem { + return item; +} diff --git a/packages/server/src/server/session/daemon/daemon-session.test.ts b/packages/server/src/server/session/daemon/daemon-session.test.ts new file mode 100644 index 000000000..dffae4646 --- /dev/null +++ b/packages/server/src/server/session/daemon/daemon-session.test.ts @@ -0,0 +1,171 @@ +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import pino from "pino"; +import { + DaemonSession, + type DaemonRuntimeConfig, + type DaemonSessionHost, +} from "./daemon-session.js"; +import type { ProviderAvailability } from "../../agent/agent-manager.js"; +import type { SessionOutboundMessage } from "../../messages.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeHome(): string { + const home = realpathSync(mkdtempSync(join(tmpdir(), "daemon-session-test-"))); + tempDirs.push(home); + return home; +} + +function makeSubsystem(overrides: { + serverId?: string; + daemonVersion?: string; + daemonRuntimeConfig?: DaemonRuntimeConfig; + listProviderAvailability?: () => Promise; +}) { + const emitted: SessionOutboundMessage[] = []; + const host: DaemonSessionHost = { emit: (msg) => emitted.push(msg) }; + const subsystem = new DaemonSession({ + host, + paseoHome: makeHome(), + serverId: overrides.serverId, + daemonVersion: overrides.daemonVersion, + daemonRuntimeConfig: overrides.daemonRuntimeConfig, + listProviderAvailability: overrides.listProviderAvailability ?? (async () => []), + logger: pino({ level: "silent" }), + }); + return { subsystem, emitted }; +} + +describe("DaemonSession", () => { + test("status reports identity, runtime config, and providers with errors normalized to null", async () => { + const { subsystem, emitted } = makeSubsystem({ + serverId: "srv-1", + daemonVersion: "1.2.3", + daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null }, + listProviderAvailability: async () => [ + { provider: "claude", available: true, error: null }, + { provider: "codex", available: false, error: "boom" }, + ], + }); + + await subsystem.handleGetStatusRequest({ type: "daemon.get_status.request", requestId: "s-1" }); + + expect(emitted).toEqual([ + { + type: "daemon.get_status.response", + payload: { + requestId: "s-1", + serverId: "srv-1", + version: "1.2.3", + pid: process.pid, + nodePath: process.execPath, + startedAt: null, + listen: "127.0.0.1:6767", + relay: null, + providers: [ + { provider: "claude", available: true, error: null }, + { provider: "codex", available: false, error: "boom" }, + ], + }, + }, + ]); + }); + + test("status falls back to null fields and an empty provider list when listing rejects", async () => { + const { subsystem, emitted } = makeSubsystem({ + serverId: "srv-1", + daemonVersion: "1.2.3", + daemonRuntimeConfig: { listen: "127.0.0.1:6767", relay: null }, + listProviderAvailability: async () => { + throw new Error("provider listing failed"); + }, + }); + + await subsystem.handleGetStatusRequest({ type: "daemon.get_status.request", requestId: "s-2" }); + + expect(emitted).toEqual([ + { + type: "daemon.get_status.response", + payload: { + requestId: "s-2", + serverId: "srv-1", + version: "1.2.3", + pid: process.pid, + nodePath: process.execPath, + startedAt: null, + listen: null, + relay: null, + providers: [], + }, + }, + ]); + }); + + test("pairing offer is empty when relay is disabled", async () => { + const { subsystem, emitted } = makeSubsystem({ + daemonRuntimeConfig: { + listen: "127.0.0.1:6767", + relay: { + enabled: false, + endpoint: "relay.paseo.sh:443", + publicEndpoint: "relay.paseo.sh:443", + useTls: true, + publicUseTls: true, + }, + }, + }); + + await subsystem.handleGetPairingOfferRequest({ + type: "daemon.get_pairing_offer.request", + requestId: "p-1", + }); + + expect(emitted).toEqual([ + { + type: "daemon.get_pairing_offer.response", + payload: { requestId: "p-1", url: "", qr: null, relayEnabled: false }, + }, + ]); + }); + + test("pairing offer mints a real connection URL when relay is enabled", async () => { + const { subsystem, emitted } = makeSubsystem({ + daemonRuntimeConfig: { + listen: "127.0.0.1:6767", + appBaseUrl: "https://app.example.test", + relay: { + enabled: true, + endpoint: "relay.example.test:443", + publicEndpoint: "relay.example.test:443", + useTls: true, + publicUseTls: true, + }, + }, + }); + + await subsystem.handleGetPairingOfferRequest({ + type: "daemon.get_pairing_offer.request", + requestId: "p-2", + }); + + expect(emitted).toHaveLength(1); + const message = emitted[0]; + expect(message.type).toBe("daemon.get_pairing_offer.response"); + if (message.type !== "daemon.get_pairing_offer.response") { + throw new Error("expected a pairing offer response"); + } + expect(message.payload.requestId).toBe("p-2"); + expect(message.payload.relayEnabled).toBe(true); + expect(message.payload.url.startsWith("https://app.example.test")).toBe(true); + expect(typeof message.payload.qr).toBe("string"); + }); +}); diff --git a/packages/server/src/server/session/daemon/daemon-session.ts b/packages/server/src/server/session/daemon/daemon-session.ts new file mode 100644 index 000000000..d0392f1f9 --- /dev/null +++ b/packages/server/src/server/session/daemon/daemon-session.ts @@ -0,0 +1,139 @@ +import type pino from "pino"; +import type { ProviderAvailability } from "../../agent/agent-manager.js"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import { getPidLockInfo } from "../../pid-lock.js"; +import { generateLocalPairingOffer } from "../../pairing-offer.js"; + +export interface DaemonRuntimeConfig { + listen: string | null; + appBaseUrl?: string; + relay: { + enabled: boolean; + endpoint: string; + publicEndpoint: string; + useTls: boolean; + publicUseTls: boolean; + } | null; +} + +export interface DaemonSessionHost { + emit(msg: SessionOutboundMessage): void; +} + +export interface DaemonSessionOptions { + host: DaemonSessionHost; + paseoHome: string; + serverId: string | undefined; + daemonVersion: string | undefined; + daemonRuntimeConfig: DaemonRuntimeConfig | undefined; + listProviderAvailability: () => Promise; + logger: pino.Logger; +} + +/** + * A client's read surface for the daemon process itself: its runtime status + * (pid-lock start time, listen address, relay config, provider availability) and + * a fresh local pairing offer for connecting a new client. Owns the `daemon.*` + * RPCs. Reaches no state beyond the never-mutated runtime values injected at + * construction and the outbound channel. + */ +export class DaemonSession { + private readonly host: DaemonSessionHost; + private readonly paseoHome: string; + private readonly serverId: string | undefined; + private readonly daemonVersion: string | undefined; + private readonly daemonRuntimeConfig: DaemonRuntimeConfig | undefined; + private readonly listProviderAvailability: () => Promise; + private readonly logger: pino.Logger; + + constructor(options: DaemonSessionOptions) { + this.host = options.host; + this.paseoHome = options.paseoHome; + this.serverId = options.serverId; + this.daemonVersion = options.daemonVersion; + this.daemonRuntimeConfig = options.daemonRuntimeConfig; + this.listProviderAvailability = options.listProviderAvailability; + this.logger = options.logger; + } + + async handleGetStatusRequest( + msg: Extract, + ): Promise { + try { + const pidInfo = await getPidLockInfo(this.paseoHome); + const providers = (await this.listProviderAvailability()).map((p) => ({ + provider: p.provider, + available: p.available, + error: p.error ?? null, + })); + this.host.emit({ + type: "daemon.get_status.response", + payload: { + requestId: msg.requestId, + serverId: this.serverId ?? "", + version: this.daemonVersion ?? null, + pid: process.pid, + nodePath: process.execPath, + startedAt: pidInfo?.startedAt ?? null, + listen: this.daemonRuntimeConfig?.listen ?? null, + relay: this.daemonRuntimeConfig?.relay ?? null, + providers, + }, + }); + } catch (error) { + this.logger.error({ err: error }, "Failed to handle daemon status request"); + this.host.emit({ + type: "daemon.get_status.response", + payload: { + requestId: msg.requestId, + serverId: this.serverId ?? "", + version: this.daemonVersion ?? null, + pid: process.pid, + nodePath: process.execPath, + startedAt: null, + listen: null, + relay: null, + providers: [], + }, + }); + } + } + + async handleGetPairingOfferRequest( + msg: Extract, + ): Promise { + try { + const relay = this.daemonRuntimeConfig?.relay; + const pairing = await generateLocalPairingOffer({ + paseoHome: this.paseoHome, + relayEnabled: relay?.enabled ?? true, + relayEndpoint: relay?.endpoint, + relayPublicEndpoint: relay?.publicEndpoint, + relayUseTls: relay?.useTls, + relayPublicUseTls: relay?.publicUseTls, + appBaseUrl: this.daemonRuntimeConfig?.appBaseUrl, + includeQr: true, + logger: this.logger, + }); + this.host.emit({ + type: "daemon.get_pairing_offer.response", + payload: { + requestId: msg.requestId, + url: pairing.url ?? "", + qr: pairing.qr ?? null, + relayEnabled: pairing.relayEnabled, + }, + }); + } catch (error) { + this.logger.error({ err: error }, "Failed to handle daemon pairing offer request"); + this.host.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: "daemon.get_pairing_offer.request", + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } +} diff --git a/packages/server/src/server/session/files/workspace-files-session.test.ts b/packages/server/src/server/session/files/workspace-files-session.test.ts new file mode 100644 index 000000000..cfc45c5c7 --- /dev/null +++ b/packages/server/src/server/session/files/workspace-files-session.test.ts @@ -0,0 +1,272 @@ +import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import pino from "pino"; +import { + decodeFileTransferFrame, + encodeFileTransferFrame, + FileTransferOpcode, + type FileTransferFrame, +} from "@getpaseo/protocol/binary-frames/index"; +import { + WorkspaceFilesSession, + type WorkspaceFilesSessionHost, +} from "./workspace-files-session.js"; +import { DownloadTokenStore } from "../../file-download/token-store.js"; +import type { SessionOutboundMessage } from "../../messages.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeDir(prefix: string): string { + const dir = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + tempDirs.push(dir); + return dir; +} + +function makeSubsystem(options: { hasBinaryChannel?: boolean } = {}) { + const emitted: SessionOutboundMessage[] = []; + const binary: Uint8Array[] = []; + let hasBinary = options.hasBinaryChannel ?? false; + const host: WorkspaceFilesSessionHost = { + emit: (msg) => emitted.push(msg), + emitBinary: (frame) => binary.push(frame), + hasBinaryChannel: () => hasBinary, + }; + const paseoHome = makeDir("workspace-files-home-"); + const subsystem = new WorkspaceFilesSession({ + host, + downloadTokenStore: new DownloadTokenStore({ ttlMs: 60_000 }), + paseoHome, + logger: pino({ level: "silent" }), + }); + return { + subsystem, + emitted, + binary, + paseoHome, + setHasBinary: (value: boolean) => { + hasBinary = value; + }, + }; +} + +function uploadFrame(args: Parameters[0]): FileTransferFrame { + const frame = decodeFileTransferFrame(encodeFileTransferFrame(args)); + if (!frame) { + throw new Error("Expected a file transfer frame"); + } + return frame; +} + +describe("WorkspaceFilesSession", () => { + test("lists directory entries", async () => { + const cwd = makeDir("workspace-files-list-"); + writeFileSync(join(cwd, "a.txt"), "alpha"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileExplorerRequest({ + type: "file_explorer_request", + cwd, + path: ".", + mode: "list", + requestId: "req-list", + }); + + expect(emitted).toHaveLength(1); + const message = emitted[0]; + if (message.type !== "file_explorer_response") { + throw new Error(`expected file_explorer_response, got ${message.type}`); + } + expect(message.payload.error).toBeNull(); + expect(message.payload.directory).not.toBeNull(); + }); + + test("reads file content inline when the client has no binary channel", async () => { + const cwd = makeDir("workspace-files-read-"); + writeFileSync(join(cwd, "notes.txt"), "hello world"); + const { subsystem, emitted, binary } = makeSubsystem({ hasBinaryChannel: false }); + + await subsystem.handleFileExplorerRequest({ + type: "file_explorer_request", + cwd, + path: "notes.txt", + mode: "file", + requestId: "req-read", + acceptBinary: true, + }); + + expect(binary).toEqual([]); + expect(emitted).toHaveLength(1); + const message = emitted[0]; + if (message.type !== "file_explorer_response") { + throw new Error(`expected file_explorer_response, got ${message.type}`); + } + expect(message.payload.error).toBeNull(); + expect(message.payload.file).not.toBeNull(); + }); + + test("streams binary frames when the client accepts binary and has a channel", async () => { + const cwd = makeDir("workspace-files-binary-"); + writeFileSync(join(cwd, "notes.txt"), "hello world"); + const { subsystem, emitted, binary } = makeSubsystem({ hasBinaryChannel: true }); + + await subsystem.handleFileExplorerRequest({ + type: "file_explorer_request", + cwd, + path: "notes.txt", + mode: "file", + requestId: "req-binary", + acceptBinary: true, + }); + + expect(emitted).toEqual([]); + expect(binary).toHaveLength(3); + const opcodes = binary.map((frame) => decodeFileTransferFrame(frame)?.opcode); + expect(opcodes).toEqual([ + FileTransferOpcode.FileBegin, + FileTransferOpcode.FileChunk, + FileTransferOpcode.FileEnd, + ]); + }); + + test("rejects an empty file-explorer cwd with an error envelope", async () => { + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileExplorerRequest({ + type: "file_explorer_request", + cwd: " ", + path: ".", + mode: "list", + requestId: "req-empty", + }); + + expect(emitted).toEqual([ + { + type: "file_explorer_response", + payload: expect.objectContaining({ + error: "cwd is required", + directory: null, + file: null, + requestId: "req-empty", + }), + }, + ]); + }); + + test("issues a download token for a real file", async () => { + const cwd = makeDir("workspace-files-token-"); + writeFileSync(join(cwd, "report.txt"), "hello world"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileDownloadTokenRequest({ + type: "file_download_token_request", + cwd, + path: "report.txt", + requestId: "req-token", + }); + + expect(emitted).toHaveLength(1); + const message = emitted[0]; + if (message.type !== "file_download_token_response") { + throw new Error(`expected file_download_token_response, got ${message.type}`); + } + expect(message.payload.error).toBeNull(); + expect(typeof message.payload.token).toBe("string"); + expect(message.payload.fileName).toBe("report.txt"); + expect(message.payload.size).toBe(11); + }); + + test("rejects an empty download-token cwd with an error envelope", async () => { + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleFileDownloadTokenRequest({ + type: "file_download_token_request", + cwd: "", + path: "report.txt", + requestId: "req-token-empty", + }); + + expect(emitted).toEqual([ + { + type: "file_download_token_response", + payload: expect.objectContaining({ + token: null, + error: "cwd is required", + requestId: "req-token-empty", + }), + }, + ]); + }); + + test("responds to a project icon request", async () => { + const cwd = makeDir("workspace-files-icon-"); + const { subsystem, emitted } = makeSubsystem(); + + await subsystem.handleProjectIconRequest({ + type: "project_icon_request", + cwd, + requestId: "req-icon", + }); + + expect(emitted).toHaveLength(1); + const message = emitted[0]; + if (message.type !== "project_icon_response") { + throw new Error(`expected project_icon_response, got ${message.type}`); + } + expect(message.payload.cwd).toBe(cwd); + expect(message.payload.error).toBeNull(); + }); + + test("round-trips an upload through transfer frames", async () => { + const { subsystem, emitted, paseoHome } = makeSubsystem(); + + subsystem.handleFileUploadRequest({ + type: "file.upload.request", + fileName: "notes.txt", + mimeType: "text/plain", + size: 11, + modifiedAt: "2026-05-02T00:00:00.000Z", + requestId: "req-upload", + }); + await subsystem.handleFileTransferFrame( + uploadFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId: "req-upload", + metadata: { + mime: "text/plain", + size: 11, + encoding: "binary", + modifiedAt: "2026-05-02T00:00:00.000Z", + fileName: "notes.txt", + }, + }), + ); + await subsystem.handleFileTransferFrame( + uploadFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId: "req-upload", + payload: new TextEncoder().encode("hello world"), + }), + ); + await subsystem.handleFileTransferFrame( + uploadFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-upload" }), + ); + + const message = emitted.find((entry) => entry.type === "file.upload.response"); + if (message?.type !== "file.upload.response") { + throw new Error("expected a file.upload.response message"); + } + expect(message.payload.error).toBeNull(); + expect(message.payload.file?.fileName).toBe("notes.txt"); + expect(readFileSync(join(paseoHome, "uploads", "upload_req-upload", "notes.txt"), "utf8")).toBe( + "hello world", + ); + }); +}); diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts new file mode 100644 index 000000000..c5e97ecc4 --- /dev/null +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -0,0 +1,286 @@ +import type pino from "pino"; +import { getErrorMessage } from "@getpaseo/protocol/error-utils"; +import { + encodeFileTransferFrame, + FileTransferOpcode, + type FileTransferFrame, +} from "@getpaseo/protocol/binary-frames/index"; +import type { + FileDownloadTokenRequest, + FileExplorerRequest, + FileUploadRequest, + SessionInboundMessage, + SessionOutboundMessage, +} from "../../messages.js"; +import { FileUploadStore } from "../../file-upload/index.js"; +import type { DownloadTokenStore } from "../../file-download/token-store.js"; +import { + getDownloadableFileInfo, + listDirectoryEntries, + readExplorerFile, + readExplorerFileBytes, +} from "../../file-explorer/service.js"; +import { getProjectIcon } from "../../../utils/project-icon.js"; + +/** + * What a workspace file-access request reaches outside its own domain: the + * outbound message channel (text + binary). `hasBinaryChannel` gates the + * binary file-explorer transfer path the same way the terminal subsystem does + * — old clients without a binary channel fall back to inline JSON file content. + */ +export interface WorkspaceFilesSessionHost { + emit(msg: SessionOutboundMessage): void; + emitBinary(frame: Uint8Array): void; + hasBinaryChannel(): boolean; +} + +export interface WorkspaceFilesSessionOptions { + host: WorkspaceFilesSessionHost; + downloadTokenStore: DownloadTokenStore; + paseoHome: string; + logger: pino.Logger; +} + +/** + * A client's workspace file-access surface: browsing directories, reading file + * contents (inline JSON or binary frames), receiving uploads, issuing download + * tokens, and reading project icons. It owns the upload store and reaches no + * workspace-git, registry, or subscription state — file I/O scoped to a cwd is + * the whole concern. + */ +export class WorkspaceFilesSession { + private readonly host: WorkspaceFilesSessionHost; + private readonly downloadTokenStore: DownloadTokenStore; + private readonly logger: pino.Logger; + private readonly fileUploads: FileUploadStore; + + constructor(options: WorkspaceFilesSessionOptions) { + this.host = options.host; + this.downloadTokenStore = options.downloadTokenStore; + this.logger = options.logger; + this.fileUploads = new FileUploadStore({ paseoHome: options.paseoHome }); + } + + async handleFileExplorerRequest(request: FileExplorerRequest): Promise { + const { cwd: workspaceCwd, path: requestedPath = ".", mode, requestId } = request; + const cwd = workspaceCwd.trim(); + if (!cwd) { + this.host.emit({ + type: "file_explorer_response", + payload: { + cwd: workspaceCwd, + path: requestedPath, + mode, + directory: null, + file: null, + error: "cwd is required", + requestId, + }, + }); + return; + } + + try { + if (mode === "list") { + const directory = await listDirectoryEntries({ + root: cwd, + relativePath: requestedPath, + }); + + this.host.emit({ + type: "file_explorer_response", + payload: { + cwd, + path: directory.path, + mode, + directory, + file: null, + error: null, + requestId, + }, + }); + } else { + if (request.acceptBinary && this.host.hasBinaryChannel()) { + const file = await readExplorerFileBytes({ + root: cwd, + relativePath: requestedPath, + }); + + this.host.emitBinary( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileBegin, + requestId, + metadata: { + mime: file.mimeType, + size: file.size, + encoding: file.encoding, + modifiedAt: file.modifiedAt, + }, + }), + ); + this.host.emitBinary( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileChunk, + requestId, + payload: file.bytes, + }), + ); + this.host.emitBinary( + encodeFileTransferFrame({ + opcode: FileTransferOpcode.FileEnd, + requestId, + }), + ); + } else { + const file = await readExplorerFile({ + root: cwd, + relativePath: requestedPath, + }); + + this.host.emit({ + type: "file_explorer_response", + payload: { + cwd, + path: file.path, + mode, + directory: null, + file, + error: null, + requestId, + }, + }); + } + } + } catch (error) { + this.logger.error( + { err: error, cwd, path: requestedPath }, + `Failed to fulfill file explorer request for workspace ${cwd}`, + ); + this.host.emit({ + type: "file_explorer_response", + payload: { + cwd, + path: requestedPath, + mode, + directory: null, + file: null, + error: getErrorMessage(error), + requestId, + }, + }); + } + } + + handleFileUploadRequest(request: FileUploadRequest): void { + this.fileUploads.beginUpload(request); + } + + async handleFileTransferFrame(frame: FileTransferFrame): Promise { + const response = await this.fileUploads.receiveFrame(frame); + if (response) { + this.host.emit(response); + } + } + + async handleProjectIconRequest( + request: Extract, + ): Promise { + const { cwd, requestId } = request; + + try { + const icon = await getProjectIcon(cwd); + this.host.emit({ + type: "project_icon_response", + payload: { + cwd, + icon, + error: null, + requestId, + }, + }); + } catch (error) { + this.host.emit({ + type: "project_icon_response", + payload: { + cwd, + icon: null, + error: getErrorMessage(error), + requestId, + }, + }); + } + } + + async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise { + const { cwd: workspaceCwd, path: requestedPath, requestId } = request; + const cwd = workspaceCwd.trim(); + if (!cwd) { + this.host.emit({ + type: "file_download_token_response", + payload: { + cwd: workspaceCwd, + path: requestedPath, + token: null, + fileName: null, + mimeType: null, + size: null, + error: "cwd is required", + requestId, + }, + }); + return; + } + + this.logger.debug( + { cwd, path: requestedPath }, + `Handling file download token request for workspace ${cwd} (${requestedPath})`, + ); + + try { + const info = await getDownloadableFileInfo({ + root: cwd, + relativePath: requestedPath, + }); + + const entry = this.downloadTokenStore.issueToken({ + path: info.path, + absolutePath: info.absolutePath, + fileName: info.fileName, + mimeType: info.mimeType, + size: info.size, + }); + + this.host.emit({ + type: "file_download_token_response", + payload: { + cwd, + path: info.path, + token: entry.token, + fileName: entry.fileName, + mimeType: entry.mimeType, + size: entry.size, + error: null, + requestId, + }, + }); + } catch (error) { + this.logger.error( + { err: error, cwd, path: requestedPath }, + `Failed to issue download token for workspace ${cwd}`, + ); + this.host.emit({ + type: "file_download_token_response", + payload: { + cwd, + path: requestedPath, + token: null, + fileName: null, + mimeType: null, + size: null, + error: getErrorMessage(error), + requestId, + }, + }); + } + } +} diff --git a/packages/server/src/server/session/project-config/project-config-session.test.ts b/packages/server/src/server/session/project-config/project-config-session.test.ts new file mode 100644 index 000000000..0faa95404 --- /dev/null +++ b/packages/server/src/server/session/project-config/project-config-session.test.ts @@ -0,0 +1,230 @@ +import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import pino from "pino"; +import { ProjectConfigSession, type ProjectConfigSessionHost } from "./project-config-session.js"; +import type { PersistedProjectRecord } from "../../workspace-registry.js"; +import type { SessionOutboundMessage } from "../../messages.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeRoot(): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), "project-config-session-test-"))); + tempDirs.push(root); + return root; +} + +function projectRecord(rootPath: string, archivedAt: string | null = null): PersistedProjectRecord { + return { + projectId: `project:${rootPath}`, + rootPath, + kind: "git", + displayName: "Project", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt, + }; +} + +function makeSubsystem(records: PersistedProjectRecord[]) { + const emitted: SessionOutboundMessage[] = []; + const host: ProjectConfigSessionHost = { emit: (msg) => emitted.push(msg) }; + const subsystem = new ProjectConfigSession({ + host, + projectRegistry: { list: async () => records }, + logger: pino({ level: "silent" }), + }); + return { subsystem, emitted }; +} + +describe("ProjectConfigSession", () => { + test("read resolves a known root despite a trailing slash and returns the raw config + revision", async () => { + const repoRoot = makeRoot(); + writeFileSync(join(repoRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } })); + const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]); + + await subsystem.handleReadProjectConfigRequest({ + type: "read_project_config_request", + requestId: "read-1", + repoRoot: `${repoRoot}/`, + }); + + expect(emitted).toEqual([ + { + type: "read_project_config_response", + payload: { + requestId: "read-1", + repoRoot, + ok: true, + config: { worktree: { setup: "npm ci" } }, + revision: expect.objectContaining({ + mtimeMs: expect.any(Number), + size: expect.any(Number), + }), + }, + }, + ]); + }); + + // POSIX-only: creates a directory symlink without Windows privileges. + test.skipIf(process.platform === "win32")( + "read resolves a symlink to an active root via realpath", + async () => { + const repoRoot = makeRoot(); + writeFileSync( + join(repoRoot, "paseo.json"), + JSON.stringify({ worktree: { setup: "npm ci" } }), + ); + const linkRoot = join(makeRoot(), "link"); + symlinkSync(repoRoot, linkRoot, "dir"); + const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]); + + await subsystem.handleReadProjectConfigRequest({ + type: "read_project_config_request", + requestId: "read-symlink-1", + repoRoot: linkRoot, + }); + + expect(emitted).toEqual([ + { + type: "read_project_config_response", + payload: { + requestId: "read-symlink-1", + repoRoot, + ok: true, + config: { worktree: { setup: "npm ci" } }, + revision: expect.objectContaining({ + mtimeMs: expect.any(Number), + size: expect.any(Number), + }), + }, + }, + ]); + }, + ); + + test("read rejects archived and unknown roots with project_not_found", async () => { + const archivedRoot = makeRoot(); + const unknownRoot = makeRoot(); + const { subsystem, emitted } = makeSubsystem([ + projectRecord(archivedRoot, "2026-01-02T00:00:00.000Z"), + ]); + + await subsystem.handleReadProjectConfigRequest({ + type: "read_project_config_request", + requestId: "archived-1", + repoRoot: archivedRoot, + }); + await subsystem.handleReadProjectConfigRequest({ + type: "read_project_config_request", + requestId: "unknown-1", + repoRoot: unknownRoot, + }); + + expect(emitted).toEqual([ + { + type: "read_project_config_response", + payload: { + requestId: "archived-1", + repoRoot: archivedRoot, + ok: false, + error: { code: "project_not_found" }, + }, + }, + { + type: "read_project_config_response", + payload: { + requestId: "unknown-1", + repoRoot: unknownRoot, + ok: false, + error: { code: "project_not_found" }, + }, + }, + ]); + }); + + test("write round-trips a config to a known root and echoes the new revision", async () => { + const repoRoot = makeRoot(); + const { subsystem, emitted } = makeSubsystem([projectRecord(repoRoot)]); + + await subsystem.handleWriteProjectConfigRequest({ + type: "write_project_config_request", + requestId: "write-1", + repoRoot, + config: { worktree: { setup: "npm ci" } }, + expectedRevision: null, + }); + + expect(emitted).toEqual([ + { + type: "write_project_config_response", + payload: { + requestId: "write-1", + repoRoot, + ok: true, + config: { worktree: { setup: "npm ci" } }, + revision: expect.objectContaining({ + mtimeMs: expect.any(Number), + size: expect.any(Number), + }), + }, + }, + ]); + }); + + test("write rejects a stale revision and an unknown root with their inline domain failures", async () => { + const staleRoot = makeRoot(); + writeFileSync(join(staleRoot, "paseo.json"), JSON.stringify({ worktree: { setup: "old" } })); + const unknownRoot = makeRoot(); + const { subsystem, emitted } = makeSubsystem([projectRecord(staleRoot)]); + + await subsystem.handleWriteProjectConfigRequest({ + type: "write_project_config_request", + requestId: "stale-1", + repoRoot: staleRoot, + config: { worktree: { setup: "new" } }, + expectedRevision: { mtimeMs: 1, size: 1 }, + }); + await subsystem.handleWriteProjectConfigRequest({ + type: "write_project_config_request", + requestId: "unknown-write-1", + repoRoot: unknownRoot, + config: { worktree: { setup: "new" } }, + expectedRevision: null, + }); + + expect(emitted).toEqual([ + { + type: "write_project_config_response", + payload: { + requestId: "stale-1", + repoRoot: staleRoot, + ok: false, + error: { + code: "stale_project_config", + currentRevision: expect.objectContaining({ + mtimeMs: expect.any(Number), + size: expect.any(Number), + }), + }, + }, + }, + { + type: "write_project_config_response", + payload: { + requestId: "unknown-write-1", + repoRoot: unknownRoot, + ok: false, + error: { code: "project_not_found" }, + }, + }, + ]); + }); +}); diff --git a/packages/server/src/server/session/project-config/project-config-session.ts b/packages/server/src/server/session/project-config/project-config-session.ts new file mode 100644 index 000000000..ae62bd3d2 --- /dev/null +++ b/packages/server/src/server/session/project-config/project-config-session.ts @@ -0,0 +1,184 @@ +import { realpathSync } from "node:fs"; +import { resolve, sep } from "path"; +import type pino from "pino"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import type { ProjectRegistry } from "../../workspace-registry.js"; +import { + readPaseoConfigForEdit, + writePaseoConfigForEdit, + type ProjectConfigRpcError, +} from "../../../utils/paseo-config-file.js"; + +export interface ProjectConfigSessionHost { + emit(msg: SessionOutboundMessage): void; +} + +export interface ProjectConfigSessionOptions { + host: ProjectConfigSessionHost; + projectRegistry: Pick; + logger: pino.Logger; +} + +/** + * A client's read/write surface for a project's on-disk paseo.json. Resolves the + * request's repoRoot against the known (non-archived) project roots — accepting a + * trailing slash or a symlink via realpath — then reads or writes the config + * substrate and emits the matching response. Reaches no state beyond the injected + * project registry and the outbound channel. + */ +export class ProjectConfigSession { + private readonly host: ProjectConfigSessionHost; + private readonly projectRegistry: Pick; + private readonly logger: pino.Logger; + + constructor(options: ProjectConfigSessionOptions) { + this.host = options.host; + this.projectRegistry = options.projectRegistry; + this.logger = options.logger; + } + + async handleReadProjectConfigRequest( + msg: Extract, + ): Promise { + const repoRoot = await this.resolveKnownProjectRoot(msg.repoRoot); + if (!repoRoot) { + this.emitProjectConfigReadFailure(msg, { code: "project_not_found" }); + return; + } + + const result = readPaseoConfigForEdit(repoRoot); + if (!result.ok) { + this.logger.warn( + { repoRoot, requestId: msg.requestId, outcome: result.error.code }, + "Failed to read project config", + ); + this.emitProjectConfigReadFailure(msg, result.error, repoRoot); + return; + } + + if (result.config === null) { + this.logger.debug( + { repoRoot, requestId: msg.requestId, outcome: "missing_project_config" }, + "Project config missing", + ); + } + + this.host.emit({ + type: "read_project_config_response", + payload: { + requestId: msg.requestId, + repoRoot, + ok: true, + config: result.config, + revision: result.revision, + }, + }); + } + + async handleWriteProjectConfigRequest( + msg: Extract, + ): Promise { + const repoRoot = await this.resolveKnownProjectRoot(msg.repoRoot); + if (!repoRoot) { + this.emitProjectConfigWriteFailure(msg, { code: "project_not_found" }); + return; + } + + this.logger.debug( + { repoRoot, requestId: msg.requestId, outcome: "write_attempt" }, + "Writing project config", + ); + const result = writePaseoConfigForEdit({ + repoRoot, + config: msg.config, + expectedRevision: msg.expectedRevision, + }); + if (!result.ok) { + this.logger.debug( + { repoRoot, requestId: msg.requestId, outcome: result.error.code }, + "Project config write did not complete", + ); + this.emitProjectConfigWriteFailure(msg, result.error, repoRoot); + return; + } + + this.logger.debug( + { repoRoot, requestId: msg.requestId, outcome: "written" }, + "Project config written", + ); + this.host.emit({ + type: "write_project_config_response", + payload: { + requestId: msg.requestId, + repoRoot, + ok: true, + config: result.config, + revision: result.revision, + }, + }); + } + + private emitProjectConfigReadFailure( + msg: Extract, + error: ProjectConfigRpcError, + repoRoot = msg.repoRoot, + ): void { + this.host.emit({ + type: "read_project_config_response", + payload: { + requestId: msg.requestId, + repoRoot, + ok: false, + error, + }, + }); + } + + private emitProjectConfigWriteFailure( + msg: Extract, + error: ProjectConfigRpcError, + repoRoot = msg.repoRoot, + ): void { + this.host.emit({ + type: "write_project_config_response", + payload: { + requestId: msg.requestId, + repoRoot, + ok: false, + error, + }, + }); + } + + private async resolveKnownProjectRoot(repoRoot: string): Promise { + const requestedRoot = canonicalizeConfigRoot(repoRoot); + const projects = await this.projectRegistry.list(); + for (const project of projects) { + if (project.archivedAt !== null) { + continue; + } + const projectRoot = canonicalizeConfigRoot(project.rootPath); + if (requestedRoot === projectRoot) { + return projectRoot; + } + } + return null; + } +} + +function canonicalizeConfigRoot(repoRoot: string): string { + const resolved = resolve(repoRoot); + try { + return stripTrailingPathSeparators(realpathSync(resolved)); + } catch { + return stripTrailingPathSeparators(resolved); + } +} + +function stripTrailingPathSeparators(path: string): string { + let normalized = path; + while (normalized.length > 1 && normalized.endsWith(sep)) { + normalized = normalized.slice(0, -1); + } + return normalized; +} diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts new file mode 100644 index 000000000..2d0d9040d --- /dev/null +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import pino from "pino"; +import { + ProviderCatalogSession, + type ProviderCatalogSessionHost, +} from "./provider-catalog-session.js"; +import { createStub } from "../../test-utils/class-mocks.js"; +import { findByType } from "../../test-utils/session-stubs.js"; +import type { SessionOutboundMessage } from "../../messages.js"; +import { + resolveSnapshotCwd, + type ProviderSnapshotManager, +} from "../../agent/provider-snapshot-manager.js"; +import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js"; +import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js"; + +type SnapshotChangeHandler = (entries: ProviderSnapshotEntry[], cwd: string) => void; + +interface MakeOptions { + visibleProviders?: Set; + supportsCustomModeIcons?: boolean; + snapshot?: { [K in keyof ProviderSnapshotManager]?: unknown }; + usage?: { [K in keyof ProviderUsageService]?: unknown }; + host?: Partial; +} + +// A codex entry whose two modes exercise both downgrade branches (unknown icon → +// "ShieldCheck", known icon → preserved) plus a claude entry the visibility gate drops. +function makeEntries(): ProviderSnapshotEntry[] { + return [ + { + provider: "codex", + status: "ready", + enabled: true, + modes: [ + { id: "default", label: "Default", icon: "Sparkles" }, + { id: "safe", label: "Safe", icon: "ShieldCheck" }, + ], + }, + { provider: "claude", status: "ready", enabled: true, modes: [] }, + ]; +} + +function makeSubsystem(options: MakeOptions = {}) { + const emitted: SessionOutboundMessage[] = []; + const visible = options.visibleProviders ?? new Set(["codex"]); + let changeHandler: SnapshotChangeHandler | null = null; + const host: ProviderCatalogSessionHost = { + emit: (msg) => emitted.push(msg), + isProviderVisibleToClient: (provider) => visible.has(provider), + supportsCustomModeIcons: () => options.supportsCustomModeIcons ?? false, + listProviderAvailability: async () => [], + listDraftFeatures: async () => [], + ...options.host, + }; + const providerSnapshotManager = createStub({ + on: (_event: string, handler: SnapshotChangeHandler) => { + changeHandler = handler; + }, + off: () => {}, + ...options.snapshot, + }); + const subsystem = new ProviderCatalogSession({ + host, + providerSnapshotManager, + providerUsageService: createStub(options.usage ?? {}), + logger: pino({ level: "silent" }), + }); + function pushSnapshotChange(entries: ProviderSnapshotEntry[], cwd = resolveSnapshotCwd()): void { + if (!changeHandler) throw new Error("start() must run before a snapshot change"); + changeHandler(entries, cwd); + } + return { subsystem, emitted, pushSnapshotChange }; +} + +describe("ProviderCatalogSession", () => { + it("PUSH gates invisible providers and downgrades unknown mode icons for legacy clients", () => { + const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({ + visibleProviders: new Set(["codex"]), + supportsCustomModeIcons: false, + }); + + subsystem.start(); + pushSnapshotChange(makeEntries()); + + const push = findByType(emitted, "providers_snapshot_update"); + expect(push?.payload.entries.map((entry) => entry.provider)).toEqual(["codex"]); + expect(push?.payload.entries[0]?.modes).toEqual([ + { id: "default", label: "Default", icon: "ShieldCheck" }, + { id: "safe", label: "Safe", icon: "ShieldCheck" }, + ]); + }); + + it("PUSH and PULL produce identical visible, downgraded entries for one client", async () => { + const { subsystem, emitted, pushSnapshotChange } = makeSubsystem({ + visibleProviders: new Set(["codex"]), + supportsCustomModeIcons: false, + snapshot: { getSnapshot: () => makeEntries() }, + }); + + subsystem.start(); + pushSnapshotChange(makeEntries()); + await subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "g1", + }); + + const push = findByType(emitted, "providers_snapshot_update"); + const pull = findByType(emitted, "get_providers_snapshot_response"); + expect(pull?.payload.entries).toEqual(push?.payload.entries); + }); + + it("preserves custom mode icons when the client supports them", async () => { + const { subsystem, emitted } = makeSubsystem({ + supportsCustomModeIcons: true, + snapshot: { getSnapshot: () => makeEntries() }, + }); + + await subsystem.handleGetProvidersSnapshotRequest({ + type: "get_providers_snapshot_request", + requestId: "g2", + }); + + const pull = findByType(emitted, "get_providers_snapshot_response"); + expect(pull?.payload.entries[0]?.modes?.[0]?.icon).toBe("Sparkles"); + }); + + it("reports a disabled provider on list_provider_models without warming the snapshot", async () => { + // warmUpSnapshotForCwd is intentionally unstubbed: createStub throws if it is called, + // so the disabled short-circuit is proven by the absence of a throw. + const { subsystem, emitted } = makeSubsystem({ + snapshot: { getSnapshot: () => [{ provider: "codex", status: "loading", enabled: false }] }, + }); + + await subsystem.handleListProviderModelsRequest({ + type: "list_provider_models_request", + provider: "codex", + requestId: "m1", + }); + + const res = findByType(emitted, "list_provider_models_response"); + expect(res?.payload.error).toBe("Provider codex is disabled"); + }); + + it("surfaces a usage-list failure as an rpc_error envelope", async () => { + const { subsystem, emitted } = makeSubsystem({ + usage: { + listUsage: async () => { + throw new Error("quota service down"); + }, + }, + }); + + await subsystem.handleProviderUsageListRequest({ + type: "provider.usage.list.request", + requestId: "u1", + }); + + const err = findByType(emitted, "rpc_error"); + expect(err?.payload.code).toBe("provider_usage_list_failed"); + expect(err?.payload.requestId).toBe("u1"); + }); + + it("surfaces a feature-list failure inline, not as an rpc_error", async () => { + const { subsystem, emitted } = makeSubsystem({ + host: { + listDraftFeatures: async () => { + throw new Error("feature probe failed"); + }, + }, + }); + + await subsystem.handleListProviderFeaturesRequest({ + type: "list_provider_features_request", + requestId: "f1", + draftConfig: { provider: "codex", cwd: "/tmp/project" }, + }); + + expect(findByType(emitted, "rpc_error")).toBeUndefined(); + const res = findByType(emitted, "list_provider_features_response"); + expect(res?.payload.error).toBe("feature probe failed"); + expect(res?.payload.requestId).toBe("f1"); + }); +}); diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts new file mode 100644 index 000000000..fcb4561f1 --- /dev/null +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -0,0 +1,454 @@ +import type pino from "pino"; +import { getErrorMessage } from "@getpaseo/protocol/error-utils"; +import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; +import { + resolveSnapshotCwd, + type ProviderSnapshotManager, +} from "../../agent/provider-snapshot-manager.js"; +import type { + AgentFeature, + AgentProvider, + AgentSessionConfig, + ProviderSnapshotEntry, +} from "../../agent/agent-sdk-types.js"; +import type { ProviderAvailability } from "../../agent/agent-manager.js"; +import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js"; +import { expandTilde } from "../../../utils/path.js"; + +// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any +// other icon name is downgraded to "ShieldCheck" for those clients. +const LEGACY_MODE_ICONS = new Set([ + "ShieldCheck", + "ShieldAlert", + "ShieldOff", + "ShieldQuestionMark", +]); + +/** + * The collaborators a provider-catalog request reaches that are NOT part of the + * provider domain. Two are CLIENT-COMPAT predicates the Session shell owns because + * agent-lifecycle shares the visibility gate: both read client state (appVersion / + * capabilities) LIVE, mutated post-construction via updateAppVersion / + * updateClientCapabilities. The two agent-control reads expose provider availability + * and draft features the AgentManager owns. + */ +export interface ProviderCatalogSessionHost { + emit(msg: SessionOutboundMessage): void; + // COMPAT(providersSnapshot): visibility gating for older clients lives on the shell + // (agent-lifecycle shares it). Reads appVersion live. + isProviderVisibleToClient(provider: string): boolean; + // COMPAT(customModeIcons): reads clientCapabilities live. + supportsCustomModeIcons(): boolean; + listProviderAvailability(): Promise; + listDraftFeatures(config: AgentSessionConfig): Promise; +} + +export interface ProviderCatalogSessionOptions { + host: ProviderCatalogSessionHost; + providerSnapshotManager: ProviderSnapshotManager; + providerUsageService: ProviderUsageService; + logger: pino.Logger; +} + +/** + * A client's provider catalog surface: model / mode / feature listing, the providers + * snapshot push + pull, provider diagnostics, and usage. The snapshot PUSH (start) and + * every PULL handler gate visibility and downgrade mode icons through the SAME predicates, + * so an older client sees one consistent provider set across both paths — the COMPAT + * invariant the shell could only enforce by code proximity before this carve. + */ +export class ProviderCatalogSession { + private readonly host: ProviderCatalogSessionHost; + private readonly providerSnapshotManager: ProviderSnapshotManager; + private readonly providerUsageService: ProviderUsageService; + private readonly logger: pino.Logger; + private unsubscribeSnapshotEvents: (() => void) | null = null; + + constructor(options: ProviderCatalogSessionOptions) { + this.host = options.host; + this.providerSnapshotManager = options.providerSnapshotManager; + this.providerUsageService = options.providerUsageService; + this.logger = options.logger; + } + + start(): void { + const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => { + // COMPAT(providersSnapshot): keep provider visibility gating for older clients. + const visibleEntries = entries.filter((entry) => + this.host.isProviderVisibleToClient(entry.provider), + ); + const snapshotCwd = cwd === resolveSnapshotCwd() ? undefined : cwd; + this.host.emit({ + type: "providers_snapshot_update", + payload: { + ...(snapshotCwd ? { cwd: snapshotCwd } : {}), + entries: this.downgradeEntryModesForClient(visibleEntries), + generatedAt: new Date().toISOString(), + }, + }); + }; + this.providerSnapshotManager.on("change", handleProviderSnapshotChange); + this.unsubscribeSnapshotEvents = () => { + this.providerSnapshotManager.off("change", handleProviderSnapshotChange); + }; + } + + dispose(): void { + if (this.unsubscribeSnapshotEvents) { + this.unsubscribeSnapshotEvents(); + this.unsubscribeSnapshotEvents = null; + } + } + + // COMPAT(customModeIcons): rewrite icons unknown to v0.1.83 clients (whose MODE_ICONS + // map is a closed enum and would render `undefined`, crashing in render). Drop + // this and the cap gate when floor >= v0.1.84. + private downgradeModeIconsForClient(modes: T[]): T[] { + if (this.host.supportsCustomModeIcons()) return modes; + return modes.map((mode) => + mode.icon && !LEGACY_MODE_ICONS.has(mode.icon) ? { ...mode, icon: "ShieldCheck" } : mode, + ); + } + + private downgradeEntryModesForClient( + entries: T[], + ): T[] { + if (this.host.supportsCustomModeIcons()) return entries; + return entries.map((entry) => + entry.modes ? { ...entry, modes: this.downgradeModeIconsForClient(entry.modes) } : entry, + ); + } + + private emitProviderDisabledResponse( + kind: "models" | "modes", + provider: AgentProvider, + requestId: string, + fetchedAt: string, + ): void { + const payload = { + provider, + error: `Provider ${provider} is disabled`, + fetchedAt, + requestId, + }; + if (kind === "models") { + this.host.emit({ type: "list_provider_models_response", payload }); + } else { + this.host.emit({ type: "list_provider_modes_response", payload }); + } + } + + async handleListProviderModelsRequest( + msg: Extract, + ): Promise { + const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined); + const fetchedAt = new Date().toISOString(); + + const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider); + + if (!entry) { + this.host.emit({ + type: "list_provider_models_response", + payload: { + provider: msg.provider, + error: `Unknown provider: ${msg.provider}`, + fetchedAt, + requestId: msg.requestId, + }, + }); + return; + } + + if (!entry.enabled) { + this.emitProviderDisabledResponse("models", msg.provider, msg.requestId, fetchedAt); + return; + } + + if (entry.status === "ready") { + this.host.emit({ + type: "list_provider_models_response", + payload: { + provider: msg.provider, + models: entry.models ?? [], + error: null, + fetchedAt: entry.fetchedAt ?? fetchedAt, + requestId: msg.requestId, + }, + }); + return; + } + + const errorMessage = + entry.status === "error" + ? (entry.error ?? `Failed to list models for ${msg.provider}`) + : `Provider ${msg.provider} is not available`; + + this.host.emit({ + type: "list_provider_models_response", + payload: { + provider: msg.provider, + error: errorMessage, + fetchedAt, + requestId: msg.requestId, + }, + }); + } + + async handleListProviderModesRequest( + msg: Extract, + ): Promise { + const fetchedAt = new Date().toISOString(); + const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined); + const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider); + + if (!entry) { + this.host.emit({ + type: "list_provider_modes_response", + payload: { + provider: msg.provider, + error: `Unknown provider: ${msg.provider}`, + fetchedAt, + requestId: msg.requestId, + }, + }); + return; + } + + if (!entry.enabled) { + this.emitProviderDisabledResponse("modes", msg.provider, msg.requestId, fetchedAt); + return; + } + + if (entry.status === "ready") { + this.host.emit({ + type: "list_provider_modes_response", + payload: { + provider: msg.provider, + modes: this.downgradeModeIconsForClient(entry.modes ?? []), + error: null, + fetchedAt: entry.fetchedAt ?? fetchedAt, + requestId: msg.requestId, + }, + }); + return; + } + + const errorMessage = + entry.status === "error" + ? (entry.error ?? `Failed to list modes for ${msg.provider}`) + : `Provider ${msg.provider} is not available`; + + this.host.emit({ + type: "list_provider_modes_response", + payload: { + provider: msg.provider, + error: errorMessage, + fetchedAt, + requestId: msg.requestId, + }, + }); + } + + private async getProviderSnapshotEntryForRead( + cwd: string, + provider: AgentProvider, + ): Promise { + const manager = this.providerSnapshotManager; + const findEntry = () => + manager.getSnapshot(cwd).find((candidate) => candidate.provider === provider); + + let entry = findEntry(); + if (entry && !entry.enabled) { + return entry; + } + if (!entry || entry.status === "loading") { + // Awaits the in-flight warmup (deduped per-cwd) so old clients still get + // a resolved answer rather than a loading placeholder. + await manager.warmUpSnapshotForCwd({ cwd, providers: [provider] }); + entry = findEntry(); + } + return entry; + } + + private buildDraftAgentSessionConfig(draftConfig: { + provider: AgentProvider; + cwd: string; + modeId?: string; + model?: string; + thinkingOptionId?: string; + featureValues?: Record; + }): AgentSessionConfig { + return { + provider: draftConfig.provider, + cwd: expandTilde(draftConfig.cwd), + ...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}), + ...(draftConfig.model ? { model: draftConfig.model } : {}), + ...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}), + ...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}), + }; + } + + async handleListProviderFeaturesRequest( + msg: Extract, + ): Promise { + const fetchedAt = new Date().toISOString(); + try { + const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig); + const features = await this.host.listDraftFeatures(sessionConfig); + this.host.emit({ + type: "list_provider_features_response", + payload: { + provider: msg.draftConfig.provider, + features, + error: null, + fetchedAt, + requestId: msg.requestId, + }, + }); + } catch (error) { + this.logger.error( + { err: error, provider: msg.draftConfig.provider, draftConfig: msg.draftConfig }, + `Failed to list features for ${msg.draftConfig.provider}`, + ); + this.host.emit({ + type: "list_provider_features_response", + payload: { + provider: msg.draftConfig.provider, + error: getErrorMessage(error), + fetchedAt, + requestId: msg.requestId, + }, + }); + } + } + + async handleListAvailableProvidersRequest( + msg: Extract, + ): Promise { + const fetchedAt = new Date().toISOString(); + try { + const providers = (await this.host.listProviderAvailability()).filter((provider) => + this.host.isProviderVisibleToClient(provider.provider), + ); + this.host.emit({ + type: "list_available_providers_response", + payload: { + providers, + error: null, + fetchedAt, + requestId: msg.requestId, + }, + }); + } catch (error) { + this.logger.error({ err: error }, "Failed to list provider availability"); + this.host.emit({ + type: "list_available_providers_response", + payload: { + providers: [], + error: getErrorMessage(error), + fetchedAt, + requestId: msg.requestId, + }, + }); + } + } + + async handleGetProvidersSnapshotRequest( + msg: Extract, + ): Promise { + // COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow. + const entries = this.providerSnapshotManager + .getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined) + .filter((entry) => this.host.isProviderVisibleToClient(entry.provider)); + + this.host.emit({ + type: "get_providers_snapshot_response", + payload: { + entries: this.downgradeEntryModesForClient(entries), + generatedAt: new Date().toISOString(), + requestId: msg.requestId, + }, + }); + } + + async handleRefreshProvidersSnapshotRequest( + msg: Extract, + ): Promise { + if (msg.cwd) { + await this.providerSnapshotManager.refreshSnapshotForCwd({ + cwd: expandTilde(msg.cwd), + providers: msg.providers, + }); + } else { + await this.providerSnapshotManager.refreshSettingsSnapshot({ + providers: msg.providers, + }); + } + this.host.emit({ + type: "refresh_providers_snapshot_response", + payload: { + acknowledged: true, + requestId: msg.requestId, + }, + }); + } + + async handleProviderDiagnosticRequest( + msg: Extract, + ): Promise { + try { + const { diagnostic } = await this.providerSnapshotManager.getProviderDiagnostic(msg.provider); + this.host.emit({ + type: "provider_diagnostic_response", + payload: { + provider: msg.provider, + diagnostic, + requestId: msg.requestId, + }, + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.error( + { err, provider: msg.provider }, + `Failed to get provider diagnostic for ${msg.provider}`, + ); + this.host.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: `Failed to get provider diagnostic: ${err.message}`, + code: "provider_diagnostic_failed", + }, + }); + } + } + + async handleProviderUsageListRequest( + msg: Extract, + ): Promise { + try { + const usage = await this.providerUsageService.listUsage(); + this.host.emit({ + type: "provider.usage.list.response", + payload: { + requestId: msg.requestId, + fetchedAt: usage.fetchedAt, + providers: usage.providers, + }, + }); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + this.logger.error({ err }, "Failed to list provider usage"); + this.host.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: `Failed to list provider usage: ${err.message}`, + code: "provider_usage_list_failed", + }, + }); + } + } +} diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 3a876a15e..c41c78049 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -25,6 +25,28 @@ const READ_ONLY_GIT_ENV = { GIT_OPTIONAL_LOCKS: "0", } as const; +/** + * Why a git mutation is forcing a workspace snapshot refresh. Shared between the + * Session shell (which owns the refresh primitive) and the checkout subsystem + * (which triggers most of these reasons after a write). + */ +export type GitMutationRefreshReason = + | "commit-changes" + | "pull" + | "push" + | "merge-to-base" + | "merge-from-base" + | "merge-pr" + | "enable-pr-auto-merge" + | "disable-pr-auto-merge" + | "create-pr" + | "switch-branch" + | "rename-branch" + | "create-branch" + | "stash-push" + | "stash-pop" + | "create-worktree"; + const DEFAULT_PULL_REQUEST_STATUS_CACHE_TTL_MS = 30_000; const PULL_REQUEST_STATUS_CACHE_MAX = 1_000; const DEFAULT_SHORTSTAT_CACHE_TTL_MS = 15_000;