diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index f44a7a5314..3c1973125a 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -169,6 +169,12 @@ export type NewSessionMeta = { model?: string; /** Base branch of the task's repo (e.g. "master"), for the signed-git tools. */ baseBranch?: string; + /** + * Repo-less channel "generic chat box" session: enables the lazy-repo tools + * (list_repos / clone_repo) and channel guidance. The agent decides at + * runtime whether it needs a repo and clones one only if so. + */ + channelMode?: boolean; jsonSchema?: Record | null; mcpToolApprovals?: McpToolApprovals; claudeCode?: { diff --git a/packages/agent/src/adapters/local-tools/index.ts b/packages/agent/src/adapters/local-tools/index.ts index 1272e18a73..04302ae215 100644 --- a/packages/agent/src/adapters/local-tools/index.ts +++ b/packages/agent/src/adapters/local-tools/index.ts @@ -1,4 +1,6 @@ import type { LocalTool, LocalToolCtx, LocalToolGateMeta } from "./registry"; +import { cloneRepoTool } from "./tools/clone-repo"; +import { listReposTool } from "./tools/list-repos"; import { signedCommitTool } from "./tools/signed-commit"; import { signedMergeTool } from "./tools/signed-merge"; import { signedRewriteTool } from "./tools/signed-rewrite"; @@ -17,6 +19,8 @@ export const LOCAL_TOOLS: LocalTool[] = [ signedCommitTool, signedMergeTool, signedRewriteTool, + listReposTool, + cloneRepoTool, ]; /** Tools whose gate passes for the given context — the set to actually expose. */ diff --git a/packages/agent/src/adapters/local-tools/registry.ts b/packages/agent/src/adapters/local-tools/registry.ts index 97e3da62b7..fd4b3ee807 100644 --- a/packages/agent/src/adapters/local-tools/registry.ts +++ b/packages/agent/src/adapters/local-tools/registry.ts @@ -25,6 +25,8 @@ export interface LocalToolCtx { /** Minimal session-meta shape needed to gate tools (e.g. cloud-only). */ export interface LocalToolGateMeta { environment?: "local" | "cloud"; + /** Repo-less channel session: enables the lazy-repo tools. */ + channelMode?: boolean; } /** diff --git a/packages/agent/src/adapters/local-tools/tools/clone-repo.ts b/packages/agent/src/adapters/local-tools/tools/clone-repo.ts new file mode 100644 index 0000000000..b0bc277d50 --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/clone-repo.ts @@ -0,0 +1,129 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { createGitClient } from "@posthog/git/client"; +import { getCurrentBranch } from "@posthog/git/queries"; +import { CloneSaga } from "@posthog/git/sagas/clone"; +import { z } from "zod"; +import { resolveGithubToken } from "../../../utils/github-token"; +import { defineLocalTool, type LocalToolResult } from "../registry"; + +const OWNER_REPO_RE = /^[\w.-]+\/[\w.-]+$/; + +const cloneRepoSchema = { + repo: z + .string() + .describe( + "Repository to clone, as 'owner/repo' (preferred) or a full https GitHub URL.", + ), + branch: z + .string() + .optional() + .describe( + "Optional branch to check out. Defaults to the repo's default branch.", + ), +}; + +function fail(text: string): LocalToolResult { + return { content: [{ type: "text", text }], isError: true }; +} + +/** + * Lazily brings a repo into a repo-less channel session's scratch workspace. + * Clones into `/repos/` (a subdir of the session cwd, so no session + * restart / cwd rebind is needed) and reports the path for the agent to cd into. + */ +export const cloneRepoTool = defineLocalTool({ + name: "clone_repo", + description: + "Clone a git repository into your working directory (channel tasks only). " + + "Use this once you've determined a coding task needs a specific repo. " + + "Returns the local path to cd into. Prefer repos named in the channel CONTEXT.md.", + schema: cloneRepoSchema, + alwaysLoad: true, + isEnabled: (_ctx, meta) => meta?.channelMode === true, + handler: async (ctx, args): Promise => { + const { repo, branch } = args; + const token = resolveGithubToken() ?? ctx.token; + + // Never surface the token to the model/transcript: git may echo the remote + // URL (with its embedded basic-auth credential) into error output. + const redact = (text: string): string => + token ? text.split(token).join("***") : text; + + const isOwnerRepo = OWNER_REPO_RE.test(repo); + const isHttpsUrl = /^https:\/\/github\.com\//.test(repo); + if (!isOwnerRepo && !isHttpsUrl) { + return fail( + `clone_repo: invalid repo "${repo}". Pass 'owner/repo' or a full https://github.com/... URL.`, + ); + } + + const slug = isOwnerRepo + ? repo + : repo.replace(/^https:\/\/github\.com\//, "").replace(/\.git$/, ""); + const repoName = slug.split("/").pop() ?? "repo"; + const targetPath = path.join(ctx.cwd, "repos", slug); + + const done = async (note?: string): Promise => { + const checkedOut = (await getCurrentBranch(targetPath)) ?? branch ?? null; + return { + content: [ + { + type: "text", + text: `${note ?? `Cloned ${slug} (${repoName}) to ${targetPath}`}${ + checkedOut ? ` on branch ${checkedOut}` : "" + }. cd into this path for all git and file work in this repo.`, + }, + ], + }; + }; + + const checkout = async (): Promise => { + if (!branch) return null; + try { + await createGitClient(targetPath).checkout(branch); + return null; + } catch (err) { + return fail( + `Cloned ${slug} to ${targetPath} but failed to check out branch "${branch}": ${redact( + err instanceof Error ? err.message : String(err), + )}. The default branch is checked out instead.`, + ); + } + }; + + // Idempotent: a prior clone (retry, reconnected session, LLM loop) leaves + // the repo in place. Reuse it instead of letting git abort on a non-empty + // destination, which the agent would receive as an opaque error. + if (fs.existsSync(path.join(targetPath, ".git"))) { + return ( + (await checkout()) ?? + (await done(`${slug} already cloned at ${targetPath}`)) + ); + } + + // GitHub accepts a token as the basic-auth username for https clones; this + // covers private repos. Public repos clone fine without it. + const cloneUrl = token + ? `https://x-access-token:${token}@github.com/${slug}.git` + : `https://github.com/${slug}.git`; + + try { + const result = await new CloneSaga().run({ + repoUrl: cloneUrl, + targetPath, + }); + if (!result.success) { + return fail(`clone_repo failed: ${redact(result.error)}`); + } + + return (await checkout()) ?? (await done()); + } catch (err) { + return fail( + `clone_repo failed: ${redact( + err instanceof Error ? err.message : String(err), + )}`, + ); + } + }, +}); diff --git a/packages/agent/src/adapters/local-tools/tools/list-repos.ts b/packages/agent/src/adapters/local-tools/tools/list-repos.ts new file mode 100644 index 0000000000..06a1748a5d --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/list-repos.ts @@ -0,0 +1,97 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { z } from "zod"; +import { resolveGithubToken } from "../../../utils/github-token"; +import { defineLocalTool, type LocalToolResult } from "../registry"; + +const execFileAsync = promisify(execFile); + +const listReposSchema = { + owner: z + .string() + .optional() + .describe("GitHub org or user to list repos for. Omit to list your own."), + query: z + .string() + .optional() + .describe("Case-insensitive substring to filter repository names by."), + limit: z + .number() + .int() + .min(1) + .max(200) + .optional() + .describe("Max repos to return (default 50)."), +}; + +interface GhRepo { + nameWithOwner: string; + description?: string; +} + +/** + * Lists candidate GitHub repositories for a repo-less channel session, via the + * `gh` CLI. The agent cross-references these against the channel CONTEXT.md + * (which lists the most likely repos) and asks the user if still unsure. + */ +export const listReposTool = defineLocalTool({ + name: "list_repos", + description: + "List available GitHub repositories (channel tasks only). Use to discover " + + "which repo a coding task belongs to. Prefer repos named in the channel " + + "CONTEXT.md; if still unsure, ask the user before cloning.", + schema: listReposSchema, + alwaysLoad: true, + isEnabled: (_ctx, meta) => meta?.channelMode === true, + handler: async (ctx, args): Promise => { + const { owner, query, limit } = args; + const token = resolveGithubToken() ?? ctx.token; + + const cmdArgs = ["repo", "list"]; + if (owner) cmdArgs.push(owner); + cmdArgs.push( + "--no-archived", + "--json", + "nameWithOwner,description", + "--limit", + String(limit ?? 50), + ); + + try { + const { stdout } = await execFileAsync("gh", cmdArgs, { + env: token ? { ...process.env, GH_TOKEN: token } : process.env, + maxBuffer: 1024 * 1024 * 8, + }); + let repos = JSON.parse(stdout) as GhRepo[]; + if (query) { + const q = query.toLowerCase(); + repos = repos.filter((r) => r.nameWithOwner.toLowerCase().includes(q)); + } + if (repos.length === 0) { + return { + content: [{ type: "text", text: "No repositories found." }], + }; + } + const lines = repos.map((r) => + r.description + ? `${r.nameWithOwner} — ${r.description}` + : r.nameWithOwner, + ); + return { content: [{ type: "text", text: lines.join("\n") }] }; + } catch (err) { + return { + content: [ + { + type: "text", + text: + `Couldn't list repositories via gh (${ + err instanceof Error ? err.message : String(err) + }). Determine the repo from the request and the channel CONTEXT.md, ` + + `or ask the user which repo to use, then call clone_repo with 'owner/repo'.`, + }, + ], + isError: true, + }; + } + }, +}); diff --git a/packages/core/src/task-detail/taskCreationHost.ts b/packages/core/src/task-detail/taskCreationHost.ts index 7329f050b6..e321443d11 100644 --- a/packages/core/src/task-detail/taskCreationHost.ts +++ b/packages/core/src/task-detail/taskCreationHost.ts @@ -55,6 +55,11 @@ export interface ITaskCreationHost { getAuthenticatedClient(): Promise; assertCloudUsageAvailable(): Promise; getTaskDirectory(taskId: string, repoKey?: string): Promise; + /** + * Ensure a per-task scratch working directory exists for a repo-less channel + * task. Returns its absolute path so the agent session can start there. + */ + ensureScratchDir(taskId: string): Promise; getWorkspace(taskId: string): Promise; createWorkspace(args: CreateWorkspaceArgs): Promise; deleteWorkspace(args: { diff --git a/packages/core/src/task-detail/taskCreationSaga.test.ts b/packages/core/src/task-detail/taskCreationSaga.test.ts index 929def3723..33e0799e32 100644 --- a/packages/core/src/task-detail/taskCreationSaga.test.ts +++ b/packages/core/src/task-detail/taskCreationSaga.test.ts @@ -9,6 +9,7 @@ import type { const mockHost = vi.hoisted(() => ({ getAuthenticatedClient: vi.fn(), getTaskDirectory: vi.fn(), + ensureScratchDir: vi.fn(), getWorkspace: vi.fn(), createWorkspace: vi.fn(), deleteWorkspace: vi.fn(), @@ -70,6 +71,7 @@ describe("TaskCreationSaga", () => { mockHost.createWorkspace.mockResolvedValue({}); mockHost.deleteWorkspace.mockResolvedValue(undefined); mockHost.getTaskDirectory.mockResolvedValue(null); + mockHost.ensureScratchDir.mockResolvedValue("/tmp/scratch/task-123"); mockHost.getWorkspace.mockResolvedValue(null); mockHost.getFolders.mockResolvedValue([]); mockHost.uploadRunAttachments.mockResolvedValue([]); @@ -155,6 +157,41 @@ describe("TaskCreationSaga", () => { ); }); + it("starts a repo-less channel task in a scratch dir (allowNoRepo)", async () => { + const createdTask = createTask({ repository: undefined }); + const createTaskMock = vi.fn().mockResolvedValue(createdTask); + + const saga = new TaskCreationSaga({ + posthogClient: { + createTask: createTaskMock, + deleteTask: vi.fn(), + getTask: vi.fn(), + createTaskRun: vi.fn(), + startTaskRun: vi.fn(), + sendRunCommand: vi.fn(), + updateTask: vi.fn(), + } as never, + host, + sessionService, + track: vi.fn(), + }); + + const result = await saga.run({ + content: "Draft a launch email", + workspaceMode: "local", + allowNoRepo: true, + }); + + expect(result.success).toBe(true); + // No repo selected → no workspace created, but a scratch dir is provisioned + // and the agent session connects there. + expect(mockHost.createWorkspace).not.toHaveBeenCalled(); + expect(mockHost.ensureScratchDir).toHaveBeenCalledWith("task-123"); + expect(sessionService.connectToTask).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: "/tmp/scratch/task-123" }), + ); + }); + it("uploads initial cloud attachments before starting the run", async () => { const createdTask = createTask(); const startedTask = createTask({ latest_run: createRun() }); diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 8c55ed59cf..174d510efc 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -204,6 +204,22 @@ export class TaskCreationSaga extends Saga< const shouldStartCloudRun = workspaceMode === "cloud" && !task.latest_run; + // Channels "generic chat box": a repo-less local/worktree task still starts + // an agent, in a per-task scratch dir. Provision it before signalling the + // task is ready so the task view resolves the scratch dir as its cwd (a + // synthetic local workspace) instead of showing the repo-picker prompt. + let scratchCwd: string | null = null; + if ( + !repoPath && + !input.taskId && + workspaceMode !== "cloud" && + input.allowNoRepo + ) { + scratchCwd = await this.readOnlyStep("scratch_dir", () => + this.deps.host.ensureScratchDir(task.id), + ); + } + if (!hasProvisioning && !shouldStartCloudRun && this.deps.onTaskReady) { this.deps.onTaskReady({ task, workspace }); } @@ -318,9 +334,13 @@ export class TaskCreationSaga extends Saga< } } - const agentCwd = - workspace?.worktreePath ?? workspace?.folderPath ?? repoPath; const isCloudCreate = !input.taskId && workspaceMode === "cloud"; + const agentCwd = + workspace?.worktreePath ?? + workspace?.folderPath ?? + repoPath ?? + scratchCwd; + const shouldConnect = !isCloudCreate && (!!input.taskId || !!agentCwd); if (shouldConnect) { diff --git a/packages/core/src/task-detail/taskInput.ts b/packages/core/src/task-detail/taskInput.ts index 3f3ed7f35f..6849e8eda1 100644 --- a/packages/core/src/task-detail/taskInput.ts +++ b/packages/core/src/task-detail/taskInput.ts @@ -20,6 +20,7 @@ export interface PrepareTaskInputOptions { additionalDirectories?: string[]; channelContext?: string; channelName?: string; + allowNoRepo?: boolean; } export function prepareTaskInput( @@ -34,7 +35,7 @@ export function prepareTaskInput( ? buildCloudTaskDescription(serializedContent, filePaths) : undefined, filePaths, - repoPath: isCloud ? undefined : options.selectedDirectory, + repoPath: isCloud ? undefined : options.selectedDirectory || undefined, repository: isCloud ? options.selectedRepository : undefined, githubIntegrationId: options.githubIntegrationId, githubUserIntegrationId: options.githubUserIntegrationId, @@ -55,6 +56,7 @@ export function prepareTaskInput( additionalDirectories: isCloud ? undefined : options.additionalDirectories, channelContext: options.channelContext, channelName: options.channelName, + allowNoRepo: options.allowNoRepo, }; } diff --git a/packages/host-router/src/routers/workspace.router.ts b/packages/host-router/src/routers/workspace.router.ts index 9c1621345e..f87dda1e7d 100644 --- a/packages/host-router/src/routers/workspace.router.ts +++ b/packages/host-router/src/routers/workspace.router.ts @@ -10,6 +10,8 @@ import { createWorkspaceOutput, deleteWorkspaceInput, deleteWorktreeInput, + ensureScratchDirInput, + ensureScratchDirOutput, getAllTaskTimestampsOutput, getAllWorkspacesOutput, getLocalTasksInput, @@ -113,6 +115,13 @@ export const workspaceRouter = router({ getService(ctx.container).verifyWorkspaceExists(input.taskId), ), + ensureScratchDir: publicProcedure + .input(ensureScratchDirInput) + .output(ensureScratchDirOutput) + .mutation(async ({ ctx, input }) => ({ + path: await getService(ctx.container).ensureScratchDir(input.taskId), + })), + getInfo: publicProcedure .input(getWorkspaceInfoInput) .output(getWorkspaceInfoOutput) diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 8bb489da29..a07e08eb66 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -43,6 +43,13 @@ export interface TaskCreationInput { channelContext?: string; /** Display name of that channel, embedded in the context block for the UI. */ channelName?: string; + /** + * When true, the task may be created without a repo/branch. Used by the + * channels "generic chat box": the agent decides at runtime whether it needs + * a repo and attaches one lazily. A local session still starts, in a scratch + * working directory, so non-code tasks (analysis, email) can run repo-less. + */ + allowNoRepo?: boolean; } export interface TaskCreationOutput { diff --git a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx index eb84ea0308..7b83b47324 100644 --- a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx @@ -46,6 +46,7 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { onTaskCreated={onTaskCreated} channelContext={instructions?.content} channelName={channelName} + allowNoRepo /> ); } diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index dc8848e288..5f997629c4 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -71,6 +71,12 @@ interface TaskInputProps { channelContext?: string; /** Display name of the channel the CONTEXT.md came from (for the chip). */ channelName?: string; + /** + * Channels "generic chat box" mode: hide the repo/branch pickers and let the + * task be submitted without a repo. The agent decides at runtime whether it + * needs a repo and attaches one lazily. + */ + allowNoRepo?: boolean; } export function TaskInput({ @@ -84,6 +90,7 @@ export function TaskInput({ reportAssociation, channelContext, channelName, + allowNoRepo, }: TaskInputProps = {}) { const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const trpc = useHostTRPC(); @@ -544,6 +551,7 @@ export function TaskInput({ signalReportId: activeReportAssociation?.reportId, channelContext: includeChannelContext ? channelContext : undefined, channelName, + allowNoRepo, }); const handleModeChange = useCallback( @@ -683,7 +691,7 @@ export function TaskInput({ onCloudEnvironmentChange={setSelectedCloudEnvId} size="1" /> - {workspaceMode === "worktree" && ( + {!allowNoRepo && workspaceMode === "worktree" && ( )} - - {workspaceMode === "cloud" ? ( - + {workspaceMode === "cloud" ? ( + + ) : ( + + )} + - ) : ( - - )} - - - {workspaceMode !== "cloud" && ( + + )} + {!allowNoRepo && workspaceMode !== "cloud" && ( void; } @@ -142,11 +148,13 @@ export function useTaskCreation({ signalReportId, channelContext, channelName, + allowNoRepo, onTaskCreated, }: UseTaskCreationOptions): UseTaskCreationReturn { const [isCreatingTask, setIsCreatingTask] = useState(false); const hostClient = useHostTRPCClient(); const trpc = useHostTRPC(); + const queryClient = useQueryClient(); const defaultAdditionalDirectoriesQuery = useQuery( trpc.additionalDirectories.listDefaults.queryOptions(), ); @@ -166,8 +174,11 @@ export function useTaskCreation({ const { invalidateTasks } = useCreateTask(); const { isOnline } = useConnectivity(); - const hasRequiredPath = - workspaceMode === "cloud" ? !!selectedRepository : !!selectedDirectory; + const hasRequiredPath = allowNoRepo + ? true + : workspaceMode === "cloud" + ? !!selectedRepository + : !!selectedDirectory; const canSubmitBase = isAuthenticated && isOnline && hasRequiredPath && !isCreatingTask; const canSubmit = !!editorRef.current && canSubmitBase && !editorIsEmpty; @@ -243,8 +254,10 @@ export function useTaskCreation({ const serializedContent = contentToXml(content).trim(); const filePaths = extractFilePaths(content); const input = prepareTaskInput(serializedContent, filePaths, { - selectedDirectory, - selectedRepository, + // In channels chat-box mode no repo is attached up front, even if a + // directory/repo is lingering in the persisted picker state. + selectedDirectory: allowNoRepo ? "" : selectedDirectory, + selectedRepository: allowNoRepo ? null : selectedRepository, githubIntegrationId, githubUserIntegrationId, workspaceMode, @@ -260,6 +273,7 @@ export function useTaskCreation({ additionalDirectories, channelContext, channelName, + allowNoRepo, }); if (executionMode) { @@ -293,6 +307,15 @@ export function useTaskCreation({ if (result.success) { setAdditionalDirectoriesOverride(null); void trackTaskCreated(input, selectedDirectory, hostClient); + // Repo-less channel tasks create no workspace row (the agent runs in + // a scratch dir surfaced as a synthetic workspace), so the normal + // workspace.create invalidation never fires. Refresh the workspace + // cache so the task view resolves its cwd and skips the repo prompt. + if (allowNoRepo) { + void queryClient.invalidateQueries({ + queryKey: trpc.workspace.getAll.queryKey(), + }); + } } if (!result.success) { @@ -348,10 +371,13 @@ export function useTaskCreation({ additionalDirectories, channelContext, channelName, + allowNoRepo, clearTaskInputReportAssociation, invalidateTasks, onTaskCreated, hostClient, + trpc, + queryClient, taskService, ], ); diff --git a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts index 191bd9b532..d88885507f 100644 --- a/packages/ui/src/features/task-detail/taskCreationHostImpl.ts +++ b/packages/ui/src/features/task-detail/taskCreationHostImpl.ts @@ -79,6 +79,13 @@ export class TrpcTaskCreationHost implements ITaskCreationHost { return null; } + async ensureScratchDir(taskId: string): Promise { + const { path } = await hostClient().workspace.ensureScratchDir.mutate({ + taskId, + }); + return path; + } + async getWorkspace(taskId: string): Promise { const workspaces = await hostClient().workspace.getAll.query(); return workspaces?.[taskId] ?? null; diff --git a/packages/workspace-server/src/services/agent/agent.test.ts b/packages/workspace-server/src/services/agent/agent.test.ts index 3a0053d912..40c713a625 100644 --- a/packages/workspace-server/src/services/agent/agent.test.ts +++ b/packages/workspace-server/src/services/agent/agent.test.ts @@ -173,6 +173,9 @@ function createMockDependencies() { addAdditionalDirectory: vi.fn(), removeAdditionalDirectory: vi.fn(), }, + workspaceSettings: { + getWorktreeLocation: () => "/mock/worktrees", + }, loggerFactory: { scope: () => ({ info: vi.fn(), @@ -216,6 +219,7 @@ describe("AgentService", () => { deps.appMeta as never, deps.storagePaths as never, deps.workspaceRepository as never, + deps.workspaceSettings as never, deps.loggerFactory as never, ); vi.spyOn(service, "emit"); diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index 11a7df8009..a900e6f11a 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -52,6 +52,10 @@ import { type IStoragePaths, STORAGE_PATHS_SERVICE, } from "@posthog/platform/storage-paths"; +import { + type IWorkspaceSettings, + WORKSPACE_SETTINGS_SERVICE, +} from "@posthog/platform/workspace-settings"; import { type AcpMessage, isAuthError, @@ -66,6 +70,7 @@ import type { PosthogPluginService } from "../posthog-plugin/posthog-plugin"; import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; import type { ProcessTrackingService } from "../process-tracking/process-tracking"; import { loadSessionEnvOverrides } from "../session-env/loader"; +import { isScratchPath } from "../workspace/scratch"; import type { AgentAuthAdapter, McpToolInstallations } from "./auth-adapter"; import { discoverExternalPlugins } from "./discover-plugins"; import { @@ -360,6 +365,8 @@ export class AgentService extends TypedEventEmitter { private readonly storagePaths: IStoragePaths, @inject(WORKSPACE_REPOSITORY) private readonly workspaceRepository: IWorkspaceRepository, + @inject(WORKSPACE_SETTINGS_SERVICE) + private readonly workspaceSettings: IWorkspaceSettings, @inject(AGENT_LOGGER) loggerFactory: AgentLogger, ) { @@ -522,6 +529,7 @@ export class AgentService extends TypedEventEmitter { customInstructions?: string, additionalDirectories?: string[], systemPromptOverride?: string, + channelMode?: boolean, ): { append: string; } { @@ -561,6 +569,19 @@ When creating pull requests, add the following footer at the end of the PR descr *Created with [PostHog Code](https://posthog.com/code?ref=pr)* \`\`\``; + if (channelMode) { + prompt += ` + +## Channel task (no repository attached) +You are running in a PostHog channel as a general-purpose assistant. This task may NOT need a code repository at all — it could be data analysis via PostHog tools, drafting a message, or answering a question. Do not assume you need a repo. + +- Your working directory is a scratch directory, not a git checkout. Treat it as empty. +- Decide from the user's request (and the channel CONTEXT.md included above, if any) whether the task actually requires working inside a code repository. +- Only if a repository is genuinely required: pick which one from the request and CONTEXT.md. Repositories named in CONTEXT.md are the most likely candidates — prefer them. Call \`list_repos\` to see what is available. +- Bring a repo into your workspace with the \`clone_repo\` tool (pass \`owner/repo\`). It clones into a subdirectory of your working directory and returns the path — cd into that path for all git work. +- If a repository is required but you cannot confidently determine which one, use the AskUserQuestion tool to ask the user to choose before cloning. Do not guess.`; + } + if (customInstructions) { prompt += `\n\nUser custom instructions:\n${customInstructions}`; } @@ -626,6 +647,14 @@ When creating pull requests, add the following footer at the end of the PR descr // Preview config doesn't need a real repo — use a temp directory const repoPath = taskId === "__preview__" ? tmpdir() : rawRepoPath; + // Repo-less channel tasks run in a scratch dir. Detecting it server-side + // (rather than plumbing a flag from the client) keeps channel mode correct + // across reconnects, where the same scratch repoPath is passed back in. + const channelMode = isScratchPath( + repoPath, + this.workspaceSettings.getWorktreeLocation(), + ); + const additionalDirectories = taskId === "__preview__" ? [] @@ -682,6 +711,7 @@ When creating pull requests, add the following footer at the end of the PR descr customInstructions, additionalDirectories, systemPromptOverride, + channelMode, ); const acpConnection = await agent.run(taskId, taskRunId, { @@ -854,6 +884,7 @@ When creating pull requests, add the following footer at the end of the PR descr environment: "local", sessionId: existingSessionId, systemPrompt, + ...(channelMode && { channelMode }), mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), @@ -879,6 +910,7 @@ When creating pull requests, add the following footer at the end of the PR descr taskRunId, environment: "local", systemPrompt, + ...(channelMode && { channelMode }), mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), diff --git a/packages/workspace-server/src/services/workspace/schemas.ts b/packages/workspace-server/src/services/workspace/schemas.ts index 973c4628e6..d6fd388ba6 100644 --- a/packages/workspace-server/src/services/workspace/schemas.ts +++ b/packages/workspace-server/src/services/workspace/schemas.ts @@ -65,6 +65,14 @@ export const verifyWorkspaceInput = z.object({ taskId: z.string(), }); +export const ensureScratchDirInput = z.object({ + taskId: z.string(), +}); + +export const ensureScratchDirOutput = z.object({ + path: z.string(), +}); + export const getWorkspaceInfoInput = z.object({ taskId: z.string(), }); diff --git a/packages/workspace-server/src/services/workspace/scratch.ts b/packages/workspace-server/src/services/workspace/scratch.ts new file mode 100644 index 0000000000..22441abf4e --- /dev/null +++ b/packages/workspace-server/src/services/workspace/scratch.ts @@ -0,0 +1,34 @@ +import path from "node:path"; + +/** + * Folder holding per-task scratch working directories for repo-less channel + * tasks (the "generic chat box"). A repo-less channel session runs here instead + * of in a git workspace; the agent clones a repo into a subdirectory only if it + * decides it needs one. + * + * The name is shared so both the WorkspaceService (which creates scratch dirs) + * and the AgentService (which detects them to enable channel-mode behavior) + * agree on it without one importing the other's service. + */ +export const SCRATCH_DIR_NAME = "posthog-code-scratch"; + +/** Base directory for scratch dirs: a sibling of the worktree location. */ +export function scratchBasePath(worktreeLocation: string): string { + return path.join(path.dirname(worktreeLocation), SCRATCH_DIR_NAME); +} + +/** + * Whether a working directory lives under the scratch base (i.e. it's a + * repo-less channel session, possibly cd'd into a cloned subdir). Checks an + * actual path prefix against the real scratch base rather than just scanning + * for the folder name, so an unrelated dir that happens to contain a + * `posthog-code-scratch` segment can't spuriously enable channel mode. + */ +export function isScratchPath( + workingDir: string, + worktreeLocation: string, +): boolean { + const base = path.resolve(scratchBasePath(worktreeLocation)); + const dir = path.resolve(workingDir); + return dir === base || dir.startsWith(base + path.sep); +} diff --git a/packages/workspace-server/src/services/workspace/workspace.ts b/packages/workspace-server/src/services/workspace/workspace.ts index 35f645b79b..8abd54d613 100644 --- a/packages/workspace-server/src/services/workspace/workspace.ts +++ b/packages/workspace-server/src/services/workspace/workspace.ts @@ -72,6 +72,7 @@ import type { WorkspaceWarningPayload, WorktreeInfo, } from "./schemas"; +import { scratchBasePath } from "./scratch"; type TaskAssociation = | { taskId: string; folderId: string; mode: "local" } @@ -698,6 +699,15 @@ export class WorkspaceService extends TypedEventEmitter const association = this.findTaskAssociation(taskId); if (!association) { + // Repo-less channel task: no workspace row, just a scratch dir to remove. + const scratchPath = this.getScratchPath(taskId); + if (fs.existsSync(scratchPath)) { + await this.agent.cancelSessionsByTaskId(taskId); + this.processTracking.killByTaskId(taskId); + await fs.promises.rm(scratchPath, { recursive: true, force: true }); + this.log.info(`Scratch workspace deleted for task ${taskId}`); + return; + } this.log.warn(`No workspace found for task ${taskId}`); return; } @@ -811,11 +821,75 @@ export class WorkspaceService extends TypedEventEmitter } } + /** + * Base directory holding per-task scratch dirs for repo-less channel tasks. + * A sibling of the worktree location so it lives under the same managed + * storage root but never shows up in worktree enumeration. + */ + private getScratchPath(taskId: string): string { + return path.join( + scratchBasePath(this.workspaceSettings.getWorktreeLocation()), + taskId, + ); + } + + /** + * Ensure a per-task scratch working directory exists for a repo-less channel + * task ("generic chat box"). The agent starts here and clones a repo into a + * subdirectory only if it decides it needs one. + */ + async ensureScratchDir(taskId: string): Promise { + const scratchPath = this.getScratchPath(taskId); + await fs.promises.mkdir(scratchPath, { recursive: true }); + return scratchPath; + } + + /** Task IDs that have a scratch dir on disk (repo-less channel tasks). */ + private listScratchTaskIds(): string[] { + const base = scratchBasePath(this.workspaceSettings.getWorktreeLocation()); + try { + return fs + .readdirSync(base, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + return []; + } + } + + /** + * A repo-less channel task has no workspace row — its working directory is a + * scratch dir. Synthesize a local-mode Workspace from it so the rest of the + * app (cwd resolution, session connect/reconnect, verify) treats it as a + * normal local workspace instead of prompting the user to pick a repo. + */ + private buildScratchWorkspace(taskId: string): Workspace { + return { + taskId, + folderId: "", + folderPath: this.getScratchPath(taskId), + mode: "local", + worktreePath: null, + worktreeName: null, + branchName: null, + baseBranch: null, + linkedBranch: null, + createdAt: new Date().toISOString(), + }; + } + async verifyWorkspaceExists( taskId: string, ): Promise<{ exists: boolean; missingPath?: string }> { const association = this.findTaskAssociation(taskId); if (!association) { + // Repo-less channel tasks create no workspace association — they run in a + // scratch dir. Treat an existing scratch dir as a valid workspace so the + // session isn't flagged as "working directory no longer exists". + const scratchPath = this.getScratchPath(taskId); + if (fs.existsSync(scratchPath)) { + return { exists: true }; + } return { exists: false }; } @@ -855,7 +929,13 @@ export class WorkspaceService extends TypedEventEmitter async getWorkspace(taskId: string): Promise { const assoc = this.findTaskAssociation(taskId); - if (!assoc) return null; + if (!assoc) { + // Repo-less channel task: working dir is a scratch dir, no workspace row. + if (fs.existsSync(this.getScratchPath(taskId))) { + return this.buildScratchWorkspace(taskId); + } + return null; + } const dbRow = this.workspaceRepo.findByTaskId(taskId); const linkedBranch = dbRow?.linkedBranch ?? null; @@ -1022,6 +1102,14 @@ export class WorkspaceService extends TypedEventEmitter }; } + // Repo-less channel tasks (scratch dirs) have no workspace row; surface + // them as local workspaces so they resolve a cwd and skip the repo prompt. + for (const taskId of this.listScratchTaskIds()) { + if (!workspaces[taskId]) { + workspaces[taskId] = this.buildScratchWorkspace(taskId); + } + } + return workspaces; } diff --git a/packages/workspace-server/src/services/workspace/workspace.verify.test.ts b/packages/workspace-server/src/services/workspace/workspace.verify.test.ts index 8c1de95b44..777fabe95c 100644 --- a/packages/workspace-server/src/services/workspace/workspace.verify.test.ts +++ b/packages/workspace-server/src/services/workspace/workspace.verify.test.ts @@ -136,4 +136,33 @@ describe("WorkspaceService.verifyWorkspaceExists", () => { expect(result.missingPath).toBe(repoPath); expect(workspaceRepo.findByTaskId(TASK_ID)).not.toBeNull(); }); + + it("treats a scratch dir (no workspace row) as a valid local workspace", async () => { + const { service, workspaceRepo } = createService(worktreeBasePath); + + // No workspace row exists for a repo-less channel task. + expect(workspaceRepo.findByTaskId(TASK_ID)).toBeNull(); + + const scratchPath = await service.ensureScratchDir(TASK_ID); + expect(scratchPath).toContain("posthog-code-scratch"); + + // verify, getWorkspace and getAllWorkspaces all treat it as a local + // workspace, so the UI resolves a cwd and skips the repo-picker prompt. + const verify = await service.verifyWorkspaceExists(TASK_ID); + expect(verify.exists).toBe(true); + + const workspace = await service.getWorkspace(TASK_ID); + expect(workspace).toMatchObject({ + taskId: TASK_ID, + mode: "local", + folderPath: scratchPath, + worktreePath: null, + }); + + const all = await service.getAllWorkspaces(); + expect(all[TASK_ID]?.folderPath).toBe(scratchPath); + + // It is not backed by a DB row. + expect(workspaceRepo.findByTaskId(TASK_ID)).toBeNull(); + }); });