Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/agent/src/adapters/claude/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null;
mcpToolApprovals?: McpToolApprovals;
claudeCode?: {
Expand Down
4 changes: 4 additions & 0 deletions packages/agent/src/adapters/local-tools/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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. */
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/adapters/local-tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
129 changes: 129 additions & 0 deletions packages/agent/src/adapters/local-tools/tools/clone-repo.ts
Original file line number Diff line number Diff line change
@@ -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 `<cwd>/repos/<repo>` (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<LocalToolResult> => {
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<LocalToolResult> => {
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<LocalToolResult | null> => {
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),
)}`,
);
}
},
});
97 changes: 97 additions & 0 deletions packages/agent/src/adapters/local-tools/tools/list-repos.ts
Original file line number Diff line number Diff line change
@@ -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<LocalToolResult> => {
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,
};
}
},
});
5 changes: 5 additions & 0 deletions packages/core/src/task-detail/taskCreationHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export interface ITaskCreationHost {
getAuthenticatedClient(): Promise<TaskCreationApiClient | null>;
assertCloudUsageAvailable(): Promise<void>;
getTaskDirectory(taskId: string, repoKey?: string): Promise<string | null>;
/**
* 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<string>;
getWorkspace(taskId: string): Promise<Workspace | null>;
createWorkspace(args: CreateWorkspaceArgs): Promise<CreatedWorkspaceInfo>;
deleteWorkspace(args: {
Expand Down
37 changes: 37 additions & 0 deletions packages/core/src/task-detail/taskCreationSaga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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([]);
Expand Down Expand Up @@ -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() });
Expand Down
24 changes: 22 additions & 2 deletions packages/core/src/task-detail/taskCreationSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/task-detail/taskInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface PrepareTaskInputOptions {
additionalDirectories?: string[];
channelContext?: string;
channelName?: string;
allowNoRepo?: boolean;
}

export function prepareTaskInput(
Expand All @@ -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,
Expand All @@ -55,6 +56,7 @@ export function prepareTaskInput(
additionalDirectories: isCloud ? undefined : options.additionalDirectories,
channelContext: options.channelContext,
channelName: options.channelName,
allowNoRepo: options.allowNoRepo,
};
}

Expand Down
Loading
Loading