diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 7c6d8220f4..86b22b8a5e 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -1682,7 +1682,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ...initialInProcess, }; - const systemPrompt = buildSystemPrompt(meta?.systemPrompt); + const systemPrompt = buildSystemPrompt( + meta?.systemPrompt, + meta?.branchPrefix, + ); if (meta?.mcpToolApprovals) { setMcpToolApprovalStates(meta.mcpToolApprovals); diff --git a/packages/agent/src/adapters/claude/session/instructions.ts b/packages/agent/src/adapters/claude/session/instructions.ts index 792be41d04..d9b532a8a3 100644 --- a/packages/agent/src/adapters/claude/session/instructions.ts +++ b/packages/agent/src/adapters/claude/session/instructions.ts @@ -1,9 +1,11 @@ -const BRANCH_NAMING = ` +import { BRANCH_PREFIX, normalizeBranchPrefix } from "@posthog/shared"; + +const buildBranchNaming = (prefix: string) => ` # Branch Naming When working in a detached HEAD state, create a descriptive branch name based on the work being done before committing. Do this automatically without asking the user. -When creating a new branch, prefix it with \`posthog-code/\` (e.g. \`posthog-code/fix-login-redirect\`). +When creating a new branch, prefix it with \`${prefix}\` (e.g. \`${prefix}fix-login-redirect\`). `; const PLAN_MODE = ` @@ -26,4 +28,10 @@ If an MCP tool call is explicitly denied with a message, relay that denial messa If an MCP tool call returns an error, treat it as a normal tool error — troubleshoot, retry, or inform the user about the specific error. Do NOT assume it is a permissions issue and do NOT direct the user to any settings page. `; -export const APPENDED_INSTRUCTIONS = BRANCH_NAMING + PLAN_MODE + MCP_TOOLS; +export const buildAppendedInstructions = (branchPrefix?: string | null) => + buildBranchNaming(normalizeBranchPrefix(branchPrefix)) + + PLAN_MODE + + MCP_TOOLS; + +/** Default appended instructions using the standard {@link BRANCH_PREFIX}. */ +export const APPENDED_INSTRUCTIONS = buildAppendedInstructions(BRANCH_PREFIX); diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index ece3813296..8db90f4e09 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -26,7 +26,7 @@ import { } from "../hooks"; import type { CodeExecutionMode } from "../tools"; import type { EffortLevel } from "../types"; -import { APPENDED_INSTRUCTIONS } from "./instructions"; +import { buildAppendedInstructions } from "./instructions"; import { loadUserClaudeJsonMcpServers } from "./mcp-config"; import { DEFAULT_MODEL } from "./models"; import type { SettingsManager } from "./settings"; @@ -74,11 +74,13 @@ export interface BuildOptionsParams { export function buildSystemPrompt( customPrompt?: unknown, + branchPrefix?: string | null, ): Options["systemPrompt"] { + const appended = buildAppendedInstructions(branchPrefix); const defaultPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code", - append: APPENDED_INSTRUCTIONS, + append: appended, }; if (!customPrompt) { @@ -86,7 +88,7 @@ export function buildSystemPrompt( } if (typeof customPrompt === "string") { - return customPrompt + APPENDED_INSTRUCTIONS; + return customPrompt + appended; } if ( @@ -97,7 +99,7 @@ export function buildSystemPrompt( ) { return { ...defaultPrompt, - append: customPrompt.append + APPENDED_INSTRUCTIONS, + append: customPrompt.append + appended, }; } diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 3c1973125a..d988f827d3 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -169,6 +169,8 @@ export type NewSessionMeta = { model?: string; /** Base branch of the task's repo (e.g. "master"), for the signed-git tools. */ baseBranch?: string; + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix?: string; /** * Repo-less channel "generic chat box" session: enables the lazy-repo tools * (list_repos / clone_repo) and channel guidance. The agent decides at diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index e7030e7b18..1ab6496368 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -1406,6 +1406,15 @@ describe("AgentServer HTTP Mode", () => { delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; }); + it("uses a configured branch prefix in the cloud prompt", () => { + process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "signal_report"; + const s = createServer({ branchPrefix: "team/" }); + const prompt = (s as unknown as TestableServer).buildCloudSystemPrompt(); + expect(prompt).toContain("team/fix-login-redirect"); + expect(prompt).not.toContain("posthog-code/fix-login-redirect"); + delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; + }); + it("returns PR-update prompt for existing PRs on Slack-origin runs", () => { process.env.POSTHOG_CODE_INTERACTION_ORIGIN = "slack"; const s = createServer(); diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 5b01dc9968..27ff5b04c9 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -14,6 +14,7 @@ import { import { type ServerType, serve } from "@hono/node-server"; import { execGh } from "@posthog/git/gh"; import { getCurrentBranch } from "@posthog/git/queries"; +import { normalizeBranchPrefix } from "@posthog/shared"; import { Hono } from "hono"; import { z } from "zod"; import packageJson from "../../package.json" with { type: "json" }; @@ -1071,6 +1072,9 @@ export class AgentServer { jsonSchema: preTask?.json_schema ?? null, permissionMode: initialPermissionMode, ...(this.config.baseBranch && { baseBranch: this.config.baseBranch }), + ...(this.config.branchPrefix && { + branchPrefix: this.config.branchPrefix, + }), ...this.buildClaudeCodeSessionMeta(runtimeAdapter), }, }); @@ -1736,6 +1740,7 @@ export class AgentServer { slackThreadUrl?: string | null, ): string { const taskId = this.config.taskId; + const branchPrefix = normalizeBranchPrefix(this.config.branchPrefix); const shouldAutoCreatePr = this.shouldAutoPublishCloudChanges(); const isSlack = this.getCloudInteractionOrigin() === "slack"; const identityInstructions = isSlack @@ -1764,7 +1769,7 @@ Commits MUST be signed. \`git commit\` and \`git push\` are blocked in this envi To commit: stage your changes with \`git add\`, then call the \`git_signed_commit\` tool (full name \`${SIGNED_COMMIT_QUALIFIED_TOOL_NAME}\`) with a \`message\` (and optional \`body\`/\`paths\`). It creates a GitHub-signed ("Verified") commit on the branch and keeps your local checkout in -sync. To start a new branch, pass \`branch\` (prefixed with \`posthog-code/\`) — the tool creates +sync. To start a new branch, pass \`branch\` (prefixed with \`${branchPrefix}\`) — the tool creates it on the remote for you. ## Updating from the base branch @@ -1901,7 +1906,7 @@ ${signedCommitInstructions} # Cloud Task Execution After completing the requested changes: -1. Pick a new branch name prefixed with \`posthog-code/\` (e.g. \`posthog-code/fix-login-redirect\`) +1. Pick a new branch name prefixed with \`${branchPrefix}\` (e.g. \`${branchPrefix}fix-login-redirect\`) 2. Stage your changes with \`git add\`, then call the \`git_signed_commit\` tool with \`branch\` set to that name and a clear \`message\` (do NOT use \`git commit\`/\`git push\` — they are blocked). The tool creates the branch on the remote and a signed commit on it. 3. Before opening the PR, prepare the body: - Keep the PR description brief overall. Summarize only the most important changes — do NOT enumerate every change you made. A few sentences or bullets is plenty. diff --git a/packages/agent/src/server/bin.ts b/packages/agent/src/server/bin.ts index c70368ae9a..a61fd33d89 100644 --- a/packages/agent/src/server/bin.ts +++ b/packages/agent/src/server/bin.ts @@ -97,6 +97,10 @@ program ) .option("--createPr ", "Whether this run may publish changes") .option("--baseBranch ", "Base branch for PR creation") + .option( + "--branchPrefix ", + 'Prefix for branches the agent creates (default "posthog-code/")', + ) .option( "--claudeCodeConfig ", "Claude Code config as JSON (systemPrompt, systemPromptAppend, plugins)", @@ -170,6 +174,7 @@ program createPr, mcpServers, baseBranch: options.baseBranch, + branchPrefix: options.branchPrefix, claudeCode, allowedDomains, runtimeAdapter: env.POSTHOG_CODE_RUNTIME_ADAPTER, diff --git a/packages/agent/src/server/types.ts b/packages/agent/src/server/types.ts index d11cb7748c..256391a5e2 100644 --- a/packages/agent/src/server/types.ts +++ b/packages/agent/src/server/types.ts @@ -24,6 +24,8 @@ export interface AgentServerConfig { version?: string; mcpServers?: RemoteMcpServer[]; baseBranch?: string; + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix?: string; claudeCode?: ClaudeCodeConfig; allowedDomains?: string[]; runtimeAdapter?: "claude" | "codex"; diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 249701fa40..13ab708638 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -8,6 +8,7 @@ import type { StoredLogEntry, } from "@posthog/shared"; import { + BRANCH_PREFIX, DISMISSAL_REASON_OPTIONS, type DismissalReasonOptionValue, SEAT_PRODUCT_KEY, @@ -465,6 +466,8 @@ interface CloudRunOptions { adapter?: CloudRuntimeAdapter; model?: string; reasoningLevel?: string; + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix?: string; sandboxEnvironmentId?: string; prAuthorshipMode?: PrAuthorshipMode; runSource?: CloudRunSource; @@ -552,6 +555,10 @@ function buildCloudRunRequestBody( if (options?.homeQuickAction) { body.home_quick_action = options.homeQuickAction; } + // Only send a customized prefix; the backend defaults to "posthog-code/". + if (options?.branchPrefix && options.branchPrefix !== BRANCH_PREFIX) { + body.branch_prefix = options.branchPrefix; + } return body; } diff --git a/packages/core/src/git-interaction/branchName.test.ts b/packages/core/src/git-interaction/branchName.test.ts index b6247967d9..c7f163beae 100644 --- a/packages/core/src/git-interaction/branchName.test.ts +++ b/packages/core/src/git-interaction/branchName.test.ts @@ -112,4 +112,13 @@ describe("suggestBranchName", () => { ]), ).toBe("posthog-code/fix-bug-4"); }); + + it("uses a custom prefix and dedupes against it", () => { + expect(suggestBranchName("Fix bug", "abc", [], "team/")).toBe( + "team/fix-bug", + ); + expect(suggestBranchName("Fix bug", "abc", ["team/fix-bug"], "team/")).toBe( + "team/fix-bug-2", + ); + }); }); diff --git a/packages/core/src/git-interaction/branchName.ts b/packages/core/src/git-interaction/branchName.ts index d5301081eb..11700453ff 100644 --- a/packages/core/src/git-interaction/branchName.ts +++ b/packages/core/src/git-interaction/branchName.ts @@ -54,7 +54,11 @@ export function validateBranchName(name: string): string | null { return null; } -export function deriveBranchName(title: string, fallbackId: string): string { +export function deriveBranchName( + title: string, + fallbackId: string, + prefix: string = BRANCH_PREFIX, +): string { const slug = title .toLowerCase() .trim() @@ -64,16 +68,17 @@ export function deriveBranchName(title: string, fallbackId: string): string { .slice(0, 60) .replace(/-$/, ""); - if (!slug) return `${BRANCH_PREFIX}task-${fallbackId}`; - return `${BRANCH_PREFIX}${slug}`; + if (!slug) return `${prefix}task-${fallbackId}`; + return `${prefix}${slug}`; } export function suggestBranchName( title: string, fallbackId: string, existingBranches: string[], + prefix: string = BRANCH_PREFIX, ): string { - const base = deriveBranchName(title, fallbackId); + const base = deriveBranchName(title, fallbackId, prefix); if (!existingBranches.includes(base)) return base; diff --git a/packages/core/src/git-interaction/deriveBranchName.test.ts b/packages/core/src/git-interaction/deriveBranchName.test.ts index 1d4cc0b860..319f651a7b 100644 --- a/packages/core/src/git-interaction/deriveBranchName.test.ts +++ b/packages/core/src/git-interaction/deriveBranchName.test.ts @@ -48,4 +48,14 @@ describe("deriveBranchName", () => { "posthog-code/task-abc123", ); }); + + it("applies a custom prefix when provided", () => { + expect(deriveBranchName("Fix login bug", "abc123", "team/")).toBe( + "team/fix-login-bug", + ); + }); + + it("applies a custom prefix to the task-ID fallback", () => { + expect(deriveBranchName("", "abc123", "team/")).toBe("team/task-abc123"); + }); }); diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 6672cbbaa3..fdcaa50c47 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -253,7 +253,10 @@ export interface SessionServiceDeps { setAdapter(taskRunId: string, adapter: Adapter): void; removeAdapter(taskRunId: string): void; }; - readonly settings: { customInstructions?: string | null }; + readonly settings: { + customInstructions?: string | null; + branchPrefix?: string | null; + }; usageLimit: { show: (...args: any[]) => any }; readonly addDirectoryDialog: { open: boolean }; taskViewedApi: { markActivity(taskId: string): void }; @@ -817,7 +820,7 @@ export class SessionService { this.d.log.warn("Failed to verify workspace", { taskId, err }); }); - const { customInstructions } = this.d.settings; + const { customInstructions, branchPrefix } = this.d.settings; const result = await this.d.trpc.agent.reconnect.mutate({ taskId, taskRunId, @@ -830,6 +833,7 @@ export class SessionService { permissionMode: persistedMode, model: persistedModel, customInstructions: customInstructions || undefined, + branchPrefix: branchPrefix || undefined, }); if (result) { @@ -1129,7 +1133,10 @@ export class SessionService { throw new Error("Failed to create task run. Please try again."); } - const { customInstructions: startCustomInstructions } = this.d.settings; + const { + customInstructions: startCustomInstructions, + branchPrefix: startBranchPrefix, + } = this.d.settings; const preferredModel = model ?? this.d.DEFAULT_GATEWAY_MODEL; const result = await this.d.trpc.agent.start.mutate({ taskId, @@ -1140,6 +1147,7 @@ export class SessionService { permissionMode: executionMode, adapter, customInstructions: startCustomInstructions || undefined, + branchPrefix: startBranchPrefix || undefined, effort: effortLevelSchema.safeParse(reasoningLevel).success ? (reasoningLevel as EffortLevel) : undefined, diff --git a/packages/core/src/task-detail/taskCreationApiClient.ts b/packages/core/src/task-detail/taskCreationApiClient.ts index 3ef2f5ed56..d86e28d6d9 100644 --- a/packages/core/src/task-detail/taskCreationApiClient.ts +++ b/packages/core/src/task-detail/taskCreationApiClient.ts @@ -8,6 +8,8 @@ export interface CreateTaskRunClientOptions { adapter?: "claude" | "codex"; model?: string; reasoningLevel?: string; + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix?: string; sandboxEnvironmentId?: string; prAuthorshipMode?: PrAuthorshipMode; runSource?: CloudRunSource; diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index fef5e75dc0..0580332082 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -285,6 +285,7 @@ export class TaskCreationSaga extends Saga< adapter: input.adapter, model: input.model, reasoningLevel: input.reasoningLevel, + branchPrefix: input.branchPrefix, sandboxEnvironmentId: input.sandboxEnvironmentId, prAuthorshipMode, runSource: input.cloudRunSource ?? "manual", diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index a8ea7bbc7e..201a8b9227 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -14,6 +14,7 @@ export interface PrepareTaskInputOptions { adapter?: "claude" | "codex"; model?: string; reasoningLevel?: string; + branchPrefix?: string; environmentId?: string | null; sandboxEnvironmentId?: string; signalReportId?: string; @@ -46,6 +47,7 @@ export function prepareTaskInput( adapter: options.adapter, model: options.model, reasoningLevel: options.reasoningLevel, + branchPrefix: options.branchPrefix, environmentId: options.environmentId ?? undefined, sandboxEnvironmentId: options.sandboxEnvironmentId, cloudPrAuthorshipMode: diff --git a/packages/shared/src/git-naming.test.ts b/packages/shared/src/git-naming.test.ts new file mode 100644 index 0000000000..00fa3e6419 --- /dev/null +++ b/packages/shared/src/git-naming.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { BRANCH_PREFIX, normalizeBranchPrefix } from "./git-naming"; + +describe("normalizeBranchPrefix", () => { + it("falls back to the default for empty input", () => { + expect(normalizeBranchPrefix("")).toBe(BRANCH_PREFIX); + expect(normalizeBranchPrefix(" ")).toBe(BRANCH_PREFIX); + expect(normalizeBranchPrefix(undefined)).toBe(BRANCH_PREFIX); + expect(normalizeBranchPrefix(null)).toBe(BRANCH_PREFIX); + }); + + it("trims surrounding whitespace", () => { + expect(normalizeBranchPrefix(" team/ ")).toBe("team/"); + }); + + it("guarantees exactly one trailing slash", () => { + expect(normalizeBranchPrefix("team")).toBe("team/"); + expect(normalizeBranchPrefix("team/")).toBe("team/"); + expect(normalizeBranchPrefix("team//")).toBe("team/"); + expect(normalizeBranchPrefix("team-")).toBe("team-/"); + }); + + it("strips leading slashes and collapses repeated slashes", () => { + expect(normalizeBranchPrefix("/team/")).toBe("team/"); + expect(normalizeBranchPrefix("team//sub/")).toBe("team/sub/"); + }); + + it("falls back to the default when only slashes are provided", () => { + expect(normalizeBranchPrefix("/")).toBe(BRANCH_PREFIX); + }); +}); diff --git a/packages/shared/src/git-naming.ts b/packages/shared/src/git-naming.ts index 480f9d398b..764c1be631 100644 --- a/packages/shared/src/git-naming.ts +++ b/packages/shared/src/git-naming.ts @@ -1 +1,25 @@ +/** Default prefix applied to branches PostHog Code creates. */ export const BRANCH_PREFIX = "posthog-code/"; + +/** + * Max length of a custom branch prefix. A prefix is just a namespace (a GitHub + * username maxes at 39 chars, so `username/` fits in 40); the descriptive part + * of the branch is the slug, which is capped separately at 60. Keeps the full + * ref comfortably under git's ~250-byte limit. + */ +export const MAX_BRANCH_PREFIX_LENGTH = 40; + +/** + * Normalize a user-provided branch prefix into a safe, slash-terminated value, + * falling back to {@link BRANCH_PREFIX} when empty. Strips leading slashes and + * collapses repeated slashes (git refs cannot start with `/` or contain `//`), + * then guarantees exactly one trailing `/` so the prefix is always a + * slash-terminated namespace (`team` → `team/`). + */ +export function normalizeBranchPrefix(input?: string | null): string { + // Split on "/" and drop empty segments — this strips leading/trailing slashes + // and collapses repeats in linear time, avoiding the backtracking a regex + // like /\/+$/ would incur on adversarial input (CodeQL ReDoS). + const segments = (input ?? "").trim().split("/").filter(Boolean); + return segments.length === 0 ? BRANCH_PREFIX : `${segments.join("/")}/`; +} diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 93001c0d53..1d2e45be94 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -29,6 +29,8 @@ export interface TaskCreationInput { adapter?: "claude" | "codex"; model?: string; reasoningLevel?: string; + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix?: string; environmentId?: string; sandboxEnvironmentId?: string; cloudPrAuthorshipMode?: PrAuthorshipMode; diff --git a/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts b/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts index 5940e5ce99..3a481a7f71 100644 --- a/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts +++ b/packages/ui/src/features/git-interaction/utils/getSuggestedBranchName.ts @@ -2,7 +2,9 @@ import { deriveBranchName, suggestBranchName, } from "@posthog/core/git-interaction/branchName"; +import { normalizeBranchPrefix } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import type { QueryClient } from "@tanstack/react-query"; import type { GitCacheKeyProvider } from "../gitCacheProvider"; @@ -24,12 +26,16 @@ export function getSuggestedBranchName( ? String(task.task_number) : (task?.slug ?? taskId); - if (!repoPath) return deriveBranchName(task?.title ?? "", fallbackId); + const prefix = normalizeBranchPrefix( + useSettingsStore.getState().branchPrefix, + ); + + if (!repoPath) return deriveBranchName(task?.title ?? "", fallbackId, prefix); const cached = queryClient.getQueryData( provider.gitQueryKey("getAllBranches", { directoryPath: repoPath }), ) ?? []; - return suggestBranchName(task?.title ?? "", fallbackId, cached); + return suggestBranchName(task?.title ?? "", fallbackId, cached, prefix); } diff --git a/packages/ui/src/features/settings/sections/GeneralSettings.tsx b/packages/ui/src/features/settings/sections/GeneralSettings.tsx index 9702e59a72..f23aef2055 100644 --- a/packages/ui/src/features/settings/sections/GeneralSettings.tsx +++ b/packages/ui/src/features/settings/sections/GeneralSettings.tsx @@ -1,7 +1,13 @@ import { ArrowSquareOut } from "@phosphor-icons/react"; +import { validateBranchName } from "@posthog/core/git-interaction/branchName"; import { buildPostHogUrl } from "@posthog/core/settings/posthogUrl"; import { useHostTRPC } from "@posthog/host-router/react"; -import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { + ANALYTICS_EVENTS, + BRANCH_PREFIX, + MAX_BRANCH_PREFIX_LENGTH, + normalizeBranchPrefix, +} from "@posthog/shared"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { COLLAPSE_MODE_OPTIONS, @@ -30,9 +36,10 @@ import { Slider, Switch, Text, + TextField, } from "@radix-ui/themes"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; export function GeneralSettings() { @@ -102,8 +109,41 @@ export function GeneralSettings() { setSendMessagesWith, setConversationCollapseMode, setHedgehogMode, + branchPrefix, + setBranchPrefix, } = useSettingsStore(); + // The input holds a raw draft so typing stays responsive (no normalize/snap + // mid-keystroke); it's committed on blur. The preview/error are derived, not + // stored. + const [draftBranchPrefix, setDraftBranchPrefix] = useState(branchPrefix); + const normalizedDraftPrefix = normalizeBranchPrefix(draftBranchPrefix); + // Validate the prefix the way it is actually used — in front of a slug — so a + // value like "my team/" (space) or "feat/." surfaces an error before it can + // reach the agent prompt or a git_signed_commit call. + const branchPrefixError = validateBranchName( + `${normalizedDraftPrefix}example`, + ); + + // Mirror external store changes (async persisted-state hydration, or the + // setter normalizing the value) back into the editable draft. + useEffect(() => { + setDraftBranchPrefix(branchPrefix); + }, [branchPrefix]); + + const commitBranchPrefix = useCallback(() => { + const normalized = normalizeBranchPrefix(draftBranchPrefix); + if (normalized === branchPrefix) return; + // The store setter rejects invalid prefixes; skip the no-op + analytics + // when we already know it won't take. + if (validateBranchName(`${normalized}example`) !== null) return; + setBranchPrefix(normalized); + track(ANALYTICS_EVENTS.SETTING_CHANGED, { + setting_name: "branch_prefix", + new_value: normalized !== BRANCH_PREFIX, + }); + }, [draftBranchPrefix, branchPrefix, setBranchPrefix]); + // Sync toggle off if the user denied notification permission at the OS level useEffect(() => { if (window.Notification?.permission === "denied" && desktopNotifications) { @@ -519,6 +559,40 @@ export function GeneralSettings() { + {/* Version control */} + + Version control + + + + + setDraftBranchPrefix(e.target.value)} + onBlur={commitBranchPrefix} + placeholder={BRANCH_PREFIX} + size="1" + maxLength={MAX_BRANCH_PREFIX_LENGTH} + className="min-w-[240px]" + spellCheck={false} + color={branchPrefixError ? "red" : undefined} + /> + {branchPrefixError ? ( + + {branchPrefixError} + + ) : ( + + e.g. {normalizedDraftPrefix}fix-login-redirect + + )} + + + {/* Editor */} Editor diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index cfdf5c8757..030b5195dc 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -1,5 +1,11 @@ +import { validateBranchName } from "@posthog/core/git-interaction/branchName"; import type { UserRepositoryIntegrationRef } from "@posthog/core/integrations/repositories"; -import type { ExecutionMode, WorkspaceMode } from "@posthog/shared"; +import { + BRANCH_PREFIX, + type ExecutionMode, + normalizeBranchPrefix, + type WorkspaceMode, +} from "@posthog/shared"; import { COLLAPSE_MODE_DEFAULT, type CollapseMode, @@ -118,6 +124,10 @@ interface SettingsStore { diffOpenMode: DiffOpenMode; setDiffOpenMode: (mode: DiffOpenMode) => void; + // Version control + branchPrefix: string; + setBranchPrefix: (prefix: string) => void; + // System / power / permissions allowBypassPermissions: boolean; preventSleepWhileRunning: boolean; @@ -235,6 +245,19 @@ export const useSettingsStore = create()( diffOpenMode: "auto", setDiffOpenMode: (mode) => set({ diffOpenMode: mode }), + // Version control + branchPrefix: BRANCH_PREFIX, + // Self-enforcing on write: normalize the format and reject anything that + // would yield an invalid git ref, so no caller can persist a bad prefix + // (the agent and cloud paths read this value verbatim). validateBranchName + // (in @posthog/core) owns the ref rules; @posthog/shared's + // normalizeBranchPrefix is format-only since shared can't depend on core. + setBranchPrefix: (prefix) => { + const normalized = normalizeBranchPrefix(prefix); + if (validateBranchName(`${normalized}x`) !== null) return; + set({ branchPrefix: normalized }); + }, + // System / power / permissions allowBypassPermissions: false, preventSleepWhileRunning: false, @@ -333,6 +356,9 @@ export const useSettingsStore = create()( // Diff viewer diffOpenMode: state.diffOpenMode, + // Version control + branchPrefix: state.branchPrefix, + // System / power / permissions allowBypassPermissions: state.allowBypassPermissions, preventSleepWhileRunning: state.preventSleepWhileRunning, diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index c990e71361..80e3f4a4ca 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -271,6 +271,7 @@ export function useTaskCreation({ adapter, model, reasoningLevel, + branchPrefix: useSettingsStore.getState().branchPrefix, environmentId, sandboxEnvironmentId, signalReportId, diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index ebf83a9d3a..ecad6220f2 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -60,6 +60,7 @@ import { import { type AcpMessage, isAuthError, + normalizeBranchPrefix, serializeError, TypedEventEmitter, } from "@posthog/shared"; @@ -264,6 +265,8 @@ interface SessionConfig { permissionMode?: string; /** Custom instructions injected into the system prompt */ customInstructions?: string; + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix?: string; /** Replaces the PostHog system prompt entirely (constrained surfaces). */ systemPromptOverride?: string; /** Tool names denied for this session (passed to the Claude SDK). */ @@ -541,6 +544,7 @@ export class AgentService extends TypedEventEmitter { systemPromptOverride?: string, channelMode?: boolean, knownLocalFolders?: RegisteredFolder[], + branchPrefix?: string, ): { append: string; } { @@ -550,6 +554,8 @@ export class AgentService extends TypedEventEmitter { return { append: systemPromptOverride }; } + const prefix = normalizeBranchPrefix(branchPrefix); + let prompt = `PostHog context: use project ${credentials.projectId} on ${credentials.apiHost}. When using PostHog MCP tools, operate only on this project.`; prompt += ` @@ -572,7 +578,7 @@ EOF )" \`\`\` -When creating new branches, prefix them with \`posthog-code/\` (e.g. \`posthog-code/fix-login-redirect\`). +When creating new branches, prefix them with \`${prefix}\` (e.g. \`${prefix}fix-login-redirect\`). When creating pull requests, add the following footer at the end of the PR description: \`\`\` @@ -662,6 +668,7 @@ If a repository IS genuinely required, attach one in this priority order: adapter, permissionMode, customInstructions, + branchPrefix, systemPromptOverride, disallowedTools, effort, @@ -745,6 +752,7 @@ If a repository IS genuinely required, attach one in this priority order: systemPromptOverride, channelMode, knownLocalFolders, + branchPrefix, ); const bundledSkillsDir = join( @@ -943,6 +951,7 @@ If a repository IS genuinely required, attach one in this priority order: sessionId: existingSessionId, systemPrompt, ...(channelMode && { channelMode }), + ...(branchPrefix && { branchPrefix }), mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), @@ -969,6 +978,7 @@ If a repository IS genuinely required, attach one in this priority order: environment: "local", systemPrompt, ...(channelMode && { channelMode }), + ...(branchPrefix && { branchPrefix }), mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), @@ -1844,6 +1854,7 @@ For git operations while detached: "permissionMode" in params ? params.permissionMode : undefined, customInstructions: "customInstructions" in params ? params.customInstructions : undefined, + branchPrefix: "branchPrefix" in params ? params.branchPrefix : undefined, systemPromptOverride: "systemPromptOverride" in params ? params.systemPromptOverride diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index 5cfb8cd010..e257d8732a 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -2,6 +2,7 @@ import type { RequestPermissionRequest, PermissionOption as SdkPermissionOption, } from "@agentclientprotocol/sdk"; +import { MAX_BRANCH_PREFIX_LENGTH } from "@posthog/shared"; import { effortLevelSchema } from "@posthog/shared/domain-types"; import { z } from "zod"; @@ -48,6 +49,8 @@ export const startSessionInput = z.object({ adapter: z.enum(["claude", "codex"]).optional(), additionalDirectories: z.array(z.string()).optional(), customInstructions: z.string().max(2000).optional(), + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix: z.string().max(MAX_BRANCH_PREFIX_LENGTH).optional(), /** * Replaces the PostHog system prompt entirely for this session. Used by * constrained, single-purpose surfaces (e.g. the canvas generator) that drive @@ -187,6 +190,8 @@ export const reconnectSessionInput = z.object({ permissionMode: z.string().optional(), model: z.string().optional(), customInstructions: z.string().max(2000).optional(), + /** Prefix the agent applies to branches it creates (e.g. "posthog-code/"). */ + branchPrefix: z.string().max(MAX_BRANCH_PREFIX_LENGTH).optional(), effort: effortLevelSchema.optional(), jsonSchema: z.record(z.string(), z.unknown()).nullish(), });