From 341856d5c866726df0c89eb2bb11967372f7b064 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Wed, 17 Jun 2026 10:27:43 -0700 Subject: [PATCH 1/4] feat(channels): generic chat box with lazy repo attach In the channels experience, drop the up-front repo/branch picker. A channel task is now a generic chat box: it can be a coding task, an analysis task, or a non-code task (e.g. an email) that needs no repo. The agent decides at runtime whether it needs a repo, clones one only when needed, and asks the user (via AskUserQuestion) if it can't tell which repo to use. Works in local/worktree mode and cloud mode. - UI: allowNoRepo flag (channel-only) hides the repo/branch pickers and removes the submit gate; /code/ is unchanged. - Saga: a repo-less local task starts an agent in a per-task scratch dir (ensureScratchDir) instead of skipping the session; workspace.verify treats the scratch dir as valid. - Agent: channelMode is derived server-side from the scratch path (so it survives reconnects). It enables a channel system-prompt and two local tools, list_repos (gh) and clone_repo (clones into a scratch subdir, no cwd rebind), gated on channelMode. Cloud channel tasks rely on the existing cloud "No Repository Mode" prompt; the new tools are local-only for now. Generated-By: PostHog Code Task-Id: 9c51d9dc-d108-41b2-abdd-0be442a5e36d --- packages/agent/src/adapters/claude/types.ts | 6 + .../agent/src/adapters/local-tools/index.ts | 4 + .../src/adapters/local-tools/registry.ts | 2 + .../adapters/local-tools/tools/clone-repo.ts | 105 +++++++++++ .../adapters/local-tools/tools/list-repos.ts | 97 ++++++++++ .../core/src/task-detail/taskCreationHost.ts | 5 + .../src/task-detail/taskCreationSaga.test.ts | 37 ++++ .../core/src/task-detail/taskCreationSaga.ts | 13 +- packages/core/src/task-detail/taskInput.ts | 4 +- .../src/routers/workspace.router.ts | 9 + packages/shared/src/task-creation-domain.ts | 7 + .../canvas/components/WebsiteNewTask.tsx | 1 + .../task-detail/components/TaskInput.tsx | 172 ++++++++++-------- .../task-detail/hooks/useTaskCreation.ts | 22 ++- .../task-detail/taskCreationHostImpl.ts | 7 + .../src/services/agent/agent.ts | 23 +++ .../src/services/workspace/schemas.ts | 8 + .../src/services/workspace/scratch.ts | 23 +++ .../src/services/workspace/workspace.ts | 31 ++++ 19 files changed, 490 insertions(+), 86 deletions(-) create mode 100644 packages/agent/src/adapters/local-tools/tools/clone-repo.ts create mode 100644 packages/agent/src/adapters/local-tools/tools/list-repos.ts create mode 100644 packages/workspace-server/src/services/workspace/scratch.ts 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..6bb5f9ba14 --- /dev/null +++ b/packages/agent/src/adapters/local-tools/tools/clone-repo.ts @@ -0,0 +1,105 @@ +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; + + 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); + + // 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: ${result.error}`); + } + + if (branch) { + try { + await createGitClient(targetPath).checkout(branch); + } catch (err) { + return fail( + `Cloned ${slug} to ${targetPath} but failed to check out branch "${branch}": ${ + err instanceof Error ? err.message : String(err) + }. The default branch is checked out instead.`, + ); + } + } + + const checkedOut = (await getCurrentBranch(targetPath)) ?? branch ?? null; + return { + content: [ + { + type: "text", + text: `Cloned ${slug} (${repoName}) to ${targetPath}${ + checkedOut ? ` on branch ${checkedOut}` : "" + }. cd into this path for all git and file work in this repo.`, + }, + ], + }; + } catch (err) { + return fail( + `clone_repo failed: ${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..6231b391fc 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -318,9 +318,18 @@ export class TaskCreationSaga extends Saga< } } - const agentCwd = - workspace?.worktreePath ?? workspace?.folderPath ?? repoPath; const isCloudCreate = !input.taskId && workspaceMode === "cloud"; + let agentCwd = workspace?.worktreePath ?? workspace?.folderPath ?? repoPath; + + // Channels "generic chat box": a repo-less local/worktree task still starts + // an agent, in a per-task scratch dir. The agent decides at runtime whether + // it needs a repo and clones one into the scratch dir only if so. + if (!isCloudCreate && !agentCwd && input.allowNoRepo) { + agentCwd = await this.readOnlyStep("scratch_dir", () => + this.deps.host.ensureScratchDir(task.id), + ); + } + 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,6 +148,7 @@ export function useTaskCreation({ signalReportId, channelContext, channelName, + allowNoRepo, onTaskCreated, }: UseTaskCreationOptions): UseTaskCreationReturn { const [isCreatingTask, setIsCreatingTask] = useState(false); @@ -166,8 +173,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 +253,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 +272,7 @@ export function useTaskCreation({ additionalDirectories, channelContext, channelName, + allowNoRepo, }); if (executionMode) { @@ -348,6 +361,7 @@ export function useTaskCreation({ additionalDirectories, channelContext, channelName, + allowNoRepo, clearTaskInputReportAssociation, invalidateTasks, onTaskCreated, 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.ts b/packages/workspace-server/src/services/agent/agent.ts index 11a7df8009..bdd3381177 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -66,6 +66,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 { @@ -522,6 +523,7 @@ export class AgentService extends TypedEventEmitter { customInstructions?: string, additionalDirectories?: string[], systemPromptOverride?: string, + channelMode?: boolean, ): { append: string; } { @@ -561,6 +563,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 +641,11 @@ 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); + const additionalDirectories = taskId === "__preview__" ? [] @@ -682,6 +702,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 +875,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 +901,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..8fb15699d7 --- /dev/null +++ b/packages/workspace-server/src/services/workspace/scratch.ts @@ -0,0 +1,23 @@ +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 is a repo-less channel scratch dir. */ +export function isScratchPath(workingDir: string): boolean { + return workingDir.split(path.sep).includes(SCRATCH_DIR_NAME); +} diff --git a/packages/workspace-server/src/services/workspace/workspace.ts b/packages/workspace-server/src/services/workspace/workspace.ts index 35f645b79b..506f863cf4 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" } @@ -811,11 +812,41 @@ 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; + } + 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 }; } From 882676c61b20ba761043a4015decde3058b1327e Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Wed, 17 Jun 2026 10:40:31 -0700 Subject: [PATCH 2/4] fix(channels): surface scratch dir as workspace so no repo prompt shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A repo-less channel task has no workspace row, so useCwd resolved no cwd and TaskLogsPanel rendered the WorkspaceSetupPrompt ("Select a repository folder") for every such task — before the agent ever connected, and via a dedicated screen rather than letting the agent decide and ask. Synthesize a local-mode workspace from the on-disk scratch dir (the path is deterministic from the task id) in getWorkspace and getAllWorkspaces, and clean the scratch dir up on delete. The task now resolves the scratch dir as its cwd, skips the prompt, and connects the agent — which decides whether a repo is needed and asks via AskUserQuestion only for coding work. Generated-By: PostHog Code Task-Id: 9c51d9dc-d108-41b2-abdd-0be442a5e36d --- .../src/services/workspace/workspace.ts | 59 ++++++++++++++++++- .../workspace/workspace.verify.test.ts | 29 +++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/packages/workspace-server/src/services/workspace/workspace.ts b/packages/workspace-server/src/services/workspace/workspace.ts index 506f863cf4..8abd54d613 100644 --- a/packages/workspace-server/src/services/workspace/workspace.ts +++ b/packages/workspace-server/src/services/workspace/workspace.ts @@ -699,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; } @@ -835,6 +844,40 @@ export class WorkspaceService extends TypedEventEmitter 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 }> { @@ -886,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; @@ -1053,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(); + }); }); From f8d8fc4debd8d91f49bcf6318ec115302a0b820c Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Wed, 17 Jun 2026 10:47:28 -0700 Subject: [PATCH 3/4] fix(channels): refresh workspace cache so repo prompt clears on no-repo task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The synthetic scratch workspace was correct server-side, but the client's workspace.getAll query is only invalidated after workspace.create — which repo-less channel tasks skip. So the cached (empty) result kept repoPath null and the "Select a repository folder" prompt stayed up over the running task. - Provision the scratch dir before onTaskReady so it exists when the task view mounts. - Invalidate workspace.getAll after a no-repo task is created so the view refetches the synthetic scratch workspace, resolves its cwd, and shows the running session instead of the repo prompt. Generated-By: PostHog Code Task-Id: 9c51d9dc-d108-41b2-abdd-0be442a5e36d --- .../core/src/task-detail/taskCreationSaga.ts | 31 +++++++++++++------ .../task-detail/hooks/useTaskCreation.ts | 14 ++++++++- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 6231b391fc..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 }); } @@ -319,16 +335,11 @@ export class TaskCreationSaga extends Saga< } const isCloudCreate = !input.taskId && workspaceMode === "cloud"; - let agentCwd = workspace?.worktreePath ?? workspace?.folderPath ?? repoPath; - - // Channels "generic chat box": a repo-less local/worktree task still starts - // an agent, in a per-task scratch dir. The agent decides at runtime whether - // it needs a repo and clones one into the scratch dir only if so. - if (!isCloudCreate && !agentCwd && input.allowNoRepo) { - agentCwd = await this.readOnlyStep("scratch_dir", () => - this.deps.host.ensureScratchDir(task.id), - ); - } + const agentCwd = + workspace?.worktreePath ?? + workspace?.folderPath ?? + repoPath ?? + scratchCwd; const shouldConnect = !isCloudCreate && (!!input.taskId || !!agentCwd); diff --git a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts index d41ad0ee19..051806e737 100644 --- a/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts +++ b/packages/ui/src/features/task-detail/hooks/useTaskCreation.ts @@ -19,7 +19,7 @@ import type { ExecutionMode, Task } from "@posthog/shared/domain-types"; import { useTaskInputPrefillStore } from "@posthog/ui/features/task-detail/stores/taskInputPrefillStore"; import { navigateToTaskPending } from "@posthog/ui/router/navigationBridge"; import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { useConnectivity } from "../../../hooks/useConnectivity"; import { toast } from "../../../primitives/toast"; @@ -154,6 +154,7 @@ export function useTaskCreation({ const [isCreatingTask, setIsCreatingTask] = useState(false); const hostClient = useHostTRPCClient(); const trpc = useHostTRPC(); + const queryClient = useQueryClient(); const defaultAdditionalDirectoriesQuery = useQuery( trpc.additionalDirectories.listDefaults.queryOptions(), ); @@ -306,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) { @@ -366,6 +376,8 @@ export function useTaskCreation({ invalidateTasks, onTaskCreated, hostClient, + trpc, + queryClient, taskService, ], ); From 15cf90790782ce71e7f1c458fc6e590c43145bc4 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Wed, 17 Jun 2026 15:46:35 -0700 Subject: [PATCH 4/4] fix(channels): address Greptile review on lazy repo-attach tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clone_repo: redact the GitHub token from any error text, so a git failure that echoes the credential-bearing remote URL can't leak it into the tool result / transcript. - clone_repo: make it idempotent — if the repo is already cloned at the target path, reuse it (optionally re-checking out the branch) instead of letting git abort on a non-empty destination. - isScratchPath: match an actual path prefix against the real scratch base (derived from the worktree location) instead of scanning for the folder name anywhere in the path, so an unrelated/malicious dir named posthog-code-scratch can't spuriously enable channel mode. AgentService now injects IWorkspaceSettings to resolve the base. Generated-By: PostHog Code Task-Id: 9c51d9dc-d108-41b2-abdd-0be442a5e36d --- .../adapters/local-tools/tools/clone-repo.ts | 74 ++++++++++++------- .../src/services/agent/agent.test.ts | 4 + .../src/services/agent/agent.ts | 11 ++- .../src/services/workspace/scratch.ts | 17 ++++- 4 files changed, 77 insertions(+), 29 deletions(-) diff --git a/packages/agent/src/adapters/local-tools/tools/clone-repo.ts b/packages/agent/src/adapters/local-tools/tools/clone-repo.ts index 6bb5f9ba14..b0bc277d50 100644 --- a/packages/agent/src/adapters/local-tools/tools/clone-repo.ts +++ b/packages/agent/src/adapters/local-tools/tools/clone-repo.ts @@ -1,3 +1,4 @@ +import * as fs from "node:fs"; import * as path from "node:path"; import { createGitClient } from "@posthog/git/client"; import { getCurrentBranch } from "@posthog/git/queries"; @@ -44,6 +45,11 @@ export const cloneRepoTool = defineLocalTool({ 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) { @@ -58,6 +64,44 @@ export const cloneRepoTool = defineLocalTool({ 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 @@ -70,35 +114,15 @@ export const cloneRepoTool = defineLocalTool({ targetPath, }); if (!result.success) { - return fail(`clone_repo failed: ${result.error}`); - } - - if (branch) { - try { - await createGitClient(targetPath).checkout(branch); - } catch (err) { - return fail( - `Cloned ${slug} to ${targetPath} but failed to check out branch "${branch}": ${ - err instanceof Error ? err.message : String(err) - }. The default branch is checked out instead.`, - ); - } + return fail(`clone_repo failed: ${redact(result.error)}`); } - const checkedOut = (await getCurrentBranch(targetPath)) ?? branch ?? null; - return { - content: [ - { - type: "text", - text: `Cloned ${slug} (${repoName}) to ${targetPath}${ - checkedOut ? ` on branch ${checkedOut}` : "" - }. cd into this path for all git and file work in this repo.`, - }, - ], - }; + return (await checkout()) ?? (await done()); } catch (err) { return fail( - `clone_repo failed: ${err instanceof Error ? err.message : String(err)}`, + `clone_repo failed: ${redact( + err instanceof Error ? err.message : String(err), + )}`, ); } }, 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 bdd3381177..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, @@ -361,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, ) { @@ -644,7 +650,10 @@ You are running in a PostHog channel as a general-purpose assistant. This task m // 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); + const channelMode = isScratchPath( + repoPath, + this.workspaceSettings.getWorktreeLocation(), + ); const additionalDirectories = taskId === "__preview__" diff --git a/packages/workspace-server/src/services/workspace/scratch.ts b/packages/workspace-server/src/services/workspace/scratch.ts index 8fb15699d7..22441abf4e 100644 --- a/packages/workspace-server/src/services/workspace/scratch.ts +++ b/packages/workspace-server/src/services/workspace/scratch.ts @@ -17,7 +17,18 @@ export function scratchBasePath(worktreeLocation: string): string { return path.join(path.dirname(worktreeLocation), SCRATCH_DIR_NAME); } -/** Whether a working directory is a repo-less channel scratch dir. */ -export function isScratchPath(workingDir: string): boolean { - return workingDir.split(path.sep).includes(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); }