diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 7262239577b4..8349dc762549 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -44,6 +44,15 @@ describe("RPC authorization scopes", () => { }); it("requires write access to import agent session history", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsList)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsPreview)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsAttach)).toBe( + AuthOrchestrationOperateScope, + ); expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsScan)).toBe( AuthOrchestrationReadScope, ); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a069322aa8bf..566ca2f2dbaa 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -102,6 +102,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, + [WS_METHODS.agentSessionsList]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsPreview]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsAttach]: AuthOrchestrationOperateScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index c9f514037804..ab71b9ccd9ff 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -91,6 +91,7 @@ describe("CheckpointDiffQuery.layer", () => { getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.sync(() => { getThreadCheckpointContextCalls += 1; @@ -205,6 +206,7 @@ describe("CheckpointDiffQuery.layer", () => { getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -294,6 +296,7 @@ describe("CheckpointDiffQuery.layer", () => { getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -368,6 +371,7 @@ describe("CheckpointDiffQuery.layer", () => { getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -427,6 +431,7 @@ describe("CheckpointDiffQuery.layer", () => { getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 0b2e08c40d68..1bbf6cd13c57 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -420,6 +420,7 @@ describe("OrchestrationEngine", () => { getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index e262bce34aaf..6083a8db2aa2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -909,11 +909,24 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { "None", ); } + const candidateIds = [ + ...Array.from({ length: 501 }, (_, index) => ThreadId.make(`missing-${index}`)), + ThreadId.make("thread-active"), + ThreadId.make("thread-archived"), + ]; + assert.deepEqual(yield* snapshotQuery.getThreadProjectIds(candidateIds), [ + { + threadId: ThreadId.make("thread-active"), + projectId: asProjectId("project-archive-test"), + }, + ]); + assert.deepEqual(yield* snapshotQuery.getThreadProjectIds([]), []); yield* sql`UPDATE projection_threads SET deleted_at = '2026-04-06T00:00:08.000Z' WHERE thread_id = 'thread-active'`; assert.equal( (yield* snapshotQuery.getThreadRuntimeContext(ThreadId.make("thread-active")))._tag, "None", ); + assert.deepEqual(yield* snapshotQuery.getThreadProjectIds(candidateIds), []); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 5f82a26e2a36..6bdfe6a15e1f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1102,6 +1102,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const getThreadProjectIdRows = SqlSchema.findAll({ + Request: Schema.Array(ThreadId), + Result: Schema.Struct({ threadId: ThreadId, projectId: ProjectId }), + execute: (threadIds) => sql` + SELECT thread_id AS "threadId", project_id AS "projectId" + FROM projection_threads + WHERE ${sql.in("thread_id", threadIds)} + AND deleted_at IS NULL + AND archived_at IS NULL + `, + }); + const getThreadRuntimeContextRow = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadRuntimeContextDbRowSchema, @@ -2930,6 +2942,24 @@ pending_approval_requests AS ( } satisfies OrchestrationThreadShell); }); + const getThreadProjectIds: ProjectionSnapshotQueryShape["getThreadProjectIds"] = (threadIds) => + Effect.gen(function* () { + const rows: Array<{ threadId: ThreadId; projectId: ProjectId }> = []; + for (const batch of Arr.chunksOf(threadIds, 500)) { + rows.push( + ...(yield* getThreadProjectIdRows(batch).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadProjectIds:query", + "ProjectionSnapshotQuery.getThreadProjectIds:decodeRows", + ), + ), + )), + ); + } + return rows; + }); + const getThreadRuntimeContext: ProjectionSnapshotQueryShape["getThreadRuntimeContext"] = Effect.fn("ProjectionSnapshotQuery.getThreadRuntimeContext")(function* (threadId) { const context = yield* getThreadRuntimeContextRow({ threadId }).pipe( @@ -3413,6 +3443,7 @@ pending_approval_requests AS ( getThreadCheckpointContext, getFullThreadDiffContext, getThreadShellById, + getThreadProjectIds, getThreadRuntimeContext, getTurnStartMessage, getThreadDetailById, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 35d5bacc239c..d85d34baa74d 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -205,6 +205,14 @@ export interface ProjectionSnapshotQueryShape { threadId: ThreadId, ) => Effect.Effect, ProjectionRepositoryError>; + /** Read project ownership for active candidate threads without hydrating their shells. */ + readonly getThreadProjectIds: ( + threadIds: ReadonlyArray, + ) => Effect.Effect< + ReadonlyArray<{ readonly threadId: ThreadId; readonly projectId: ProjectId }>, + ProjectionRepositoryError + >; + /** Read the active thread and session facts used to ingest provider events. */ readonly getThreadRuntimeContext: ( threadId: ThreadId, diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 38d6ad1d4331..38d8f36bb3fb 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -53,14 +53,26 @@ import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; import { makeAdapterRegistryMock } from "../provider/testUtils/providerAdapterRegistryMock.ts"; import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; -import { ServerSettingsService } from "../serverSettings.ts"; +import { ServerSettingsService, layerTest as serverSettingsLayerTest } from "../serverSettings.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { TextGeneration } from "../textGeneration/TextGeneration.ts"; import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; -import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; +import { + importRecentAgentThreads, + attachAgentSession, + listAgentSessions, + previewAgentSession, +} from "./AgentSessionImporter.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; +const unusedSessionBrowser = { + claudeSessionHomes: Effect.succeed(new Map()), + list: () => Effect.die("unused"), + preview: () => Effect.die("unused"), + selectedThread: () => Effect.die("unused"), +}; + const PROJECT_ID = ProjectId.make("project-1"); const WORKSPACE_ROOT = "/tmp/project-from-server"; const CLAUDE_SESSION_ID = "123e4567-e89b-42d3-a456-426614174000"; @@ -204,6 +216,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { const bindings: Array = []; let scannedRoot: string | undefined; const scanner = AgentSessionScanner.AgentSessionScanner.of({ + ...unusedSessionBrowser, scan: Effect.die("unused"), recentThreads: (workspaceRoot) => { scannedRoot = workspaceRoot; @@ -296,6 +309,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { Effect.provideService( AgentSessionScanner.AgentSessionScanner, AgentSessionScanner.AgentSessionScanner.of({ + ...unusedSessionBrowser, scan: Effect.die("must not scan a changed project"), recentThreads, }), @@ -320,6 +334,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { it.effect("counts scanner skips without writing a thread or binding", () => Effect.gen(function* () { const scanner = AgentSessionScanner.AgentSessionScanner.of({ + ...unusedSessionBrowser, scan: Effect.die("unused"), recentThreads: () => Stream.succeed({ _tag: "Skipped" }), }); @@ -361,6 +376,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { const rejectedCommandIds = new Set(); const bindings: Array = []; const scanner = AgentSessionScanner.AgentSessionScanner.of({ + ...unusedSessionBrowser, scan: Effect.die("unused"), recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), }); @@ -441,6 +457,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { it.effect("does not replace completed history or an active binding on retry", () => Effect.gen(function* () { const scanner = AgentSessionScanner.AgentSessionScanner.of({ + ...unusedSessionBrowser, scan: Effect.die("unused"), recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), }); @@ -489,6 +506,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { it.effect("skips malformed Claude ids and wrong-project thread collisions", () => Effect.gen(function* () { const scanner = AgentSessionScanner.AgentSessionScanner.of({ + ...unusedSessionBrowser, scan: Effect.die("unused"), recentThreads: () => Stream.fromIterable([ @@ -550,6 +568,7 @@ const integrationThread = { })), }; const integrationScanner = AgentSessionScanner.AgentSessionScanner.of({ + ...unusedSessionBrowser, scan: Effect.die("unused"), recentThreads: () => Stream.fromIterable([makeThreadOutcome(integrationThread)]), }); @@ -580,6 +599,449 @@ const integrationLayer = Layer.mergeAll( ); it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { + it.effect( + "attaches only the selected native session across concurrent aliases without executing a turn", + () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const projectId = ProjectId.make("single-attach-project"); + const workspaceRoot = "/tmp/single-attach-project"; + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("single-attach-project"), + projectId, + title: "Attach", + workspaceRoot: workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + const source = { + ...makeThread("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("single-claude"), + }; + const input = { + projectId, + expectedWorkspaceRoot: workspaceRoot, + providerInstanceId: source.providerInstanceId, + providerSessionId: source.providerSessionId, + }; + const alias = ProviderInstanceId.make("single-claude-alias"); + const selected = vi.fn( + ( + root: string, + selection: { providerSessionId: string; providerInstanceId: ProviderInstanceId }, + ) => { + expect(root).toBe(workspaceRoot); + expect(selection.providerSessionId).toBe(CLAUDE_SESSION_ID); + return Effect.succeed( + makeThreadOutcome({ ...source, providerInstanceId: selection.providerInstanceId }), + ); + }, + ); + const scanner = { + ...integrationScanner, + claudeSessionHomes: Effect.succeed( + new Map([ + [source.providerInstanceId, "/tmp/single-claude-home"], + [alias, "/tmp/single-claude-home"], + ]), + ), + selectedThread: selected, + preview: () => + Effect.succeed({ + messages: source.messages.map((message, id) => ({ ...message, id })), + nextBefore: null, + truncated: false, + }), + recentThreads: () => Stream.die("single attach must not scan bulk history"), + }; + const attached = yield* Effect.all( + [attachAgentSession(input), attachAgentSession({ ...input, providerInstanceId: alias })], + { + concurrency: "unbounded", + }, + ).pipe(Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner)); + expect(attached[0]).toEqual(attached[1]); + expect(selected).toHaveBeenCalledTimes(1); + const threadId = attached[0].threadId; + const thread = Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)); + expect(thread.messages.map((message) => message.text)).toEqual(["Fix the bug", "Fixed"]); + expect(thread.latestTurn).toBeNull(); + expect(thread.session).toBeNull(); + expect(thread.runtimeMode).toBe("approval-required"); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + resumeCursor: { threadId, resume: CLAUDE_SESSION_ID }, + runtimePayload: { cwd: workspaceRoot }, + runtimeMode: "approval-required", + status: "stopped", + }); + expect( + (yield* snapshots.getShellSnapshot()).threads.filter( + (entry) => entry.projectId === projectId, + ), + ).toHaveLength(1); + expect( + (yield* attachAgentSession(input).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner), + )).threadId, + ).toBe(threadId); + const changed = yield* attachAgentSession({ + ...input, + expectedWorkspaceRoot: "/changed", + }).pipe(Effect.result); + expect(changed._tag).toBe("Failure"); + const missing = yield* previewAgentSession({ + ...input, + projectId: ProjectId.make("missing-project"), + }).pipe(Effect.result); + expect(missing._tag).toBe("Failure"); + }), + ); + + it.effect( + "opens T3-created sessions without duplication and scopes native identity by provider instance", + () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const projectId = ProjectId.make("native-existing-project"); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "native-alias-import-" }); + const workspaceRoot = `${root}/workspace`; + yield* fs.makeDirectory(workspaceRoot); + const threadId = ThreadId.make("native-existing-thread"); + const instanceId = ProviderInstanceId.make("work-claude"); + const source = { ...makeThread("claudeAgent"), providerInstanceId: instanceId }; + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("native-existing-project"), + projectId, + title: "Native", + workspaceRoot: workspaceRoot, + defaultModelSelection: null, + createdAt: source.createdAt, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("native-existing-thread"), + threadId, + projectId, + title: "T3-created", + modelSelection: { instanceId, model: "default" }, + runtimeMode: "auto-accept-edits", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: source.createdAt, + }); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: instanceId, + resumeCursor: { threadId, resume: CLAUDE_SESSION_ID }, + status: "stopped", + runtimeMode: "auto-accept-edits", + }); + const home = `${root}/claude`; + const otherHome = `${root}/other-claude`; + for (const directory of [home, otherHome]) { + yield* fs.makeDirectory(`${directory}/projects/project`, { recursive: true }); + yield* fs.writeFileString( + `${directory}/projects/project/${CLAUDE_SESSION_ID}.jsonl`, + encodeTranscriptRecord({ + type: "user", + cwd: workspaceRoot, + sessionId: CLAUDE_SESSION_ID, + message: { content: "Fix the bug" }, + }) + "\n", + ); + } + const homeAlias = `${root}/claude-alias`; + yield* fs.symlink(home, homeAlias); + const scanner = yield* AgentSessionScanner.make.pipe( + Effect.provide( + serverSettingsLayerTest({ + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: home }, + }, + [instanceId]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: homeAlias }, + }, + [ProviderInstanceId.make("other-claude")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: otherHome }, + }, + }, + }), + ), + ); + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const getThreadProjectIds = vi.fn((threadIds: ReadonlyArray) => + snapshots.getThreadProjectIds(threadIds), + ); + const unrelatedThreadId = ThreadId.make("unlisted-native-binding"); + yield* directory.upsert({ + threadId: unrelatedThreadId, + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: instanceId, + resumeCursor: { resume: "123e4567-e89b-42d3-a456-426614174999" }, + status: "stopped", + runtimeMode: "approval-required", + }); + yield* Effect.gen(function* () { + const list = yield* listAgentSessions({ + projectId, + expectedWorkspaceRoot: workspaceRoot, + }).pipe( + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + ...snapshots, + getThreadProjectIds, + getThreadShellById: () => Effect.die("must not hydrate individual thread shells"), + }), + ); + expect(getThreadProjectIds).toHaveBeenCalledTimes(1); + expect(getThreadProjectIds.mock.calls[0]?.[0]).toContain(threadId); + expect(getThreadProjectIds.mock.calls[0]?.[0]).not.toContain(unrelatedThreadId); + expect(list.sessions).toHaveLength(2); + expect( + list.sessions.find((session) => session.providerInstanceId === "claudeAgent") + ?.existingThreadId, + ).toBe(threadId); + expect( + list.sessions.find((session) => session.providerInstanceId === "other-claude") + ?.existingThreadId, + ).toBeNull(); + const result = yield* attachAgentSession({ + projectId, + expectedWorkspaceRoot: workspaceRoot, + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerSessionId: CLAUDE_SESSION_ID, + }); + expect(result.threadId).toBe(threadId); + expect(Option.getOrThrow(yield* directory.getBinding(threadId)).providerInstanceId).toBe( + instanceId, + ); + expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimeMode).toBe( + "auto-accept-edits", + ); + }).pipe(Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner)); + }), + ); + + it.effect("rejects a session attached to another project without creating a duplicate", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const ownerProjectId = ProjectId.make("cross-project-owner"); + const requestedProjectId = ProjectId.make("cross-project-requested"); + const threadId = ThreadId.make("cross-project-native-thread"); + const source = makeThread("claudeAgent"); + const workspaceRoot = "/tmp/cross-project-requested"; + for (const projectId of [ownerProjectId, requestedProjectId]) { + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make(projectId), + projectId, + title: projectId, + workspaceRoot: `/tmp/${projectId}`, + defaultModelSelection: null, + createdAt: source.createdAt, + }); + } + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(threadId), + threadId, + projectId: ownerProjectId, + title: source.title, + modelSelection: { instanceId: source.providerInstanceId, model: "default" }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: source.createdAt, + }); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: source.providerInstanceId, + resumeCursor: { threadId, resume: source.providerSessionId }, + status: "stopped", + runtimeMode: "approval-required", + }); + const sequence = yield* engine.latestSequence; + const result = yield* attachAgentSession({ + projectId: requestedProjectId, + expectedWorkspaceRoot: workspaceRoot, + providerInstanceId: source.providerInstanceId, + providerSessionId: source.providerSessionId, + }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, { + ...integrationScanner, + preview: () => Effect.succeed({ messages: [], nextBefore: null, truncated: false }), + selectedThread: () => Effect.succeed(makeThreadOutcome(source)), + }), + Effect.result, + ); + expect(result._tag).toBe("Failure"); + expect(yield* engine.latestSequence).toBe(sequence); + expect( + (yield* directory.listBindings()).filter((binding) => binding.threadId === threadId), + ).toHaveLength(1); + }), + ); + + it.effect("repairs an interrupted attachment through a different same-home alias", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const projectId = ProjectId.make("partial-alias-project"); + const workspaceRoot = "/tmp/partial-alias-project"; + const source = { + ...makeThread("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("original-claude"), + }; + const alias = ProviderInstanceId.make("selected-claude"); + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make(projectId), + projectId, + title: "Partial alias", + workspaceRoot, + defaultModelSelection: null, + createdAt: source.createdAt, + }); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: source.providerInstanceId, + resumeCursor: { threadId, resume: source.providerSessionId }, + runtimePayload: { cwd: workspaceRoot }, + status: "stopped", + runtimeMode: "approval-required", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(threadId), + threadId, + projectId, + title: source.title, + modelSelection: { instanceId: source.providerInstanceId, model: "default" }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: source.createdAt, + historyImport: true, + }); + const scanner = { + ...integrationScanner, + claudeSessionHomes: Effect.succeed( + new Map([ + [source.providerInstanceId, "/tmp/shared-claude-home"], + [alias, "/tmp/shared-claude-home"], + ]), + ), + preview: () => Effect.succeed({ messages: [], nextBefore: null, truncated: false }), + selectedThread: () => + Effect.succeed(makeThreadOutcome({ ...source, providerInstanceId: alias })), + }; + const attach = attachAgentSession({ + projectId, + expectedWorkspaceRoot: workspaceRoot, + providerInstanceId: alias, + providerSessionId: source.providerSessionId, + }).pipe(Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner)); + expect(yield* attach).toEqual({ threadId }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages, + ).toHaveLength(2); + expect(yield* attach).toEqual({ threadId }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages, + ).toHaveLength(2); + expect(Option.getOrThrow(yield* directory.getBinding(threadId)).providerInstanceId).toBe( + source.providerInstanceId, + ); + expect( + yield* snapshots.getThreadDetailById( + ThreadId.make(`import:${alias}:${source.providerSessionId}`), + ), + ).toEqual(Option.none()); + }), + ); + + it.effect("repairs an attach interrupted after thread creation without duplicating history", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const projectId = ProjectId.make("partial-attach-project"); + const workspaceRoot = "/tmp/partial-attach-project"; + const source = { + ...makeThread("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("partial-claude"), + }; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("partial-attach-project"), + projectId, + title: "Partial", + workspaceRoot: workspaceRoot, + defaultModelSelection: null, + createdAt: source.createdAt, + }); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: source.providerInstanceId, + resumeCursor: { threadId, resume: source.providerSessionId }, + runtimePayload: { cwd: workspaceRoot }, + status: "stopped", + runtimeMode: "approval-required", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("partial-attach-thread"), + threadId, + projectId, + title: source.title, + modelSelection: { instanceId: source.providerInstanceId, model: "default" }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: source.createdAt, + historyImport: true, + }); + yield* attachAgentSession({ + projectId, + expectedWorkspaceRoot: workspaceRoot, + providerInstanceId: source.providerInstanceId, + providerSessionId: source.providerSessionId, + }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, { + ...integrationScanner, + selectedThread: () => Effect.succeed(makeThreadOutcome(source)), + }), + ); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages, + ).toHaveLength(2); + }), + ); it.effect("imports once after the real engine persists an old rejected receipt", () => Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; @@ -998,6 +1460,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { Effect.provideService( AgentSessionScanner.AgentSessionScanner, AgentSessionScanner.AgentSessionScanner.of({ + ...unusedSessionBrowser, scan: Effect.die("unused"), recentThreads: () => Stream.succeed(makeThreadOutcome(sourceThread)), }), @@ -1018,6 +1481,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { const providerSessionId = "codex-binding-race"; const threadId = ThreadId.make(`import:codex:${providerSessionId}`); const scanner = AgentSessionScanner.AgentSessionScanner.of({ + ...unusedSessionBrowser, scan: Effect.die("unused"), recentThreads: () => Stream.succeed( @@ -1112,6 +1576,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { const providerSessionId = "codex-turn-race"; const threadId = ThreadId.make(`import:codex:${providerSessionId}`); const scanner = AgentSessionScanner.AgentSessionScanner.of({ + ...unusedSessionBrowser, scan: Effect.die("unused"), recentThreads: () => Stream.succeed( diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index 3820c2cf411a..5418b1adea4d 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -1,5 +1,6 @@ import { CommandId, + CLAUDE_SESSION_ID_PATTERN, DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_PROVIDER_INTERACTION_MODE, @@ -7,6 +8,11 @@ import { AgentSessionImportProjectChangedError, AgentSessionImportProjectNotFoundError, AgentSessionSource, + AgentSessionUnavailableError, + type AgentSessionAttachInput, + type AgentSessionListInput, + type AgentSessionPreviewInput, + type AgentSessionSelection, AgentSessionScanError, isImportedAgentSessionMessageId, MessageId, @@ -23,15 +29,13 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as Semaphore from "effect/Semaphore"; import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; -const CLAUDE_SESSION_ID_PATTERN = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - class AgentSessionUnresumableSessionError extends Schema.TaggedErrorClass()( "AgentSessionUnresumableSessionError", { @@ -96,42 +100,199 @@ function hasImportBlockingActivity( ); } +// Bulk imports and same-home aliases must exclude each other before resolving an existing thread. +// Separate project and thread-ID locks would allow duplicate attachments through different aliases. +const importLock = Semaphore.makeUnsafe(1); + +const resolveProject = Effect.fn("AgentSessionImporter.resolveProject")(function* ( + input: AgentSessionImportInput, +) { + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const project = yield* snapshots + .getProjectShellById(input.projectId) + .pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + ); + if (Option.isNone(project)) + return yield* new AgentSessionImportProjectNotFoundError({ projectId: input.projectId }); + if ( + input.expectedWorkspaceRoot !== undefined && + normalizeProjectPathForComparison(project.value.workspaceRoot) !== + normalizeProjectPathForComparison(input.expectedWorkspaceRoot) + ) { + return yield* new AgentSessionImportProjectChangedError({ projectId: input.projectId }); + } + return project.value; +}); + +const importedThreadId = (selection: AgentSessionSelection) => + ThreadId.make(`import:${selection.providerInstanceId}:${selection.providerSessionId}`); +const decodeClaudeCursor = Schema.decodeUnknownOption(Schema.Struct({ resume: Schema.String })); +const existingSessionThreads = Effect.fn("AgentSessionImporter.existingSessionThreads")( + function* (projectId: ProjectId, selections: ReadonlyArray) { + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const homes = yield* scanner.claudeSessionHomes; + const key = (instanceId: AgentSessionSelection["providerInstanceId"], sessionId: string) => + `${homes.has(instanceId) ? `home:${homes.get(instanceId)}` : `instance:${instanceId}`}\0${sessionId}`; + const requested = new Set( + selections.map((selection) => key(selection.providerInstanceId, selection.providerSessionId)), + ); + const bindings = requested.size === 0 ? [] : yield* directory.listBindings(); + const candidates = bindings.flatMap((binding) => { + if (binding.provider !== "claudeAgent" || !binding.providerInstanceId) return []; + const cursor = decodeClaudeCursor(binding.resumeCursor); + if (Option.isNone(cursor)) return []; + const sessionKey = key(binding.providerInstanceId, cursor.value.resume); + return requested.has(sessionKey) + ? [ + { + key: sessionKey, + threadId: binding.threadId, + providerInstanceId: binding.providerInstanceId, + }, + ] + : []; + }); + const projects = new Map( + (yield* snapshots.getThreadProjectIds(candidates.map((candidate) => candidate.threadId))).map( + (thread) => [thread.threadId, thread.projectId], + ), + ); + const existing = new Map< + string, + { + threadId: ThreadId; + projectId: ProjectId; + providerInstanceId: AgentSessionSelection["providerInstanceId"]; + } + >(); + for (const candidate of candidates) { + const owner = projects.get(candidate.threadId); + if (owner !== undefined && (!existing.has(candidate.key) || owner === projectId)) { + existing.set(candidate.key, { + threadId: candidate.threadId, + projectId: owner, + providerInstanceId: candidate.providerInstanceId, + }); + } + } + return (selection: AgentSessionSelection) => + existing.get(key(selection.providerInstanceId, selection.providerSessionId)); + }, + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), +); + +export const listAgentSessions = Effect.fn("listAgentSessions")(function* ( + input: AgentSessionListInput, +) { + const project = yield* resolveProject(input); + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const result = yield* scanner.list(project.workspaceRoot, input.cursor); + const existing = yield* existingSessionThreads(input.projectId, result.sessions); + return { + ...result, + sessions: result.sessions.map((session) => { + const match = existing(session); + return { + ...session, + existingThreadId: match?.projectId === input.projectId ? match.threadId : null, + }; + }), + }; +}); + +export const previewAgentSession = Effect.fn("previewAgentSession")(function* ( + input: AgentSessionPreviewInput, +) { + const project = yield* resolveProject(input); + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.preview(project.workspaceRoot, input, input.before); +}); + +export const attachAgentSession = Effect.fn("attachAgentSession")(function* ( + input: AgentSessionAttachInput, +) { + const project = yield* resolveProject(input); + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const existing = (yield* existingSessionThreads(input.projectId, [input]))(input); + if (existing) { + if (existing.projectId !== input.projectId) { + return yield* new AgentSessionUnavailableError({ + message: + "This session is already attached to another project. Open it from that project instead.", + }); + } + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const isImported = + existing.threadId === + importedThreadId({ + ...input, + providerInstanceId: existing.providerInstanceId, + }); + const imported = isImported + ? yield* snapshots + .getThreadDetailById(existing.threadId) + .pipe( + Effect.mapError( + (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), + ), + ) + : Option.none(); + if (!isImported || (Option.isSome(imported) && hasImportedHistory(imported.value))) { + yield* scanner.preview(project.workspaceRoot, input); + return { threadId: existing.threadId }; + } + } + const selection = { + ...input, + providerInstanceId: existing?.providerInstanceId ?? input.providerInstanceId, + }; + const discovered = yield* scanner.selectedThread(project.workspaceRoot, input); + const outcome = { + ...discovered, + thread: { ...discovered.thread, providerInstanceId: selection.providerInstanceId }, + source: { ...discovered.source, providerInstanceId: selection.providerInstanceId }, + }; + const result = yield* importAgentThreads(input, outcome); + if (result.importedCount !== 1) + return yield* new AgentSessionUnavailableError({ + message: "The session could not be attached. Refresh and try again.", + }); + return { threadId: importedThreadId(selection) }; +}, importLock.withPermits(1)); + /** Import recent transcript text and persist the cursor needed to resume its provider session. */ export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(function* ( input: AgentSessionImportInput, +) { + return yield* importAgentThreads(input); +}, importLock.withPermits(1)); + +const importAgentThreads = Effect.fn("AgentSessionImporter.importAgentThreads")(function* ( + input: AgentSessionImportInput, + selected?: Extract, ) { const scanner = yield* AgentSessionScanner.AgentSessionScanner; const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const crypto = yield* Crypto.Crypto; - const project = yield* snapshots.getProjectShellById(input.projectId).pipe( - Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail(new AgentSessionImportProjectNotFoundError({ projectId: input.projectId })), - onSome: Effect.succeed, - }), - ), - ); + const project = yield* resolveProject(input); const workspaceRoot = project.workspaceRoot; - if ( - input.expectedWorkspaceRoot !== undefined && - normalizeProjectPathForComparison(workspaceRoot) !== - normalizeProjectPathForComparison(input.expectedWorkspaceRoot) - ) { - return yield* new AgentSessionImportProjectChangedError({ projectId: input.projectId }); - } + const runtimeMode = selected ? "approval-required" : DEFAULT_RUNTIME_MODE; const completedSources = yield* snapshots .getImportedAgentSessionSources(input.projectId) .pipe( Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), ); - const threads = scanner.recentThreads( - workspaceRoot, - completedSources.map((entry) => entry.source), - ); + const threads = selected + ? Stream.succeed(selected) + : scanner.recentThreads( + workspaceRoot, + completedSources.map((entry) => entry.source), + ); const importedThreadIds = new Set(); let importedCount = 0; let skippedCount = 0; @@ -229,7 +390,7 @@ export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(fu provider, providerInstanceId: thread.providerInstanceId, status: "stopped", - runtimeMode: DEFAULT_RUNTIME_MODE, + runtimeMode, resumeCursor: thread.source === "codex" ? { threadId: thread.providerSessionId } @@ -248,7 +409,7 @@ export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(fu projectId: input.projectId, title: thread.title, modelSelection: { instanceId: thread.providerInstanceId, model }, - runtimeMode: DEFAULT_RUNTIME_MODE, + runtimeMode, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, branch: null, worktreePath: null, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index c797eab6f550..d20d7894d291 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -14,6 +14,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -57,6 +58,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), @@ -79,7 +81,7 @@ interface ScannerTestInput { const makeScannerTestLayer = (input: ScannerTestInput) => AgentSessionScanner.layer.pipe( - Layer.provide( + Layer.provideMerge( Layer.mergeAll( ServerSettings.layerTest({ providers: { @@ -177,6 +179,494 @@ function makeRecordLimitTranscript(cwd: string, overflow: boolean): string { } it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { + const sessionId = (index: number) => `123e4567-e89b-42d3-a456-${String(index).padStart(12, "0")}`; + const sessionRecord = (cwd: string, id: string, text: string, extra = {}) => + JSON.stringify({ + type: "user", + cwd, + sessionId: id, + timestamp: "2025-01-01T00:00:00.000Z", + message: { content: text }, + ...extra, + }) + "\n"; + + it.effect( + "lists older sessions by directory and configured provider, omitting malformed and subagent files", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* makeTempDir("session-browser-"); + const cwd = `${root}/workspace`; + yield* fs.makeDirectory(cwd); + const alias = `${root}/workspace-alias`; + yield* fs.symlink(cwd, alias); + const home = `${root}/claude`; + for (let index = 0; index < 43; index++) { + yield* writeTranscript({ + filePath: `${home}/projects/project/${sessionId(index)}.jsonl`, + contents: sessionRecord(alias, sessionId(index), `Request ${index}`, { + gitBranch: "feature", + }), + mtimeMs: 1_735_689_600_000 + index * 1000, + }); + } + for (const [index, contents] of [ + [50, sessionRecord(`${root}/other`, sessionId(50), "Wrong cwd")], + [51, sessionRecord(cwd, sessionId(51), "Subagent", { isSidechain: true })], + [52, "malformed\n"], + ] as const) + yield* writeTranscript({ + filePath: `${home}/projects/project/${sessionId(index)}.jsonl`, + contents, + mtimeMs: 1_735_689_600_000, + }); + yield* writeTranscript({ + filePath: `${home}/projects/project/subagents/${sessionId(53)}.jsonl`, + contents: sessionRecord(cwd, sessionId(53), "Nested subagent"), + mtimeMs: 1_735_689_600_000, + }); + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const canonicalCwd = yield* fs.realPath(cwd); + const first = yield* scanner.list(canonicalCwd.replace(/^\/private\/tmp\//, "/tmp/")); + expect(first.sessions).toHaveLength(40); + expect(first.sessions[0]).toMatchObject({ + title: "Request 42", + branch: "feature", + providerInstanceId: "claudeAgent", + }); + expect(first.nextCursor).toEqual(expect.any(String)); + const next = yield* scanner.list(cwd, first.nextCursor ?? undefined); + expect([...first.sessions, ...next.sessions]).toHaveLength(43); + expect(next.nextCursor).toBeNull(); + }).pipe( + Effect.provide( + makeScannerTestLayer({ claudeHomePath: home, codexHomePath: `${root}/codex` }), + ), + ); + }), + ); + + it.effect("keeps the original listing order when sessions change between pages", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* makeTempDir("session-pagination-"); + const cwd = `${root}/workspace`; + const home = `${root}/claude`; + yield* fs.makeDirectory(cwd); + for (let index = 0; index < 43; index++) { + yield* writeTranscript({ + filePath: `${home}/projects/project/${sessionId(index)}.jsonl`, + contents: sessionRecord(cwd, sessionId(index), `Request ${index}`), + mtimeMs: 1_735_689_600_000 + index * 1000, + }); + } + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const first = yield* scanner.list(cwd); + expect(first.sessions).toHaveLength(40); + expect(first.nextCursor).not.toBeNull(); + yield* fs.utimes( + `${home}/projects/project/${sessionId(0)}.jsonl`, + 1_735_700_000, + 1_735_700_000, + ); + yield* writeTranscript({ + filePath: `${home}/projects/project/${sessionId(43)}.jsonl`, + contents: sessionRecord(cwd, sessionId(43), "New session"), + mtimeMs: 1_735_700_001_000, + }); + const next = yield* scanner.list(cwd, first.nextCursor ?? undefined); + const ids = [...first.sessions, ...next.sessions].map( + (session) => session.providerSessionId, + ); + expect(ids).toEqual(Array.from({ length: 43 }, (_, index) => sessionId(42 - index))); + expect(next.nextCursor).toBeNull(); + yield* writeTranscript({ + filePath: `${home}/projects/project/${sessionId(2)}.jsonl`, + contents: sessionRecord(cwd, sessionId(2), "Changed after reading page two"), + mtimeMs: 1_735_689_602_000, + }); + const replay = yield* scanner.list(cwd, first.nextCursor ?? undefined); + expect(replay).toEqual(next); + const refreshed = yield* scanner.list(cwd); + expect(refreshed.sessions[0]?.providerSessionId).toBe(sessionId(43)); + expect(refreshed.sessions[1]?.providerSessionId).toBe(sessionId(0)); + yield* fs.makeDirectory(`${root}/other-project`); + for (const { workspaceRoot, cursor } of [ + { workspaceRoot: `${root}/other-project`, cursor: first.nextCursor ?? "" }, + { workspaceRoot: cwd, cursor: "not-a-cursor" }, + { workspaceRoot: cwd, cursor: "" }, + ]) { + const failed = yield* scanner.list(workspaceRoot, cursor).pipe(Effect.result); + expect(failed).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "AgentSessionUnavailableError", + message: expect.stringContaining("Refresh"), + }, + }); + } + yield* TestClock.adjust("5 minutes"); + expect(yield* scanner.list(cwd, first.nextCursor ?? "").pipe(Effect.result)).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "AgentSessionUnavailableError", + message: expect.stringContaining("expired"), + }, + }); + }).pipe( + Effect.provide( + makeScannerTestLayer({ claudeHomePath: home, codexHomePath: `${root}/codex` }), + ), + ); + }), + ); + + it.effect("invalidates listing cursors after home changes and bounds retained snapshots", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* makeTempDir("session-pagination-scope-"); + const cwd = `${root}/workspace`; + const home = `${root}/claude`; + yield* fs.makeDirectory(cwd); + for (let index = 0; index < 41; index++) { + yield* writeTranscript({ + filePath: `${home}/projects/project/${sessionId(index)}.jsonl`, + contents: sessionRecord(cwd, sessionId(index), `Request ${index}`), + mtimeMs: 1_735_689_600_000 + index * 1000, + }); + } + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const settings = yield* ServerSettings.ServerSettingsService; + const first = yield* scanner.list(cwd); + expect(first.nextCursor).not.toBeNull(); + yield* settings.updateSettings({ + providers: { claudeAgent: { homePath: `${root}/different-home` } }, + }); + expect(yield* scanner.list(cwd, first.nextCursor ?? "").pipe(Effect.result)).toMatchObject({ + _tag: "Failure", + failure: { _tag: "AgentSessionUnavailableError" }, + }); + yield* settings.updateSettings({ providers: { claudeAgent: { homePath: home } } }); + yield* fs.rename(home, `${root}/old-home`); + yield* fs.makeDirectory(home); + expect(yield* scanner.list(cwd, first.nextCursor ?? "").pipe(Effect.result)).toMatchObject({ + _tag: "Failure", + failure: { _tag: "AgentSessionUnavailableError" }, + }); + yield* fs.remove(home, { recursive: true }); + yield* fs.rename(`${root}/old-home`, home); + for (let index = 0; index < 8; index++) yield* scanner.list(cwd); + expect(yield* scanner.list(cwd, first.nextCursor ?? "").pipe(Effect.result)).toMatchObject({ + _tag: "Failure", + failure: { _tag: "AgentSessionUnavailableError" }, + }); + const fresh = yield* scanner.list(cwd); + expect((yield* scanner.list(cwd, fresh.nextCursor ?? "")).sessions).toHaveLength(1); + }).pipe( + Effect.provide( + makeScannerTestLayer({ claudeHomePath: home, codexHomePath: `${root}/codex` }), + ), + ); + }), + ); + + it.effect( + "pages actual conversation backwards without losing exchanges or showing tool/control records", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* makeTempDir("session-preview-"); + const cwd = `${root}/workspace`; + yield* fs.makeDirectory(cwd); + const home = `${root}/claude`; + const selection = { + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerSessionId: sessionId(100), + }; + const records = Array.from({ length: 91 }, (_, index) => + sessionRecord(cwd, selection.providerSessionId, `Message ${index}`, { + type: index % 2 === 0 ? "user" : "assistant", + }), + ); + records.splice( + 3, + 0, + sessionRecord(cwd, selection.providerSessionId, "Hidden metadata", { isMeta: true }), + sessionRecord(cwd, selection.providerSessionId, "", { + message: { content: [{ type: "tool_result", text: "Tool output" }] }, + }), + ); + yield* writeTranscript({ + filePath: `${home}/projects/project/${selection.providerSessionId}.jsonl`, + contents: records.join(""), + mtimeMs: 1_735_689_600_000, + }); + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + let page = yield* scanner.preview(cwd, selection); + expect(page.messages[0]?.text).toBe("Message 61"); + const messages = [...page.messages]; + while (page.nextBefore !== null) { + page = yield* scanner.preview(cwd, selection, page.nextBefore); + messages.unshift(...page.messages); + } + expect(messages.map((message) => message.text)).toEqual( + Array.from({ length: 91 }, (_, i) => `Message ${i}`), + ); + const selected = yield* scanner.selectedThread(cwd, selection); + expect(selected.thread.providerSessionId).toBe(selection.providerSessionId); + expect(selected.thread.messages).toHaveLength(91); + expect( + (yield* scanner.preview(`${root}/other`, selection).pipe(Effect.result))._tag, + ).toBe("Failure"); + yield* fs.writeFileString( + `${home}/projects/project/${selection.providerSessionId}.jsonl`, + sessionRecord(`${root}/other`, selection.providerSessionId, "Changed cwd"), + ); + expect((yield* scanner.selectedThread(cwd, selection).pipe(Effect.result))._tag).toBe( + "Failure", + ); + }).pipe( + Effect.provide( + makeScannerTestLayer({ claudeHomePath: home, codexHomePath: `${root}/codex` }), + ), + ); + }), + ); + + it.effect( + "bounds preview I/O for oversized records and honors provider-instance Claude homes", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* makeTempDir("session-bounds-"); + const cwd = `${root}/workspace`; + yield* fs.makeDirectory(cwd); + const home = `${root}/custom-claude`; + const selection = { + providerInstanceId: ProviderInstanceId.make("work-claude"), + providerSessionId: sessionId(200), + }; + const filename = `${home}/projects/project/${selection.providerSessionId}.jsonl`; + yield* writeTranscript({ + filePath: filename, + contents: + sessionRecord(cwd, selection.providerSessionId, "First request") + + sessionRecord(cwd, selection.providerSessionId, "x".repeat(2 * 1024 * 1024), { + type: "assistant", + }) + + sessionRecord(cwd, selection.providerSessionId, "Latest answer", { type: "assistant" }), + mtimeMs: 1_735_689_600_000, + }); + let bytesRequested = 0; + let maxRead = Infinity; + const countedFs = { + ...fs, + open: (...args: Parameters) => + fs.open(...args).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + seek: (offset: FileSystem.SizeInput, from: FileSystem.SeekMode) => + file.seek(offset, from), + readAlloc: (size: FileSystem.SizeInput) => { + bytesRequested += Number(size); + return file.readAlloc(Math.min(Number(size), maxRead)); + }, + })), + ), + }; + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const page = yield* scanner.preview(cwd, selection); + expect(page.messages.map((message) => message.text)).toEqual(["Latest answer"]); + expect(page.nextBefore).not.toBeNull(); + expect(bytesRequested).toBeLessThan(512 * 1024); + const previous = yield* scanner.preview(cwd, selection, page.nextBefore ?? 0); + expect(previous.truncated).toBe(true); + maxRead = 7; + yield* fs.writeFileString( + filename, + sessionRecord(cwd, selection.providerSessionId, "héllo 世界"), + ); + expect( + (yield* scanner.preview(cwd, selection)).messages.map((message) => message.text), + ).toEqual(["héllo 世界"]); + expect( + (yield* scanner + .preview(cwd, { + ...selection, + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + }) + .pipe(Effect.result))._tag, + ).toBe("Failure"); + }).pipe( + Effect.provide( + makeScannerTestLayer({ + claudeHomePath: `${root}/unused`, + codexHomePath: `${root}/codex`, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + config: {}, + }, + [ProviderInstanceId.make("work-claude")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: {}, + environment: [{ name: "CLAUDE_CONFIG_DIR", value: home, sensitive: false }], + }, + }, + }).pipe(Layer.provide(Layer.succeed(FileSystem.FileSystem, countedFs))), + ), + ); + }), + ); + it.effect("finds the first request beyond a large initial control record", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* makeTempDir("session-prefix-"); + const cwd = `${root}/workspace`; + const home = `${root}/claude`; + yield* fs.makeDirectory(cwd); + yield* writeTranscript({ + filePath: `${home}/projects/project/${sessionId(201)}.jsonl`, + contents: + encodeTranscriptRecord({ + type: "file-history-snapshot", + snapshot: "x".repeat(80 * 1024), + }) + + "\n" + + sessionRecord(cwd, sessionId(201), "First request after setup") + + sessionRecord(cwd, sessionId(201), "The answer", { type: "assistant" }), + mtimeMs: 1_735_689_600_000, + }); + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const listed = yield* scanner.list(cwd); + expect(listed.sessions).toHaveLength(1); + expect(listed.sessions[0]?.firstRequest).toBe("First request after setup"); + expect(listed.truncated).toBe(false); + }).pipe( + Effect.provide( + makeScannerTestLayer({ claudeHomePath: home, codexHomePath: `${root}/codex` }), + ), + ); + }), + ); + + it.effect("keeps valid sessions when a child directory or transcript is unreadable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* makeTempDir("session-unreadable-"); + const cwd = `${root}/workspace`; + const home = `${root}/claude`; + const blocked = `${home}/projects/blocked`; + yield* fs.makeDirectory(cwd); + yield* fs.makeDirectory(blocked, { recursive: true }); + yield* writeTranscript({ + filePath: `${home}/projects/project/${sessionId(202)}.jsonl`, + contents: sessionRecord(cwd, sessionId(202), "Readable session"), + mtimeMs: 1_735_689_600_000, + }); + yield* fs.symlink(`${root}/missing`, `${home}/projects/project/${sessionId(203)}.jsonl`); + let blockedDirectory = blocked; + const simulatedFs = FileSystem.FileSystem.of({ + ...fs, + readDirectory: (directory, options) => + directory === blockedDirectory + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readDirectory", + pathOrDescriptor: directory, + }), + ) + : fs.readDirectory(directory, options), + }); + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const listed = yield* scanner.list(cwd); + expect(listed.sessions.map((session) => session.firstRequest)).toEqual([ + "Readable session", + ]); + expect(listed.truncated).toBe(true); + blockedDirectory = `${home}/projects`; + const failed = yield* scanner.list(cwd).pipe(Effect.result); + expect(failed).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "AgentSessionScanError", + operation: "read-projects", + cause: { reason: { _tag: "PermissionDenied", pathOrDescriptor: `${home}/projects` } }, + }, + }); + }).pipe( + Effect.provide( + makeScannerTestLayer({ claudeHomePath: home, codexHomePath: `${root}/codex` }).pipe( + Layer.provide(Layer.succeed(FileSystem.FileSystem, simulatedFs)), + ), + ), + ); + }), + ); + + it.effect( + "reuses selected locators without rescanning homes and rejects a replaced transcript", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* makeTempDir("session-locator-"); + const cwd = `${root}/workspace`; + const home = `${root}/claude`; + const filename = `${home}/projects/project/${sessionId(204)}.jsonl`; + yield* fs.makeDirectory(cwd); + yield* writeTranscript({ + filePath: filename, + contents: Array.from({ length: 61 }, (_, i) => + sessionRecord(cwd, sessionId(204), `Message ${i}`), + ).join(""), + mtimeMs: 1_735_689_600_000, + }); + let directoryReads = 0; + const countedFs = { + ...fs, + readDirectory: (...args: Parameters) => { + directoryReads += 1; + return fs.readDirectory(...args); + }, + }; + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + yield* scanner.list(cwd); + const readsAfterListing = directoryReads; + const selection = { + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerSessionId: sessionId(204), + }; + const latest = yield* scanner.preview(cwd, selection); + yield* scanner.preview(cwd, selection, latest.nextBefore ?? 0); + yield* scanner.selectedThread(cwd, selection); + expect(directoryReads).toBe(readsAfterListing); + yield* fs.writeFileString( + filename, + sessionRecord(`${root}/another-project`, sessionId(204), "Replaced session"), + ); + expect((yield* scanner.preview(cwd, selection).pipe(Effect.result))._tag).toBe("Failure"); + }).pipe( + Effect.provide( + makeScannerTestLayer({ claudeHomePath: home, codexHomePath: `${root}/codex` }).pipe( + Layer.provide(Layer.succeed(FileSystem.FileSystem, countedFs)), + ), + ), + ); + }), + ); + describe("scan", () => { it.effect("reads Claude project cwds from transcripts, newest first", () => Effect.gen(function* () { diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 1dfd1d5df8af..faba1c0a589b 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -13,11 +13,17 @@ * * @module project/AgentSessionScanner */ +import * as NodeCrypto from "node:crypto"; import * as NodeOS from "node:os"; import { AgentSessionScanError, + AgentSessionUnavailableError, + type AgentSessionSelection, + type AgentSessionListResult, + type AgentSessionPreviewResult, ClaudeSettings, + CLAUDE_SESSION_ID_PATTERN, CodexSettings, ProviderDriverKind, ProviderInstanceId, @@ -94,6 +100,9 @@ const MAX_IMPORT_HISTORY_BYTES = 32 * 1024 * 1024; const MAX_IMPORT_BYTES = 4 * 1024 * 1024 * 1024; const MAX_IMPORT_TRANSCRIPTS = 100; const MAX_IMPORT_RECORDS = 100_000; +const SESSION_LIST_PAGE_SIZE = 40; +const SESSION_LIST_TTL_MS = 5 * 60 * 1000; +const MAX_SESSION_LIST_SNAPSHOTS = 8; const TranscriptContentBlock = Schema.Struct({ type: Schema.optional(Schema.String), @@ -116,6 +125,7 @@ const TranscriptRecord = Schema.Struct({ cwd: Schema.optional(Schema.String), sessionId: Schema.optional(Schema.String), aiTitle: Schema.optional(Schema.String), + gitBranch: Schema.optional(Schema.String), isSidechain: Schema.optional(Schema.Boolean), isMeta: Schema.optional(Schema.Boolean), isCompactSummary: Schema.optional(Schema.Boolean), @@ -189,6 +199,32 @@ export class AgentSessionScanner extends Context.Service< * error directly — there is no server-local context worth wrapping. */ readonly scan: Effect.Effect; + readonly claudeSessionHomes: Effect.Effect< + ReadonlyMap, + AgentSessionScanError + >; + readonly list: ( + workspaceRoot: string, + cursor?: string, + ) => Effect.Effect< + AgentSessionListResult, + AgentSessionScanError | AgentSessionUnavailableError + >; + readonly preview: ( + workspaceRoot: string, + selection: AgentSessionSelection, + before?: number, + ) => Effect.Effect< + AgentSessionPreviewResult, + AgentSessionScanError | AgentSessionUnavailableError + >; + readonly selectedThread: ( + workspaceRoot: string, + selection: AgentSessionSelection, + ) => Effect.Effect< + Extract, + AgentSessionScanError | AgentSessionUnavailableError + >; readonly recentThreads: ( workspaceRoot: string, completedSources?: ReadonlyArray, @@ -265,6 +301,30 @@ function extractText( .join("\n"); } +function claudeVisibleMessage( + record: DecodedTranscriptRecord, + fallbackTimestamp: string, +): AgentSessionThreadMessage | null { + if ( + record.isSidechain || + record.isMeta || + record.isCompactSummary || + (record.type !== "user" && record.type !== "assistant") + ) + return null; + const text = extractText(record.message?.content); + if ( + !text || + /^<(?:local-command-caveat|local-command-stdout|command-name|system-reminder)>/.test(text) + ) + return null; + return { + role: record.type, + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + }; +} + function normalizeTimestamp(value: string | undefined, fallback: string): string { if (value === undefined) return fallback; const parsed = DateTime.make(value); @@ -409,18 +469,9 @@ function parseAgentSessionRecords( // Claude uses this sentinel for local error responses. It is not a // model ID that can be selected when the imported session resumes. if (messageModel && messageModel !== "") model = messageModel; - if (record.type !== "user" && record.type !== "assistant") { - continue; - } - - const text = extractText(record.message?.content); - if (text.length === 0) continue; - retainMessage({ - role: record.type, - text, - createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), - codexResponseUser: false, - }); + const message = claudeVisibleMessage(record, fallbackTimestamp); + if (message === null) continue; + retainMessage({ ...message, codexResponseUser: false }); continue; } @@ -818,8 +869,13 @@ export const make = Effect.gen(function* () { expected: ReturnType, recordLimit: number, source: AgentSessionSource, + prefixOnly = false, ) { - if (expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; + if (!prefixOnly && expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; + const maxBytes = prefixOnly + ? Math.min(expected.size, MAX_TRANSCRIPT_SCAN_BYTES) + : expected.size; + const missingPrefixFields = new Set(["metadata", "request"]); return yield* Effect.scoped( fileSystem.open(filePath, { flag: "r" }).pipe( @@ -853,17 +909,23 @@ export const make = Effect.gen(function* () { if (Option.isSome(decoded) && shouldRetainDecodedRecord(source, decoded.value)) { records.push(decoded.value); historyBytes += recordBytes; + if (prefixOnly) { + if (decoded.value.cwd && decoded.value.sessionId) + missingPrefixFields.delete("metadata"); + if (claudeVisibleMessage(decoded.value, "")?.role === "user") + missingPrefixFields.delete("request"); + } } recordBytes = 0; reader = createTranscriptJsonReader(reserve, selectTranscriptPath); decoder = new TextDecoder(); recordStarted = false; - return true; + return !prefixOnly || missingPrefixFields.size > 0; }; - while (bytesRead < expected.size) { + while (bytesRead < maxBytes) { const next = yield* file.readAlloc( - Math.min(TRANSCRIPT_PREFIX_BYTES, expected.size - bytesRead), + Math.min(TRANSCRIPT_PREFIX_BYTES, maxBytes - bytesRead), ); if (Option.isNone(next)) { return null; @@ -883,10 +945,20 @@ export const make = Effect.gen(function* () { } return true; }); - if (!withinBudget) return null; + if (!withinBudget) { + if (prefixOnly && missingPrefixFields.size === 0) break; + return null; + } } - if (recordStarted && !(yield* Effect.try(finishRecord))) return null; + if ( + recordStarted && + bytesRead === expected.size && + !(yield* Effect.try(finishRecord)) && + (!prefixOnly || missingPrefixFields.size > 0) + ) + return null; + if (prefixOnly && missingPrefixFields.size > 0) return null; return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) ? { records, recordCount } : null; @@ -921,7 +993,12 @@ export const make = Effect.gen(function* () { }; const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")( - function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { + function* ( + homePath: string, + providerInstanceId: ProviderInstanceId, + operationBudget: number, + strict = false, + ) { const projectsDir = path.join(homePath, "projects"); let operationsRemaining = operationBudget; let truncated = false; @@ -931,7 +1008,19 @@ export const make = Effect.gen(function* () { return Effect.succeed>([]); } operationsRemaining -= 1; - return listDirectory(directory); + return strict + ? fileSystem.readDirectory(directory).pipe( + Effect.catch((cause) => { + if (directory === projectsDir && cause.reason._tag !== "NotFound") { + return Effect.fail( + new AgentSessionScanError({ operation: "read-projects", cause }), + ); + } + if (directory !== projectsDir) truncated = true; + return Effect.succeed>([]); + }), + ) + : listDirectory(directory); }; const projectDirectories = yield* readDirectory(projectsDir); const transcripts: Array = []; @@ -958,6 +1047,7 @@ export const make = Effect.gen(function* () { stats.value.type !== "File" || Option.isNone(stats.value.mtime) ) { + if (strict) truncated = true; continue; } transcripts.push({ @@ -1081,80 +1171,105 @@ export const make = Effect.gen(function* () { })); }); - const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () { + const resolveSourceHomes = Effect.fn("AgentSessionScanner.resolveSourceHomes")(function* ( + source: AgentSessionSource, + ) { const settings = yield* serverSettings.getSettings.pipe( Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), ); + const instances: Array<{ + readonly instanceId: ProviderInstanceId; + readonly config: ProviderInstanceConfig; + }> = Object.entries(settings.providerInstances) + .filter(([, instance]) => instance.driver === source) + .map(([instanceId, config]) => ({ + instanceId: ProviderInstanceId.make(instanceId), + config, + })); + if (!Object.hasOwn(settings.providerInstances, source)) { + const legacyInstance = { + instanceId: ProviderInstanceId.make(source), + config: { + driver: ProviderDriverKind.make(source), + config: settings.providers[source], + }, + }; + instances.push(legacyInstance); + } - const raw: Array = []; - let truncated = false; - - for (const source of ["claudeAgent", "codex"] as const) { - const instances: Array<{ - readonly instanceId: ProviderInstanceId; - readonly config: ProviderInstanceConfig; - }> = Object.entries(settings.providerInstances) - .filter( - ([, instance]) => instance.driver === source && resolveProviderInstanceEnabled(instance), - ) - .map(([instanceId, config]) => ({ - instanceId: ProviderInstanceId.make(instanceId), - config, - })); - if (!Object.hasOwn(settings.providerInstances, source)) { - const legacyInstance = { - instanceId: ProviderInstanceId.make(source), - config: { - driver: ProviderDriverKind.make(source), - config: settings.providers[source], - }, - }; - if (resolveProviderInstanceEnabled(legacyInstance.config)) { - instances.push(legacyInstance); - } + // A shared home contains one copy of each session. Prefer the built-in + // instance as its owner, then keep configured order for custom accounts. + instances.sort((left, right) => { + const leftDefault = left.instanceId === source ? 0 : 1; + const rightDefault = right.instanceId === source ? 0 : 1; + return leftDefault - rightDefault; + }); + const homes: Array<{ + homePath: string; + homeKey: string; + providerInstanceId: ProviderInstanceId; + enabled: boolean; + }> = []; + const identityByPath = new Map(); + for (const { instanceId, config: instance } of instances) { + const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; + const environmentHome = + instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ?? + hostEnvironment[homeVariable]; + + let homePath: string; + if (source === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); + } else { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + const codexSettings = + config.value.homePath.trim().length === 0 && + config.value.shadowHomePath.trim().length === 0 && + environmentHome?.trim() + ? { ...config.value, homePath: environmentHome } + : config.value; + const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( + Effect.provideService(Path.Path, path), + ); + homePath = layout.sharedHomePath; } - // A shared home contains one copy of each session. Prefer the built-in - // instance as its owner, then keep configured order for custom accounts. - instances.sort((left, right) => { - const leftDefault = left.instanceId === source ? 0 : 1; - const rightDefault = right.instanceId === source ? 0 : 1; - return leftDefault - rightDefault; + const homeKey = identityByPath.get(homePath) ?? (yield* directoryIdentity(homePath)); + identityByPath.set(homePath, homeKey); + homes.push({ + homePath, + homeKey, + providerInstanceId: instanceId, + enabled: resolveProviderInstanceEnabled(instance), }); - const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; - const seenHomes = new Set(); - for (const { instanceId, config: instance } of instances) { - const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; - const environmentHome = - instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ?? - hostEnvironment[homeVariable]; - - let homePath: string; - if (source === "claudeAgent") { - const config = decodeClaudeSettings(instance.config ?? {}); - if (Option.isNone(config)) continue; - homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); - } else { - const config = decodeCodexSettings(instance.config ?? {}); - if (Option.isNone(config)) continue; - const codexSettings = - config.value.homePath.trim().length === 0 && - config.value.shadowHomePath.trim().length === 0 && - environmentHome?.trim() - ? { ...config.value, homePath: environmentHome } - : config.value; - const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( - Effect.provideService(Path.Path, path), - ); - homePath = layout.sharedHomePath; - } + } - const homeKey = `${source}\0${yield* directoryIdentity(homePath)}`; - if (seenHomes.has(homeKey)) continue; - seenHomes.add(homeKey); - homes.push({ homePath, providerInstanceId: instanceId }); - } + return homes; + }); + const claudeSessionHomes = resolveSourceHomes("claudeAgent").pipe( + Effect.map((homes) => new Map(homes.map((home) => [home.providerInstanceId, home.homeKey]))), + ); + + const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* ( + claudeOnly = false, + claudeHomes?: Effect.Success>, + ) { + const raw: Array = []; + let truncated = false; + for (const source of ["claudeAgent", "codex"] as const) { + if (claudeOnly && source !== "claudeAgent") continue; + const sourceHomes = + source === "claudeAgent" && claudeHomes ? claudeHomes : yield* resolveSourceHomes(source); + const seenHomes = new Set(); + const homes = sourceHomes.filter((home) => { + if (!home.enabled || seenHomes.has(home.homeKey)) return false; + seenHomes.add(home.homeKey); + return true; + }); const transcriptCandidates: Array = []; const baseOperationBudget = Math.floor( MAX_DISCOVERY_OPERATIONS_PER_SOURCE / Math.max(1, homes.length), @@ -1167,7 +1282,12 @@ export const make = Effect.gen(function* () { continue; } const discovered = yield* source === "claudeAgent" - ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget) + ? discoverClaudeTranscripts( + home.homePath, + home.providerInstanceId, + operationBudget, + claudeOnly, + ) : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget); truncated ||= discovered.truncated; transcriptCandidates.push(...discovered.transcripts); @@ -1488,7 +1608,370 @@ export const make = Effect.gen(function* () { completedSources = [], ) => Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources)); - return AgentSessionScanner.of({ scan, recentThreads }); + const unavailable = () => + new AgentSessionUnavailableError({ + message: "This session is unavailable or changed. Refresh the session list.", + }); + + const sessionLocators = new Map< + string, + { transcript: Omit; homePath: string } + >(); + const locatorKey = (root: string, home: string, sessionId: string) => + `${root}\0${home}\0${sessionId}`; + const scopedTranscripts = Effect.fn("AgentSessionScanner.scopedTranscripts")(function* ( + workspaceRoot: string, + configuredHomes?: Effect.Success>, + ) { + const rootIdentity = yield* directoryIdentity(workspaceRoot); + const homes = configuredHomes ?? (yield* resolveSourceHomes("claudeAgent")); + const homeByInstance = new Map(homes.map((home) => [home.providerInstanceId, home])); + const discovered = yield* collectCandidates(true, homes); + const transcripts: Array> = []; + for (const candidate of discovered.candidates) { + if ( + !path.isAbsolute(candidate.cwd) || + (yield* directoryIdentity(candidate.cwd)) !== rootIdentity + ) + continue; + for (const transcript of candidate.transcripts) { + if ( + transcript.mtimeMs === null || + !CLAUDE_SESSION_ID_PATTERN.test(path.basename(transcript.filePath, ".jsonl")) + ) + continue; + transcripts.push({ + ...transcript, + mtimeMs: transcript.mtimeMs, + providerInstanceId: candidate.providerInstanceId, + }); + } + } + transcripts.sort((a, b) => b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath)); + const seen = new Set(); + return { + transcripts: transcripts.filter((transcript) => { + const key = `${transcript.providerInstanceId}\0${path.basename(transcript.filePath)}`; + if (seen.has(key)) return false; + seen.add(key); + const home = homeByInstance.get(transcript.providerInstanceId); + if (!home) return false; + const cacheKey = locatorKey( + rootIdentity, + home.homeKey, + path.basename(transcript.filePath, ".jsonl"), + ); + sessionLocators.delete(cacheKey); + if (sessionLocators.size >= MAX_TRANSCRIPTS_PER_SOURCE) { + const oldest = sessionLocators.keys().next().value; + if (oldest !== undefined) sessionLocators.delete(oldest); + } + sessionLocators.set(cacheKey, { transcript, homePath: home.homePath }); + return true; + }), + truncated: discovered.truncated, + }; + }); + + const readWindow = Effect.fn("AgentSessionScanner.readWindow")(function* ( + identity: ReturnType, + end: number, + maxBytes: number, + ) { + return yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fileSystem.open(identity.filePath, { flag: "r" }); + if ( + !sameTranscriptIdentity(identity, transcriptIdentity(identity.filePath, yield* file.stat)) + ) + return yield* unavailable(); + const start = Math.max(0, end - maxBytes); + yield* file.seek(start, "start"); + const bytes = new Uint8Array(end - start); + let filled = 0; + while (filled < bytes.length) { + const chunk = yield* file.readAlloc(bytes.length - filled); + if (Option.isNone(chunk) || chunk.value.length === 0) return yield* unavailable(); + bytes.set(chunk.value, filled); + filled += chunk.value.length; + } + const rows: Array<{ record: DecodedTranscriptRecord; start: number }> = []; + let offset = start === 0 ? 0 : bytes.indexOf(10) + 1; + const oversizedRecord = start > 0 && (offset === 0 || offset === bytes.length); + const nextBefore = start === 0 ? null : oversizedRecord ? start : start + offset; + let truncated = oversizedRecord; + while (offset < bytes.length) { + const newline = bytes.indexOf(10, offset); + if (newline === -1 && end !== identity.size) break; + const decoded = decodeTranscriptRecord( + new TextDecoder().decode( + bytes.subarray(offset, newline === -1 ? bytes.length : newline), + ), + ); + if (Option.isSome(decoded)) rows.push({ record: decoded.value, start: start + offset }); + else truncated = true; + if (newline === -1) break; + offset = newline + 1; + } + if ( + !sameTranscriptIdentity(identity, transcriptIdentity(identity.filePath, yield* file.stat)) + ) + return yield* unavailable(); + return { rows, nextBefore, truncated }; + }), + ).pipe(Effect.mapError(() => unavailable())); + }); + + const inspect = Effect.fn("AgentSessionScanner.inspect")(function* ( + workspaceRoot: string, + transcript: Omit, + ) { + const stats = yield* statOption(transcript.filePath); + if (Option.isNone(stats) || stats.value.type !== "File") return yield* unavailable(); + const identity = transcriptIdentity(transcript.filePath, stats.value); + const prefix = yield* readTranscript( + identity.filePath, + identity, + MAX_METADATA_RECORDS_PER_TRANSCRIPT, + "claudeAgent", + true, + ); + if (prefix === null) return yield* unavailable(); + const sessionId = path.basename(transcript.filePath, ".jsonl"); + const metadata = prefix.records.find( + (record) => record.cwd !== undefined && record.sessionId !== undefined, + ); + if ( + !metadata?.cwd || + metadata.sessionId !== sessionId || + metadata.isSidechain || + !path.isAbsolute(metadata.cwd) || + (yield* directoryIdentity(metadata.cwd)) !== (yield* directoryIdentity(workspaceRoot)) + ) + return yield* unavailable(); + if ( + prefix.records.some( + (record) => + record.isSidechain || (record.sessionId !== undefined && record.sessionId !== sessionId), + ) + ) + return yield* unavailable(); + const updatedAt = DateTime.formatIso( + DateTime.makeUnsafe(identity.mtimeMs ?? transcript.mtimeMs), + ); + const firstRequest = prefix.records + .map((record) => claudeVisibleMessage(record, updatedAt)) + .find((message) => message?.role === "user"); + if (!firstRequest) return yield* unavailable(); + const tail = yield* readWindow(identity, identity.size, 64 * 1024); + const title = + tail.rows.findLast(({ record }) => record.aiTitle?.trim())?.record.aiTitle ?? + firstRequest.text.split("\n")[0]?.slice(0, 100) ?? + "Untitled session"; + return { + identity, + summary: { + providerInstanceId: transcript.providerInstanceId, + providerSessionId: sessionId, + title: title.slice(0, 300), + firstRequest: firstRequest.text.slice(0, 500), + updatedAt, + cwd: metadata.cwd, + branch: + tail.rows.findLast(({ record }) => record.gitBranch)?.record.gitBranch ?? + metadata.gitBranch ?? + null, + existingThreadId: null, + }, + }; + }); + + interface ListingSnapshot { + readonly rootIdentity: string; + readonly homes: Effect.Success>; + readonly expiresAt: number; + readonly transcripts: Effect.Success>["transcripts"]; + readonly truncated: boolean; + readonly pages: Map; + readonly lock: Semaphore.Semaphore; + } + const listingSnapshots = new Set(); + const listingCursors = new Map(); + const discardListing = (snapshot: ListingSnapshot) => { + listingSnapshots.delete(snapshot); + for (const [cursor, page] of listingCursors) { + if (page.snapshot === snapshot) listingCursors.delete(cursor); + } + }; + const listingUnavailable = () => + new AgentSessionUnavailableError({ + message: "This session listing expired or changed. Refresh the session list.", + }); + + const list: AgentSessionScanner["Service"]["list"] = Effect.fn("AgentSessionScanner.list")( + function* (workspaceRoot, cursor) { + const now = DateTime.toEpochMillis(yield* DateTime.now); + for (const snapshot of listingSnapshots) { + if (snapshot.expiresAt <= now) discardListing(snapshot); + } + const homes = yield* resolveSourceHomes("claudeAgent"); + const rootIdentity = yield* directoryIdentity(workspaceRoot); + let snapshot: ListingSnapshot; + let offset = 0; + if (cursor !== undefined) { + const page = listingCursors.get(cursor); + if ( + !page || + page.snapshot.rootIdentity !== rootIdentity || + page.snapshot.homes.length !== homes.length || + homes.some((home, index) => { + const previous = page.snapshot.homes[index]; + return ( + !previous || + home.providerInstanceId !== previous.providerInstanceId || + home.homePath !== previous.homePath || + home.homeKey !== previous.homeKey || + home.enabled !== previous.enabled + ); + }) + ) + return yield* listingUnavailable(); + snapshot = page.snapshot; + offset = page.offset; + } else { + const discovered = yield* scopedTranscripts(workspaceRoot, homes); + snapshot = { + ...discovered, + rootIdentity, + homes, + expiresAt: now + SESSION_LIST_TTL_MS, + pages: new Map(), + lock: yield* Semaphore.make(1), + }; + while (listingSnapshots.size >= MAX_SESSION_LIST_SNAPSHOTS) { + const oldest = listingSnapshots.values().next().value; + if (oldest) discardListing(oldest); + } + listingSnapshots.add(snapshot); + } + return yield* Effect.gen(function* () { + if ( + !listingSnapshots.has(snapshot) || + snapshot.expiresAt <= DateTime.toEpochMillis(yield* DateTime.now) + ) + return yield* listingUnavailable(); + const cached = snapshot.pages.get(offset); + if (cached) return cached; + const sessions: AgentSessionListResult["sessions"][number][] = []; + let incomplete = snapshot.truncated; + for (const transcript of snapshot.transcripts.slice( + offset, + offset + SESSION_LIST_PAGE_SIZE, + )) { + const result = yield* inspect(workspaceRoot, transcript).pipe(Effect.result); + if (result._tag === "Success") sessions.push(result.success.summary); + else incomplete = true; + } + if ( + !listingSnapshots.has(snapshot) || + snapshot.expiresAt <= DateTime.toEpochMillis(yield* DateTime.now) + ) { + discardListing(snapshot); + return yield* listingUnavailable(); + } + const nextOffset = offset + SESSION_LIST_PAGE_SIZE; + const nextCursor = + nextOffset < snapshot.transcripts.length ? NodeCrypto.randomUUID() : null; + if (nextCursor !== null) listingCursors.set(nextCursor, { snapshot, offset: nextOffset }); + const result = { sessions, nextCursor, truncated: incomplete }; + snapshot.pages.set(offset, result); + return result; + }).pipe(snapshot.lock.withPermits(1)); + }, + ); + + const resolveSelection = Effect.fn("AgentSessionScanner.resolveSelection")(function* ( + workspaceRoot: string, + selection: AgentSessionSelection, + ) { + const homes = yield* resolveSourceHomes("claudeAgent"); + const home = homes.find( + (entry) => entry.providerInstanceId === selection.providerInstanceId && entry.enabled, + ); + if (!home) return yield* unavailable(); + const key = locatorKey( + yield* directoryIdentity(workspaceRoot), + home.homeKey, + selection.providerSessionId, + ); + const cached = sessionLocators.get(key); + if (cached && (yield* directoryIdentity(cached.homePath)) === home.homeKey) { + const inspected = yield* inspect(workspaceRoot, cached.transcript).pipe(Effect.result); + if (inspected._tag === "Success") return inspected.success; + } + sessionLocators.delete(key); + yield* scopedTranscripts(workspaceRoot, homes); + const locator = sessionLocators.get(key); + if (!locator) return yield* unavailable(); + return yield* inspect(workspaceRoot, locator.transcript); + }); + + const preview: AgentSessionScanner["Service"]["preview"] = Effect.fn( + "AgentSessionScanner.preview", + )(function* (workspaceRoot, selection, before) { + const { identity, summary } = yield* resolveSelection(workspaceRoot, selection); + const end = before ?? identity.size; + if (end > identity.size) return yield* unavailable(); + const window = yield* readWindow(identity, end, 256 * 1024); + const visible = window.rows.flatMap(({ record, start }) => { + const message = claudeVisibleMessage(record, summary.updatedAt); + return message === null ? [] : [{ message, start }]; + }); + const page = visible.slice(-30); + return { + messages: page.map(({ message, start }) => ({ ...message, id: start })), + nextBefore: visible.length > 30 ? (page[0]?.start ?? window.nextBefore) : window.nextBefore, + truncated: window.truncated, + }; + }); + + const selectedThread: AgentSessionScanner["Service"]["selectedThread"] = Effect.fn( + "AgentSessionScanner.selectedThread", + )(function* (workspaceRoot, selection) { + const { identity, summary } = yield* resolveSelection(workspaceRoot, selection); + const snapshot = yield* readTranscript( + identity.filePath, + identity, + MAX_IMPORT_RECORDS, + "claudeAgent", + ).pipe(importReadLock.withPermits(1)); + if (snapshot === null) return yield* unavailable(); + const thread = parseAgentSessionRecords( + { + source: "claudeAgent", + ...selection, + fallbackSessionId: selection.providerSessionId, + lastActiveAtMs: identity.mtimeMs ?? 0, + }, + snapshot.records, + ); + if (thread === null || thread.providerSessionId !== selection.providerSessionId) + return yield* unavailable(); + return { + _tag: "Importable", + thread: { ...thread, title: summary.title }, + source: { ...identity, provider: "claudeAgent", ...selection }, + }; + }); + + return AgentSessionScanner.of({ + scan, + recentThreads, + list, + preview, + selectedThread, + claudeSessionHomes, + }); }); export const layer = Layer.effect(AgentSessionScanner, make); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 3a8c3ad71e69..5c77904a6d35 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 288db92799bc..63628dc1c72e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -13,6 +13,7 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; import { ApprovalRequestId, + CLAUDE_SESSION_ID_PATTERN, ClaudeSettings, ProviderDriverKind, ProviderItemId, @@ -6221,10 +6222,7 @@ describe("ClaudeAdapterLive", () => { assert.equal(sessionResumeCursor.threadId, THREAD_ID); assert.equal(typeof sessionResumeCursor.resume, "string"); assert.equal(sessionResumeCursor.turnCount, 0); - assert.match( - sessionResumeCursor.resume ?? "", - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, - ); + assert.match(sessionResumeCursor.resume ?? "", CLAUDE_SESSION_ID_PATTERN); assert.equal(createInput?.options.resume, undefined); assert.equal(createInput?.options.sessionId, sessionResumeCursor.resume); }).pipe( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 81a4a197e9db..07a0f0ee3fa3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -25,6 +25,7 @@ import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { type ClaudeScopedLimitNames, claudeRateLimitEventToUpdate } from "./claudeUsageLimits.ts"; import { ApprovalRequestId, + CLAUDE_SESSION_ID_PATTERN, classifyTaskAgentKind, type CanonicalItemType, type CanonicalRequestType, @@ -358,7 +359,7 @@ export interface ClaudeAdapterLiveOptions { } function isUuid(value: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); + return CLAUDE_SESSION_ID_PATTERN.test(value); } function isSyntheticClaudeThreadId(value: string): boolean { diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 4d17aabaa5f5..60450d2db957 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4457,6 +4457,7 @@ describe("agent browser access", () => { const projectionLayer = Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getTurnStartMessage: () => Effect.die("unused"), getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 4998f710c29b..f3451e09abd5 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -218,6 +218,7 @@ describe("ProviderSessionReaper", () => { getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 64bfea1b9805..a24bafec6f66 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -5367,6 +5367,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { if (error._tag === "AgentSessionImportProjectNotFoundError") { assert.equal(error.projectId, projectId); } + const input = { + projectId, + expectedWorkspaceRoot: "/missing-project", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerSessionId: "123e4567-e89b-42d3-a456-426614174000", + }; + const errors = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.all([ + client[WS_METHODS.agentSessionsList](input).pipe(Effect.flip), + client[WS_METHODS.agentSessionsPreview](input).pipe(Effect.flip), + client[WS_METHODS.agentSessionsAttach](input).pipe(Effect.flip), + ]), + ), + ); + assert.deepEqual( + errors.map((failure) => failure._tag), + Array(3).fill("AgentSessionImportProjectNotFoundError"), + ); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 0426df44bcea..2b0103d65efe 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -168,6 +168,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), @@ -255,6 +256,7 @@ it.effect.each([ getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), @@ -324,6 +326,7 @@ it.effect( getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("thread lookup failed"), getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), @@ -386,6 +389,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadProjectIds: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 46396bf1e4a7..e4c4113b84eb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -126,7 +126,12 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; -import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts"; +import { + importRecentAgentThreads, + listAgentSessions, + previewAgentSession, + attachAgentSession, +} from "./project/AgentSessionImporter.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; @@ -571,6 +576,13 @@ const makeWsRpcLayer = ( }); const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; + const agentSessionServices = Layer.mergeAll( + Layer.succeed(AgentSessionScanner.AgentSessionScanner, agentSessionScanner), + Layer.succeed(OrchestrationEngine.OrchestrationEngineService, orchestrationEngine), + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, projectionSnapshotQuery), + Layer.succeed(Crypto.Crypto, crypto), + Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, providerSessionDirectory), + ); const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); @@ -2368,22 +2380,25 @@ const makeWsRpcLayer = ( [WS_METHODS.agentSessionsImport]: (input) => observeRpcEffect( WS_METHODS.agentSessionsImport, - importRecentAgentThreads(input).pipe( - Effect.provideService(AgentSessionScanner.AgentSessionScanner, agentSessionScanner), - Effect.provideService( - OrchestrationEngine.OrchestrationEngineService, - orchestrationEngine, - ), - Effect.provideService( - ProjectionSnapshotQuery.ProjectionSnapshotQuery, - projectionSnapshotQuery, - ), - Effect.provideService(Crypto.Crypto, crypto), - Effect.provideService( - ProviderSessionDirectory.ProviderSessionDirectory, - providerSessionDirectory, - ), - ), + importRecentAgentThreads(input).pipe(Effect.provide(agentSessionServices)), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.agentSessionsList]: (input) => + observeRpcEffect( + WS_METHODS.agentSessionsList, + listAgentSessions(input).pipe(Effect.provide(agentSessionServices)), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.agentSessionsPreview]: (input) => + observeRpcEffect( + WS_METHODS.agentSessionsPreview, + previewAgentSession(input).pipe(Effect.provide(agentSessionServices)), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.agentSessionsAttach]: (input) => + observeRpcEffect( + WS_METHODS.agentSessionsAttach, + attachAgentSession(input).pipe(Effect.provide(agentSessionServices)), { "rpc.aggregate": "workspace" }, ), [WS_METHODS.assetsCreateUrl]: (input) => diff --git a/apps/web/src/components/AgentSessionImportDialog.tsx b/apps/web/src/components/AgentSessionImportDialog.tsx new file mode 100644 index 000000000000..926ef1147ee4 --- /dev/null +++ b/apps/web/src/components/AgentSessionImportDialog.tsx @@ -0,0 +1,418 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { useNavigate } from "@tanstack/react-router"; +import { + type AgentSessionListResult, + type AgentSessionPreviewResult, + type AgentSessionSummary, + type ScopedProjectRef, +} from "@t3tools/contracts"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { agentSessionAttach, agentSessionList, agentSessionPreview } from "../state/agentSessions"; +import { useAtomCommand } from "../state/use-atom-command"; +import { useAtomQueryRunner } from "../state/use-atom-query-runner"; +import { buildThreadRouteParams } from "../threadRoutes"; +import ChatMarkdown from "./ChatMarkdown"; +import { Button } from "./ui/button"; +import { Dialog, DialogPopup, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"; +import { Input } from "./ui/input"; + +type RequestState = "idle" | "loading" | { error: string }; +type Scope = { projectRef: ScopedProjectRef; cwd: string; machineName: string }; + +export function AgentSessionImportDialog(props: Scope) { + const [open, setOpen] = useState(false); + return ( + <> +
+ +
+ + + + Import an existing session + {props.machineName} · Claude Code +

{props.cwd}

+
+ {open ? setOpen(false)} /> : null} +
+
+ + ); +} + +const sessionKey = (session: AgentSessionSummary) => + `${session.providerInstanceId}:${session.providerSessionId}`; + +function SessionBrowser({ projectRef, cwd, onClose }: Scope & { onClose: () => void }) { + const readList = useAtomQueryRunner(agentSessionList, { reportFailure: false, refresh: true }); + const [result, setResult] = useState({ + sessions: [], + nextCursor: null, + truncated: false, + }); + const [request, setRequest] = useState("loading"); + const [search, setSearch] = useState(""); + const [selectedId, setSelectedId] = useState(null); + const selected = result.sessions.find((session) => sessionKey(session) === selectedId) ?? null; + const generation = useRef(0); + + const load = useCallback( + async (cursor?: string) => { + const current = ++generation.current; + const response = await readList({ + environmentId: projectRef.environmentId, + input: { + projectId: projectRef.projectId, + expectedWorkspaceRoot: cwd, + ...(cursor === undefined ? {} : { cursor }), + }, + }); + if (current !== generation.current) return; + if (response._tag === "Failure") { + setRequest({ error: String(squashAtomCommandFailure(response)) }); + return; + } + setResult((previous) => ({ + ...response.value, + sessions: + cursor === undefined + ? response.value.sessions + : [...previous.sessions, ...response.value.sessions], + })); + setRequest("idle"); + }, + [cwd, projectRef.environmentId, projectRef.projectId, readList], + ); + useEffect(() => { + void load(); + return () => { + generation.current += 1; + }; + }, [load]); + + const query = search.trim().toLowerCase(); + const sessions = result.sessions.filter((session) => + `${session.title}\n${session.firstRequest}`.toLowerCase().includes(query), + ); + return ( +
+
+
+ setSearch(event.target.value)} + /> +
+
+ {sessions.map((session) => ( + + ))} + {request === "loading" ? ( +

+ Loading sessions… +

+ ) : null} + {typeof request === "object" ? ( +
+

Could not load sessions. Check this machine's connection.

+

{request.error}

+ +
+ ) : null} + {request === "idle" && sessions.length === 0 ? ( +

+ {query + ? "No matching listed sessions." + : "No Claude Code sessions found in this directory."} +

+ ) : null} + {result.truncated ? ( +

+ Some files could not be listed within the scan limits. +

+ ) : null} + {result.nextCursor !== null ? ( + + ) : null} +
+
+ {selected ? ( + + ) : ( +
+ Select a session to read its conversation. +
+ )} +
+ ); +} + +function SessionPreview({ + projectRef, + cwd, + session, + onClose, +}: Pick & { session: AgentSessionSummary; onClose: () => void }) { + const readPreview = useAtomQueryRunner(agentSessionPreview, { + reportFailure: false, + refresh: true, + }); + const attach = useAtomCommand(agentSessionAttach, { reportFailure: false }); + const navigate = useNavigate(); + const [preview, setPreview] = useState({ + messages: [], + nextBefore: null, + truncated: false, + }); + const [request, setRequest] = useState("loading"); + const [attachment, setAttachment] = useState("idle"); + const generation = useRef(0); + const mounted = useRef(true); + const scrollRef = useRef(null); + const pendingScroll = useRef< + "latest" | { height: number; top: number; messageId: string | null; offset: number } | null + >(null); + useLayoutEffect(() => { + const node = scrollRef.current; + const anchor = pendingScroll.current; + if (!node || anchor === null || preview.messages.length === 0) return; + if (anchor === "latest") { + node.scrollTop = node.scrollHeight; + } else { + const message = node.querySelector( + `[data-preview-message-id="${anchor.messageId}"]`, + ); + node.scrollTop = message + ? message.offsetTop - anchor.offset + : node.scrollHeight - anchor.height + anchor.top; + } + pendingScroll.current = null; + }, [preview.messages]); + const scope = useMemo( + () => ({ + environmentId: projectRef.environmentId, + input: { + projectId: projectRef.projectId, + expectedWorkspaceRoot: cwd, + providerInstanceId: session.providerInstanceId, + providerSessionId: session.providerSessionId, + }, + }), + [ + cwd, + projectRef.environmentId, + projectRef.projectId, + session.providerInstanceId, + session.providerSessionId, + ], + ); + const load = useCallback( + async (before?: number) => { + const current = ++generation.current; + const node = scrollRef.current; + const message = node + ? Array.from(node.querySelectorAll("[data-preview-message-id]")).find( + (entry) => entry.offsetTop + entry.offsetHeight > node.scrollTop, + ) + : undefined; + const anchor = + before === undefined || !node + ? "latest" + : { + height: node.scrollHeight, + top: node.scrollTop, + messageId: message?.getAttribute("data-preview-message-id") ?? null, + offset: message ? message.offsetTop - node.scrollTop : 0, + }; + const response = await readPreview({ + ...scope, + input: { ...scope.input, ...(before === undefined ? {} : { before }) }, + }); + if (current !== generation.current) return; + if (response._tag === "Failure") { + setRequest({ error: String(squashAtomCommandFailure(response)) }); + return; + } + pendingScroll.current = anchor; + setPreview((previous) => ({ + ...response.value, + truncated: previous.truncated || response.value.truncated, + messages: + before === undefined + ? response.value.messages + : [...response.value.messages, ...previous.messages], + })); + setRequest("idle"); + }, + [readPreview, scope], + ); + useEffect(() => { + mounted.current = true; + void load(); + return () => { + generation.current += 1; + mounted.current = false; + }; + }, [load]); + async function open() { + setAttachment("loading"); + const response = await attach(scope); + if (!mounted.current) return; + if (response._tag === "Failure") { + setAttachment({ error: String(squashAtomCommandFailure(response)) }); + return; + } + onClose(); + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(projectRef.environmentId, response.value.threadId), + ), + }); + } + return ( +
+
+ {preview.nextBefore !== null ? ( + + ) : null} + {request === "loading" ? ( +

+ Loading conversation… +

+ ) : null} + {typeof request === "object" ? ( +
+

Conversation preview unavailable.

+

{request.error}

+ +
+ ) : null} + {preview.truncated ? ( +

+ Some oversized or malformed transcript records are not shown. +

+ ) : null} + {request === "idle" && preview.messages.length === 0 ? ( +

+ No readable user or assistant text in this part of the session. Try earlier history. +

+ ) : null} + {preview.messages.map((message) => ( +
+

+ {message.role === "user" ? "You" : "Claude"} +

+ +
+ ))} +
+
+

+ T3 imports recent messages and continues the original session. Avoid sending from the + original terminal at the same time. Newly attached sessions require approval. +

+ {typeof attachment === "object" ? ( +

+ {attachment.error} +

+ ) : null} + +
+
+ ); +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index dff486df8767..33650356179b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -198,6 +198,8 @@ interface ChatMarkdownProps { lineBreaks?: boolean; /** Parse sanitized raw HTML instead of displaying its source text. */ parseRawHtml?: boolean; + /** Read-only transcript previews must not fetch linked media or rich link previews. */ + previewOnly?: boolean; /** Append a prompt that invokes a newly created artifact-template skill. */ onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; /** Directory that anchors relative links and images; defaults to `cwd`. Set @@ -3105,11 +3107,20 @@ const CHAT_MARKDOWN_COMPONENTS = { }, } satisfies Components; +const TRANSCRIPT_PREVIEW_COMPONENTS = { + ...CHAT_MARKDOWN_COMPONENTS, + img: ({ alt }) => ( + [Image not loaded{alt ? `: ${alt}` : ""}] + ), + a: ({ children }) => {children}, +} satisfies Components; + function ChatMarkdown({ text, className, lineBreaks = false, parseRawHtml = true, + previewOnly = false, extraRemarkPlugins = EMPTY_REMARK_PLUGINS, ...props }: ChatMarkdownProps) { @@ -3142,9 +3153,9 @@ function ChatMarkdown({ {text} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7d5e9c9b1534..bc4fef7fbbd8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -341,6 +341,7 @@ import { } from "./chat/ThreadErrorBanner"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; +import { AgentSessionImportDialog } from "./AgentSessionImportDialog"; import { hasAvailableCompactionProvider, hasDismissedResumeCompaction, @@ -8236,6 +8237,17 @@ export default function ChatView(props: ChatViewProps) { + {isDraftHeroState && activeProject && activeProjectRef ? ( + + ) : null}
{ + const input = { + projectId: "project", + expectedWorkspaceRoot: "/project", + providerInstanceId: "claudeAgent", + providerSessionId: "123e4567-e89b-42d3-a456-426614174000", + }; + expect(decodeAttachInput(input)).toEqual(input); + expect(() => decodeAttachInput({ ...input, expectedWorkspaceRoot: undefined })).toThrow(); + expect(() => decodeAttachInput({ ...input, providerSessionId: "../../other.jsonl" })).toThrow(); + expect(() => decodePreviewInput({ ...input, before: -1 })).toThrow(); +}); + +it("carries opaque session listing cursors instead of numeric offsets", () => { + const decodeInput = Schema.decodeUnknownSync(AgentSessionListInput); + const decodeResult = Schema.decodeUnknownSync(AgentSessionListResult); + const input = { projectId: "project", expectedWorkspaceRoot: "/project" }; + const cursor = "server-held-page-token"; + expect(decodeInput(input)).toEqual(input); + expect(decodeInput({ ...input, cursor }).cursor).toBe(cursor); + expect(() => decodeInput({ ...input, cursor: 40 })).toThrow(); + expect(decodeResult({ sessions: [], nextCursor: cursor, truncated: false }).nextCursor).toBe( + cursor, + ); + expect(decodeResult({ sessions: [], nextCursor: null, truncated: false }).nextCursor).toBeNull(); + expect(() => decodeResult({ sessions: [], nextCursor: 40, truncated: false })).toThrow(); +}); + describe("AgentSessionScanResult", () => { it("decodes candidates from servers that predate the git scan", () => { const result = decodeScanResult({ diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts index ab408c4efd17..065e41e82778 100644 --- a/packages/contracts/src/agentSessions.ts +++ b/packages/contracts/src/agentSessions.ts @@ -1,5 +1,11 @@ import * as Schema from "effect/Schema"; -import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + IsoDateTime, + NonNegativeInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; /** Coding agent home directories the scanner knows how to read. */ @@ -103,6 +109,73 @@ export const AgentSessionImportResult = Schema.Struct({ }); export type AgentSessionImportResult = typeof AgentSessionImportResult.Type; +export const CLAUDE_SESSION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export const AgentSessionSelection = Schema.Struct({ + providerInstanceId: ProviderInstanceId, + providerSessionId: TrimmedNonEmptyString.check(Schema.isPattern(CLAUDE_SESSION_ID_PATTERN)), +}); +export type AgentSessionSelection = typeof AgentSessionSelection.Type; + +export const AgentSessionListInput = Schema.Struct({ + projectId: ProjectId, + expectedWorkspaceRoot: TrimmedNonEmptyString, + cursor: Schema.optional(Schema.String), +}); +export type AgentSessionListInput = typeof AgentSessionListInput.Type; + +export const AgentSessionAttachInput = Schema.Struct({ + projectId: ProjectId, + expectedWorkspaceRoot: TrimmedNonEmptyString, + ...AgentSessionSelection.fields, +}); +export type AgentSessionAttachInput = typeof AgentSessionAttachInput.Type; + +export const AgentSessionPreviewInput = Schema.Struct({ + ...AgentSessionAttachInput.fields, + before: Schema.optional(NonNegativeInt), +}); +export type AgentSessionPreviewInput = typeof AgentSessionPreviewInput.Type; + +export const AgentSessionSummary = Schema.Struct({ + ...AgentSessionSelection.fields, + title: Schema.String, + firstRequest: Schema.String, + updatedAt: IsoDateTime, + cwd: TrimmedNonEmptyString, + branch: Schema.NullOr(Schema.String), + existingThreadId: Schema.NullOr(ThreadId), +}); +export type AgentSessionSummary = typeof AgentSessionSummary.Type; + +export const AgentSessionListResult = Schema.Struct({ + sessions: Schema.Array(AgentSessionSummary), + nextCursor: Schema.NullOr(Schema.String), + truncated: Schema.Boolean, +}); +export type AgentSessionListResult = typeof AgentSessionListResult.Type; + +export const AgentSessionPreviewResult = Schema.Struct({ + messages: Schema.Array( + Schema.Struct({ + id: NonNegativeInt, + role: Schema.Literals(["user", "assistant"]), + text: Schema.String, + createdAt: IsoDateTime, + }), + ), + nextBefore: Schema.NullOr(NonNegativeInt), + truncated: Schema.Boolean, +}); +export type AgentSessionPreviewResult = typeof AgentSessionPreviewResult.Type; +export const AgentSessionAttachResult = Schema.Struct({ threadId: ThreadId }); + +export class AgentSessionUnavailableError extends Schema.TaggedErrorClass()( + "AgentSessionUnavailableError", + { message: Schema.String }, +) {} + export class AgentSessionScanError extends Schema.TaggedErrorClass()( "AgentSessionScanError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 9dbcaa9f4164..858e6d00ed1d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -30,6 +30,13 @@ import { } from "./filesystem.ts"; import { AgentSessionImportInput, + AgentSessionListInput, + AgentSessionListResult, + AgentSessionPreviewInput, + AgentSessionPreviewResult, + AgentSessionAttachInput, + AgentSessionAttachResult, + AgentSessionUnavailableError, AgentSessionImportProjectChangedError, AgentSessionImportProjectNotFoundError, AgentSessionImportResult, @@ -253,6 +260,9 @@ export const WS_METHODS = { filesystemBrowse: "filesystem.browse", agentSessionsScan: "agentSessions.scan", agentSessionsImport: "agentSessions.import", + agentSessionsList: "agentSessions.list", + agentSessionsPreview: "agentSessions.preview", + agentSessionsAttach: "agentSessions.attach", assetsCreateUrl: "assets.createUrl", attachmentsCreateUploadUrl: "attachments.createUploadUrl", attachmentsDelete: "attachments.delete", @@ -839,6 +849,29 @@ const WsAgentSessionsImportRpc = Rpc.make(WS_METHODS.agentSessionsImport, { ]), }); +const AgentSessionSelectionError = Schema.Union([ + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionScanError, + AgentSessionUnavailableError, + EnvironmentAuthorizationError, +]); +const WsAgentSessionsListRpc = Rpc.make(WS_METHODS.agentSessionsList, { + payload: AgentSessionListInput, + success: AgentSessionListResult, + error: AgentSessionSelectionError, +}); +const WsAgentSessionsPreviewRpc = Rpc.make(WS_METHODS.agentSessionsPreview, { + payload: AgentSessionPreviewInput, + success: AgentSessionPreviewResult, + error: AgentSessionSelectionError, +}); +const WsAgentSessionsAttachRpc = Rpc.make(WS_METHODS.agentSessionsAttach, { + payload: AgentSessionAttachInput, + success: AgentSessionAttachResult, + error: AgentSessionSelectionError, +}); + const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { payload: AssetCreateUrlInput, success: AssetCreateUrlResult, @@ -1251,6 +1284,9 @@ export const WsRpcGroup = RpcGroup.make( WsFilesystemBrowseRpc, WsAgentSessionsScanRpc, WsAgentSessionsImportRpc, + WsAgentSessionsListRpc, + WsAgentSessionsPreviewRpc, + WsAgentSessionsAttachRpc, WsAssetsCreateUrlRpc, WsAttachmentsCreateUploadUrlRpc, WsAttachmentsDeleteRpc,