diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 138fdfb5b5..22861137c5 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -16,6 +16,7 @@ import { import { type ServerType, serve } from "@hono/node-server"; import { execGh } from "@posthog/git/gh"; import { getCurrentBranch } from "@posthog/git/queries"; +import { buildAdditionalDirectoriesPrompt } from "@posthog/shared"; import { unzipSync } from "fflate"; import { Hono } from "hono"; import { z } from "zod"; @@ -2481,11 +2482,12 @@ export class AgentServer { slackThreadUrl?: string | null, inboxReportUrl?: string | null, ): string | { append: string } { - const cloudAppend = this.buildCloudSystemPrompt( - prUrl, - slackThreadUrl, - inboxReportUrl, - ); + const cloudAppend = [ + this.buildCloudSystemPrompt(prUrl, slackThreadUrl, inboxReportUrl), + buildAdditionalDirectoriesPrompt(this.config.additionalDirectories), + ] + .filter(Boolean) + .join("\n\n"); const userPrompt = this.config.claudeCode?.systemPrompt; // String override: combine user prompt with cloud instructions @@ -2523,8 +2525,12 @@ export class AgentServer { const plugins = this.config.claudeCode?.plugins; const effort = runtimeAdapter === "claude" ? this.config.reasoningEffort : undefined; + const additionalDirectories = + runtimeAdapter === "claude" + ? this.config.additionalDirectories + : undefined; - if (!plugins?.length && !effort) { + if (!plugins?.length && !effort && !additionalDirectories?.length) { return undefined; } @@ -2535,6 +2541,9 @@ export class AgentServer { if (effort) { options.effort = effort; } + if (additionalDirectories?.length) { + options.additionalDirectories = additionalDirectories; + } return { claudeCode: { options } }; } diff --git a/packages/agent/src/server/bin.ts b/packages/agent/src/server/bin.ts index 4f515b15ec..9e0b4b7c02 100644 --- a/packages/agent/src/server/bin.ts +++ b/packages/agent/src/server/bin.ts @@ -115,6 +115,10 @@ program "--allowedDomains ", "Comma-separated list of domains allowed for web tools (WebFetch, WebSearch)", ) + .option( + "--additionalDirectories ", + "Comma-separated extra repo checkout paths the agent may read/edit beyond --repositoryPath", + ) .action(async (options) => { const envResult = envSchema.safeParse(process.env); @@ -149,6 +153,13 @@ program .filter(Boolean) : undefined; + const additionalDirectories = options.additionalDirectories + ? options.additionalDirectories + .split(",") + .map((d: string) => d.trim()) + .filter(Boolean) + : undefined; + if ( env.POSTHOG_CODE_RUNTIME_ADAPTER && env.POSTHOG_CODE_MODEL && @@ -186,6 +197,7 @@ program baseBranch: options.baseBranch, claudeCode, allowedDomains, + additionalDirectories, runtimeAdapter: env.POSTHOG_CODE_RUNTIME_ADAPTER, model: env.POSTHOG_CODE_MODEL, reasoningEffort: env.POSTHOG_CODE_REASONING_EFFORT, diff --git a/packages/agent/src/server/types.ts b/packages/agent/src/server/types.ts index 50a3fa1395..3729294537 100644 --- a/packages/agent/src/server/types.ts +++ b/packages/agent/src/server/types.ts @@ -29,6 +29,7 @@ export interface AgentServerConfig { mcpServers?: RemoteMcpServer[]; baseBranch?: string; claudeCode?: ClaudeCodeConfig; + additionalDirectories?: string[]; allowedDomains?: string[]; runtimeAdapter?: "claude" | "codex"; model?: string; diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index e359023d56..37d9e8dd10 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -2220,6 +2220,7 @@ export class PostHogAPIClient { channel?: string | null; pending_user_message?: string; pending_user_artifact_ids?: string[]; + additional_repositories?: string[]; }, ) { const teamId = await this.getTeamId(); diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index b021119f1d..d173ff1ec0 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -762,6 +762,11 @@ export class TaskCreationSaga extends Saga< input.workspaceMode === "cloud" ? (input.reasoningLevel ?? null) : undefined, + additional_repositories: + input.workspaceMode === "cloud" && + input.additionalRepositories?.length + ? input.additionalRepositories + : undefined, signal_report: input.signalReportId ?? undefined, channel: input.channelId ?? undefined, pending_user_message: warmPayload?.pendingUserMessage, diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index dba4749919..3dfb3cf371 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -19,6 +19,7 @@ export interface PrepareTaskInputOptions { sandboxEnvironmentId?: string; signalReportId?: string; additionalDirectories?: string[]; + additionalRepositories?: string[]; channelContext?: string; channelName?: string; channelId?: string; @@ -58,6 +59,9 @@ export function prepareTaskInput( options.signalReportId && isCloud ? "signal_report" : undefined, signalReportId: options.signalReportId, additionalDirectories: isCloud ? undefined : options.additionalDirectories, + additionalRepositories: isCloud + ? options.additionalRepositories + : undefined, channelContext: options.channelContext, channelName: options.channelName, channelId: options.channelId, diff --git a/packages/shared/src/agent-prompts.test.ts b/packages/shared/src/agent-prompts.test.ts new file mode 100644 index 0000000000..794f3fb7f5 --- /dev/null +++ b/packages/shared/src/agent-prompts.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { buildAdditionalDirectoriesPrompt } from "./agent-prompts"; + +describe("buildAdditionalDirectoriesPrompt", () => { + it.each([ + ["undefined", undefined], + ["empty", []], + ])("returns an empty string for %s directories", (_label, directories) => { + expect(buildAdditionalDirectoriesPrompt(directories)).toBe(""); + }); + + it("lists each directory in an block", () => { + const prompt = buildAdditionalDirectoriesPrompt([ + "/tmp/workspace/repos/posthog/posthog-js", + "/tmp/workspace/repos/posthog/posthog.com", + ]); + + expect(prompt).toContain( + "\n" + + " /tmp/workspace/repos/posthog/posthog-js\n" + + " /tmp/workspace/repos/posthog/posthog.com\n" + + "", + ); + }); + + it("escapes XML-significant characters in paths", () => { + const prompt = buildAdditionalDirectoriesPrompt(["/tmp/a&c"]); + + expect(prompt).toContain("/tmp/a<b>&c"); + expect(prompt).not.toContain("/tmp/a"); + }); +}); diff --git a/packages/shared/src/agent-prompts.ts b/packages/shared/src/agent-prompts.ts new file mode 100644 index 0000000000..20980aa502 --- /dev/null +++ b/packages/shared/src/agent-prompts.ts @@ -0,0 +1,11 @@ +export function buildAdditionalDirectoriesPrompt( + directories: readonly string[] | undefined, +): string { + if (!directories?.length) return ""; + const escapeXml = (value: string) => + value.replace(/&/g, "&").replace(//g, ">"); + const dirs = directories + .map((directory) => ` ${escapeXml(directory)}`) + .join("\n"); + return `The user has granted you access to additional directories outside the working directory. You may read and edit files in these paths just like the working directory:\n\n${dirs}\n`; +} diff --git a/packages/shared/src/domain-types.ts b/packages/shared/src/domain-types.ts index 31a72890a6..253d580a66 100644 --- a/packages/shared/src/domain-types.ts +++ b/packages/shared/src/domain-types.ts @@ -48,6 +48,7 @@ export interface Task { created_by?: UserBasic | null; origin_product: string; repository?: string | null; // Format: "organization/repository" (e.g., "posthog/posthog-js") + additional_repositories?: string[]; github_integration?: number | null; github_user_integration?: string | null; json_schema?: Record | null; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7174f95a77..3a636562a0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ +export { buildAdditionalDirectoriesPrompt } from "./agent-prompts"; export * from "./analytics-events"; export { type ArchivedTask, archivedTaskSchema } from "./archive-domain"; export { withTimeout } from "./async"; diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 3208609fc3..8f003785a4 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -18,6 +18,7 @@ export interface TaskCreationInput { filePaths?: string[]; repoPath?: string; repository?: string | null; + additionalRepositories?: string[]; workspaceMode?: WorkspaceMode; branch?: string | null; // When the branch exists only on the remote, opt in to fetching and checking diff --git a/packages/ui/src/features/folder-picker/AdditionalRepositoriesButton.tsx b/packages/ui/src/features/folder-picker/AdditionalRepositoriesButton.tsx new file mode 100644 index 0000000000..1795b54340 --- /dev/null +++ b/packages/ui/src/features/folder-picker/AdditionalRepositoriesButton.tsx @@ -0,0 +1,96 @@ +import { GithubLogo, Plus } from "@phosphor-icons/react"; +import { + Button, + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, +} from "@posthog/quill"; +import { type RefObject, useMemo, useRef } from "react"; + +interface AdditionalRepositoriesButtonProps { + values: string[]; + onChange: (values: string[]) => void; + repositories: string[]; + primaryRepository?: string | null; + disabled?: boolean; + anchor?: RefObject; +} + +const VISIBLE_LIMIT = 50; + +export function AdditionalRepositoriesButton({ + values, + onChange, + repositories, + primaryRepository, + disabled = false, + anchor, +}: AdditionalRepositoriesButtonProps) { + const triggerRef = useRef(null); + + const selectableRepos = useMemo( + () => repositories.filter((repo) => repo !== primaryRepository), + [repositories, primaryRepository], + ); + + const selectedValues = useMemo( + () => values.filter((repo) => repo !== primaryRepository), + [values, primaryRepository], + ); + const count = selectedValues.length; + + if (selectableRepos.length === 0) return null; + + return ( + + onChange(next.filter((repo) => repo !== primaryRepository)) + } + disabled={disabled} + > + + + {count > 0 ? ( + +{count} + ) : ( + + )} + + } + /> + + + No repositories found. + + {(repo: string) => ( + + {repo} + + )} + + + + ); +} diff --git a/packages/ui/src/features/task-detail/components/AdditionalReposBadge.tsx b/packages/ui/src/features/task-detail/components/AdditionalReposBadge.tsx new file mode 100644 index 0000000000..fa0585de45 --- /dev/null +++ b/packages/ui/src/features/task-detail/components/AdditionalReposBadge.tsx @@ -0,0 +1,31 @@ +import { GithubLogo } from "@phosphor-icons/react"; +import type { Task } from "@posthog/shared/domain-types"; +import { Tooltip } from "../../../primitives/Tooltip"; + +export function AdditionalReposBadge({ task }: { task: Task }) { + const extras = task.additional_repositories ?? []; + if (extras.length === 0) return null; + + return ( + + Repositories in this task + {task.repository && ( + {task.repository} + )} + {extras.map((repo) => ( + + {repo} + + ))} + + } + > + + +{extras.length} + + + ); +} diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index 1b4cacc500..9bacbad7a9 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -11,6 +11,7 @@ import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { ButtonGroup } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import type { TaskInputReportAssociation } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; import { useTaskInputPrefillStore } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; @@ -38,6 +39,7 @@ import { useFileSearchStore } from "../../command/fileSearchStore"; import { NewTaskFilePreview } from "../../command/NewTaskFilePreview"; import { EnvironmentSelector } from "../../environments/EnvironmentSelector"; import { AdditionalDirectoriesButton } from "../../folder-picker/AdditionalDirectoriesButton"; +import { AdditionalRepositoriesButton } from "../../folder-picker/AdditionalRepositoriesButton"; import { FolderPicker } from "../../folder-picker/FolderPicker"; import { GitHubRepoPicker } from "../../folder-picker/GitHubRepoPicker"; import { useFolders } from "../../folders/useFolders"; @@ -147,6 +149,9 @@ export function TaskInput({ onContextChipClick, }: TaskInputProps = {}) { const cloudRegion = useAuthStateValue((s) => s.cloudRegion); + // Dark-launch gate for the cloud multi-repo picker. Always on in dev. + const cloudMultiRepoEnabled = + useFeatureFlag("posthog-code-cloud-multi-repo") || import.meta.env.DEV; const trpc = useHostTRPC(); const hostClient = useHostTRPCClient(); const gitWriteClient = useMemo( @@ -349,6 +354,9 @@ export function TaskInput({ const lower = selectedRepository.toLowerCase(); return repositories.includes(lower) ? lower : null; }, [selectedRepository, repositories]); + const [additionalRepositories, setAdditionalRepositories] = useState< + string[] + >([]); const { currentBranch, branchLoading, defaultBranch, busyState } = useGitQueries(selectedDirectory); @@ -785,6 +793,9 @@ export function TaskInput({ signalReportId: activeReportAssociation?.reportId, channelContext: includeChannelContext ? channelContext : undefined, channelName, + additionalRepositories: additionalRepositories.filter( + (repo) => repo !== selectedCloudRepository, + ), allowNoRepo, }); @@ -1129,6 +1140,18 @@ export function TaskInput({ disabled={isCreatingTask} /> )} + {!allowNoRepo && + workspaceMode === "cloud" && + cloudMultiRepoEnabled && + selectedCloudRepository !== null && ( + + )} {cloudRegion === "dev" && ( )} + {isCloudTask && } {isCloudTask ? ( diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index cd51029618..5349c5dc35 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -61,6 +61,7 @@ import { } from "@posthog/platform/workspace-settings"; import { type AcpMessage, + buildAdditionalDirectoriesPrompt, isAuthError, serializeError, TypedEventEmitter, @@ -643,12 +644,7 @@ If a repository IS genuinely required, attach one in this priority order: } if (additionalDirectories?.length) { - const escapeXml = (s: string) => - s.replace(/&/g, "&").replace(//g, ">"); - const dirs = additionalDirectories - .map((d) => ` ${escapeXml(d)}`) - .join("\n"); - prompt += `\n\nThe user has granted you access to additional directories outside the working directory. You may read and edit files in these paths just like the working directory:\n\n${dirs}\n`; + prompt += `\n\n${buildAdditionalDirectoriesPrompt(additionalDirectories)}`; } return { append: prompt };