@posthog/agent 2.3.643 → 2.3.655

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/adapters/claude/permissions/permission-options.js +700 -0
  2. package/dist/adapters/claude/permissions/permission-options.js.map +1 -1
  3. package/dist/adapters/claude/tools.js +700 -0
  4. package/dist/adapters/claude/tools.js.map +1 -1
  5. package/dist/adapters/codex/local-tools-mcp-server.d.ts +2 -0
  6. package/dist/adapters/codex/local-tools-mcp-server.js +1172 -0
  7. package/dist/adapters/codex/local-tools-mcp-server.js.map +1 -0
  8. package/dist/agent.js +1488 -219
  9. package/dist/agent.js.map +1 -1
  10. package/dist/execution-mode.js +700 -0
  11. package/dist/execution-mode.js.map +1 -1
  12. package/dist/handoff-checkpoint.js.map +1 -1
  13. package/dist/posthog-api.js +1 -1
  14. package/dist/posthog-api.js.map +1 -1
  15. package/dist/server/agent-server.js +1604 -339
  16. package/dist/server/agent-server.js.map +1 -1
  17. package/dist/server/bin.cjs +1520 -258
  18. package/dist/server/bin.cjs.map +1 -1
  19. package/package.json +3 -3
  20. package/src/adapters/claude/claude-agent.ts +32 -2
  21. package/src/adapters/claude/hooks.test.ts +54 -0
  22. package/src/adapters/claude/hooks.ts +86 -0
  23. package/src/adapters/claude/mcp/local-tools.test.ts +50 -0
  24. package/src/adapters/claude/mcp/local-tools.ts +40 -0
  25. package/src/adapters/claude/session/options.ts +14 -9
  26. package/src/adapters/claude/types.ts +1 -0
  27. package/src/adapters/codex/codex-agent.ts +117 -22
  28. package/src/adapters/codex/local-tools-mcp-server.ts +71 -0
  29. package/src/adapters/local-tools/index.ts +22 -0
  30. package/src/adapters/local-tools/registry.test.ts +57 -0
  31. package/src/adapters/local-tools/registry.ts +81 -0
  32. package/src/adapters/local-tools/tools/signed-commit.ts +26 -0
  33. package/src/adapters/session-meta.ts +16 -0
  34. package/src/adapters/signed-commit-shared.ts +82 -0
  35. package/src/server/agent-server.test.ts +2 -4
  36. package/src/server/agent-server.ts +27 -30
  37. package/src/utils/common.ts +14 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Standalone stdio MCP server exposing the general local tools to the Codex
3
+ * adapter. Spawned by codex-acp as an MCP server process. Reads its context
4
+ * (cwd, taskId, token) from POSTHOG_LOCAL_TOOLS_CTX and the set of tools to
5
+ * register from POSTHOG_LOCAL_TOOLS_ENABLED (both set by the parent, which has
6
+ * already evaluated each tool's gate) — then registers those registry tools,
7
+ * the same ones the Claude adapter exposes in-process.
8
+ *
9
+ * Usage:
10
+ * POSTHOG_LOCAL_TOOLS_CTX=<base64> \
11
+ * POSTHOG_LOCAL_TOOLS_ENABLED=git_signed_commit \
12
+ * node local-tools-mcp-server.js
13
+ */
14
+
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { readGithubTokenFromEnv } from "@posthog/git/signed-commit";
18
+ import {
19
+ LOCAL_TOOLS,
20
+ LOCAL_TOOLS_MCP_NAME,
21
+ type LocalToolCtx,
22
+ } from "../local-tools";
23
+
24
+ function die(message: string): never {
25
+ process.stderr.write(`[local-tools-mcp-server] ${message}\n`);
26
+ process.exit(1);
27
+ }
28
+
29
+ const ctxEnv = process.env.POSTHOG_LOCAL_TOOLS_CTX;
30
+ if (!ctxEnv) {
31
+ die("POSTHOG_LOCAL_TOOLS_CTX env var is required");
32
+ }
33
+
34
+ let parsed: { cwd: string; taskId?: string; token?: string };
35
+ try {
36
+ parsed = JSON.parse(Buffer.from(ctxEnv, "base64").toString("utf-8"));
37
+ } catch (err) {
38
+ die(`Failed to parse POSTHOG_LOCAL_TOOLS_CTX as base64-encoded JSON: ${err}`);
39
+ }
40
+
41
+ if (!parsed.cwd) {
42
+ die("POSTHOG_LOCAL_TOOLS_CTX must include cwd");
43
+ }
44
+
45
+ const ctx: LocalToolCtx = {
46
+ cwd: parsed.cwd,
47
+ token: parsed.token ?? readGithubTokenFromEnv(),
48
+ taskId: parsed.taskId,
49
+ };
50
+
51
+ const enabledNames = (process.env.POSTHOG_LOCAL_TOOLS_ENABLED ?? "")
52
+ .split(",")
53
+ .filter(Boolean);
54
+ const tools = LOCAL_TOOLS.filter((t) => enabledNames.includes(t.name));
55
+ if (tools.length === 0) {
56
+ die("POSTHOG_LOCAL_TOOLS_ENABLED listed no known tools");
57
+ }
58
+
59
+ const server = new McpServer({
60
+ name: LOCAL_TOOLS_MCP_NAME,
61
+ version: "1.0.0",
62
+ });
63
+
64
+ for (const t of tools) {
65
+ server.tool(t.name, t.description, t.schema, async (args) =>
66
+ t.handler(ctx, args),
67
+ );
68
+ }
69
+
70
+ const transport = new StdioServerTransport();
71
+ await server.connect(transport);
@@ -0,0 +1,22 @@
1
+ import type { LocalTool, LocalToolCtx, LocalToolGateMeta } from "./registry";
2
+ import { signedCommitTool } from "./tools/signed-commit";
3
+
4
+ export {
5
+ LOCAL_TOOLS_MCP_NAME,
6
+ type LocalTool,
7
+ type LocalToolCtx,
8
+ type LocalToolGateMeta,
9
+ type LocalToolResult,
10
+ qualifiedLocalToolName,
11
+ } from "./registry";
12
+
13
+ /** Every tool the general local MCP server can expose. Add new tools here. */
14
+ export const LOCAL_TOOLS: LocalTool[] = [signedCommitTool];
15
+
16
+ /** Tools whose gate passes for the given context — the set to actually expose. */
17
+ export function enabledLocalTools(
18
+ ctx: LocalToolCtx,
19
+ meta: LocalToolGateMeta | undefined,
20
+ ): LocalTool[] {
21
+ return LOCAL_TOOLS.filter((t) => t.isEnabled(ctx, meta));
22
+ }
@@ -0,0 +1,57 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ enabledLocalTools,
4
+ LOCAL_TOOLS,
5
+ LOCAL_TOOLS_MCP_NAME,
6
+ qualifiedLocalToolName,
7
+ } from "./index";
8
+
9
+ describe("local-tools registry", () => {
10
+ const savedSandbox = process.env.IS_SANDBOX;
11
+
12
+ beforeEach(() => {
13
+ // isCloudRun also keys off IS_SANDBOX; clear it so meta.taskRunId is the
14
+ // only cloud signal under test.
15
+ delete process.env.IS_SANDBOX;
16
+ });
17
+
18
+ afterEach(() => {
19
+ if (savedSandbox === undefined) {
20
+ delete process.env.IS_SANDBOX;
21
+ } else {
22
+ process.env.IS_SANDBOX = savedSandbox;
23
+ }
24
+ });
25
+
26
+ it("registers tools with unique names", () => {
27
+ const names = LOCAL_TOOLS.map((t) => t.name);
28
+ expect(new Set(names).size).toBe(names.length);
29
+ });
30
+
31
+ it("qualifies tool names under the general server", () => {
32
+ expect(qualifiedLocalToolName("git_signed_commit")).toBe(
33
+ `mcp__${LOCAL_TOOLS_MCP_NAME}__git_signed_commit`,
34
+ );
35
+ });
36
+
37
+ it.each([
38
+ { name: "cloud run with a token", taskRunId: "run-1", token: "ghs_x" },
39
+ { name: "cloud run without a token", taskRunId: "run-1", token: undefined },
40
+ { name: "desktop run with a token", taskRunId: undefined, token: "ghs_x" },
41
+ {
42
+ name: "desktop run without a token",
43
+ taskRunId: undefined,
44
+ token: undefined,
45
+ },
46
+ ])(
47
+ "exposes git_signed_commit only in $name when cloud+token",
48
+ ({ taskRunId, token }) => {
49
+ const tools = enabledLocalTools(
50
+ { cwd: "/repo", token },
51
+ taskRunId ? { taskRunId } : undefined,
52
+ );
53
+ const hasSignedCommit = tools.some((t) => t.name === "git_signed_commit");
54
+ expect(hasSignedCommit).toBe(Boolean(taskRunId) && Boolean(token));
55
+ },
56
+ );
57
+ });
@@ -0,0 +1,81 @@
1
+ import type { z } from "zod";
2
+
3
+ /**
4
+ * A single general-purpose local MCP server hosts every tool registered here,
5
+ * for both adapters: the Claude in-process SDK server and the Codex stdio
6
+ * server. Adding a tool means adding one entry to `LOCAL_TOOLS` (see
7
+ * `./index.ts`) — no per-tool server file or adapter wiring. The name appears
8
+ * in tool ids as `mcp__posthog-local__<tool>`.
9
+ */
10
+ export const LOCAL_TOOLS_MCP_NAME = "posthog-local";
11
+
12
+ /** Runtime context handed to every local tool's handler and gate. */
13
+ export interface LocalToolCtx {
14
+ cwd: string;
15
+ /** GitHub token available to the sandbox, if any. */
16
+ token?: string;
17
+ taskId?: string;
18
+ }
19
+
20
+ /** Minimal session-meta shape needed to gate tools (e.g. cloud-only). */
21
+ export interface LocalToolGateMeta {
22
+ taskRunId?: string;
23
+ }
24
+
25
+ /**
26
+ * MCP tool result shape. Carries an open index signature so the value is
27
+ * assignable to either SDK's `CallToolResult` (the Claude SDK and the MCP SDK
28
+ * both attach an open `_meta`).
29
+ */
30
+ export interface LocalToolResult {
31
+ content: { type: "text"; text: string }[];
32
+ isError?: true;
33
+ [key: string]: unknown;
34
+ }
35
+
36
+ /** Tool definition with its input schema's type preserved for the handler. */
37
+ export interface LocalToolDef<S extends z.ZodRawShape> {
38
+ name: string;
39
+ description: string;
40
+ schema: S;
41
+ /**
42
+ * Keep the tool visible even though MCP tools are offloaded behind ToolSearch
43
+ * by default in the Claude adapter (ENABLE_TOOL_SEARCH). Ignored by Codex.
44
+ */
45
+ alwaysLoad?: boolean;
46
+ isEnabled(ctx: LocalToolCtx, meta: LocalToolGateMeta | undefined): boolean;
47
+ handler(
48
+ ctx: LocalToolCtx,
49
+ args: z.infer<z.ZodObject<S>>,
50
+ ): Promise<LocalToolResult>;
51
+ }
52
+
53
+ /** Schema-erased tool, the shape stored in the registry array. */
54
+ export interface LocalTool {
55
+ name: string;
56
+ description: string;
57
+ schema: z.ZodRawShape;
58
+ alwaysLoad?: boolean;
59
+ isEnabled(ctx: LocalToolCtx, meta: LocalToolGateMeta | undefined): boolean;
60
+ handler(
61
+ ctx: LocalToolCtx,
62
+ args: Record<string, unknown>,
63
+ ): Promise<LocalToolResult>;
64
+ }
65
+
66
+ /**
67
+ * Registers a tool, preserving its schema's inferred type at the definition
68
+ * site. The returned value erases the schema generic so tools of different
69
+ * shapes can live in one array; the cast is sound because both MCP SDKs
70
+ * validate `args` against `schema` before dispatching to the handler.
71
+ */
72
+ export function defineLocalTool<S extends z.ZodRawShape>(
73
+ def: LocalToolDef<S>,
74
+ ): LocalTool {
75
+ return def as unknown as LocalTool;
76
+ }
77
+
78
+ /** The qualified tool id as the model and tool guards see it. */
79
+ export function qualifiedLocalToolName(toolName: string): string {
80
+ return `mcp__${LOCAL_TOOLS_MCP_NAME}__${toolName}`;
81
+ }
@@ -0,0 +1,26 @@
1
+ import { isCloudRun } from "../../../utils/common";
2
+ import {
3
+ runSignedCommitTool,
4
+ SIGNED_COMMIT_TOOL_DESCRIPTION,
5
+ SIGNED_COMMIT_TOOL_NAME,
6
+ signedCommitToolSchema,
7
+ } from "../../signed-commit-shared";
8
+ import { defineLocalTool } from "../registry";
9
+
10
+ /**
11
+ * `git_signed_commit` as a local tool. Cloud runs only, and only when a GitHub
12
+ * token is available (the commit is created via GitHub's API, which also signs
13
+ * it). Committing is core to cloud tasks, so keep it visible past ToolSearch.
14
+ */
15
+ export const signedCommitTool = defineLocalTool({
16
+ name: SIGNED_COMMIT_TOOL_NAME,
17
+ description: SIGNED_COMMIT_TOOL_DESCRIPTION,
18
+ schema: signedCommitToolSchema,
19
+ alwaysLoad: true,
20
+ isEnabled: (ctx, meta) => isCloudRun(meta) && !!ctx.token,
21
+ handler: (ctx, args) =>
22
+ runSignedCommitTool(
23
+ { cwd: ctx.cwd, token: ctx.token ?? "", taskId: ctx.taskId },
24
+ args,
25
+ ),
26
+ });
@@ -0,0 +1,16 @@
1
+ /** Minimal shape needed to resolve the effective task id from session meta. */
2
+ interface TaskIdSource {
3
+ taskId?: string;
4
+ persistence?: { taskId?: string };
5
+ }
6
+
7
+ /**
8
+ * The task id can arrive directly on the session meta or nested under
9
+ * `persistence`; prefer the top-level value. Shared by the Claude and Codex
10
+ * adapters so the fallback chain stays in sync.
11
+ */
12
+ export function resolveTaskId(
13
+ meta: TaskIdSource | undefined,
14
+ ): string | undefined {
15
+ return meta?.taskId ?? meta?.persistence?.taskId;
16
+ }
@@ -0,0 +1,82 @@
1
+ import {
2
+ createSignedCommit,
3
+ type SignedCommitCtx,
4
+ type SignedCommitInput,
5
+ type SignedCommitResult,
6
+ } from "@posthog/git/signed-commit";
7
+ import { z } from "zod";
8
+ import { qualifiedLocalToolName } from "./local-tools/registry";
9
+
10
+ /**
11
+ * Shared definitions for the `git_signed_commit` tool, used by the local-tools
12
+ * registry entry (which both adapters expose) so the tool name, schema,
13
+ * description, and result formatting can't drift. The qualified name also
14
+ * appears in the cloud system prompt and the PreToolUse guard message.
15
+ */
16
+
17
+ export const SIGNED_COMMIT_TOOL_NAME = "git_signed_commit";
18
+ export const SIGNED_COMMIT_QUALIFIED_TOOL_NAME = qualifiedLocalToolName(
19
+ SIGNED_COMMIT_TOOL_NAME,
20
+ );
21
+
22
+ export const SIGNED_COMMIT_TOOL_DESCRIPTION =
23
+ "Create a GitHub-signed (Verified) commit on the branch. Stage files with `git add` " +
24
+ "first (or pass `paths`), then call this instead of `git commit`/`git push` — those are " +
25
+ "blocked because all commits must be signed. The commit is created via GitHub's API and " +
26
+ "your local checkout is kept in sync. For a new branch, pass `branch` (prefixed with " +
27
+ "`posthog-code/`) and the tool creates it on the remote.";
28
+
29
+ export const signedCommitToolSchema = {
30
+ message: z.string().describe("Commit headline (first line)."),
31
+ body: z.string().optional().describe("Optional extended commit body."),
32
+ branch: z
33
+ .string()
34
+ .optional()
35
+ .describe(
36
+ "Target branch; defaults to the current branch. Use a posthog-code/ prefix for new branches.",
37
+ ),
38
+ paths: z
39
+ .array(z.string())
40
+ .optional()
41
+ .describe(
42
+ "Files to stage before committing; defaults to already-staged files.",
43
+ ),
44
+ };
45
+
46
+ export function formatSignedCommitResult(result: SignedCommitResult): string {
47
+ const list = result.commits.map((c) => `- ${c.sha} ${c.url}`).join("\n");
48
+ return `Created ${result.commits.length} signed commit(s) on ${result.branch}:\n${list}`;
49
+ }
50
+
51
+ export interface SignedCommitToolResult {
52
+ content: { type: "text"; text: string }[];
53
+ isError?: true;
54
+ // Both SDKs' CallToolResult carries an open `_meta`/index signature; mirror it
55
+ // so this shape is assignable to either adapter's tool-handler return type.
56
+ [key: string]: unknown;
57
+ }
58
+
59
+ /**
60
+ * Runs `git_signed_commit` and formats the MCP result. Shared by the Claude
61
+ * in-process tool and the Codex stdio server so success/error formatting (and
62
+ * the error-message prefix) can't drift between adapters.
63
+ */
64
+ export async function runSignedCommitTool(
65
+ ctx: SignedCommitCtx,
66
+ args: SignedCommitInput,
67
+ ): Promise<SignedCommitToolResult> {
68
+ try {
69
+ const result = await createSignedCommit(ctx, args);
70
+ return {
71
+ content: [{ type: "text", text: formatSignedCommitResult(result) }],
72
+ };
73
+ } catch (err) {
74
+ const message = err instanceof Error ? err.message : String(err);
75
+ return {
76
+ content: [
77
+ { type: "text", text: `${SIGNED_COMMIT_TOOL_NAME} failed: ${message}` },
78
+ ],
79
+ isError: true,
80
+ };
81
+ }
82
+ }
@@ -900,10 +900,8 @@ describe("AgentServer HTTP Mode", () => {
900
900
  expect(prompt).toContain(
901
901
  "gh pr checkout https://github.com/org/repo/pull/1",
902
902
  );
903
- expect(prompt).toContain(
904
- "Stage and commit all changes with a clear commit message",
905
- );
906
- expect(prompt).toContain("Push to the existing PR branch");
903
+ expect(prompt).toContain("git_signed_commit");
904
+ expect(prompt).toContain("Committing (signed commits required)");
907
905
  expect(prompt).not.toContain("Create a draft pull request");
908
906
  // Review-comment thread handling: reply + resolve
909
907
  expect(prompt).toContain("review thread");
@@ -25,6 +25,7 @@ import {
25
25
  type AgentErrorClassification,
26
26
  classifyAgentError,
27
27
  } from "../adapters/error-classification";
28
+ import { SIGNED_COMMIT_QUALIFIED_TOOL_NAME } from "../adapters/signed-commit-shared";
28
29
  import type { PermissionMode } from "../execution-mode";
29
30
  import { DEFAULT_CODEX_MODEL } from "../gateway-models";
30
31
  import { HandoffCheckpointTracker } from "../handoff-checkpoint";
@@ -949,6 +950,7 @@ export class AgentServer {
949
950
  _meta: {
950
951
  sessionId: payload.run_id,
951
952
  taskRunId: payload.run_id,
953
+ taskId: payload.task_id,
952
954
  systemPrompt: sessionSystemPrompt,
953
955
  ...(this.config.model && { model: this.config.model }),
954
956
  allowedDomains: this.config.allowedDomains,
@@ -1599,24 +1601,21 @@ export class AgentServer {
1599
1601
  private buildCloudSystemPrompt(prUrl?: string | null): string {
1600
1602
  const taskId = this.config.taskId;
1601
1603
  const shouldAutoCreatePr = this.shouldAutoPublishCloudChanges();
1602
- const attributionInstructions = `
1603
- ## Attribution
1604
- Do NOT use Claude Code's default attribution (no "Co-Authored-By" trailers, no "Generated with [Claude Code]" lines).
1604
+ const signedCommitInstructions = `
1605
+ ## Committing (signed commits required)
1606
+ Commits MUST be signed. \`git commit\` and \`git push\` are blocked in this environment.
1607
+ To commit: stage your changes with \`git add\`, then call the \`git_signed_commit\` tool (full
1608
+ name \`${SIGNED_COMMIT_QUALIFIED_TOOL_NAME}\`) with a \`message\` (and optional \`body\`/\`paths\`).
1609
+ It creates a GitHub-signed ("Verified") commit on the branch and keeps your local checkout in
1610
+ sync. To start a new branch, pass \`branch\` (prefixed with \`posthog-code/\`) — the tool creates
1611
+ it on the remote for you.
1605
1612
 
1606
- If you create a commit, add the following trailers to the commit message (after a blank line at the end):
1613
+ ## Attribution
1614
+ Do NOT add "Co-Authored-By" trailers or "Generated with [Claude Code]" lines to your
1615
+ commit messages. The \`git_signed_commit\` tool automatically appends the only trailers
1616
+ we want:
1607
1617
  Generated-By: PostHog Code
1608
- Task-Id: ${taskId}
1609
-
1610
- Example:
1611
- \`\`\`
1612
- git commit -m "$(cat <<'EOF'
1613
- fix: resolve login redirect loop
1614
-
1615
- Generated-By: PostHog Code
1616
- Task-Id: ${taskId}
1617
- EOF
1618
- )"
1619
- \`\`\``;
1618
+ Task-Id: ${taskId}`;
1620
1619
 
1621
1620
  if (prUrl) {
1622
1621
  if (!shouldAutoCreatePr) {
@@ -1630,7 +1629,7 @@ Do the requested work, but stop with local changes ready for review.
1630
1629
  Important:
1631
1630
  - Do NOT create new commits, push to the branch, or update the pull request unless the user explicitly asks.
1632
1631
  - Do NOT create a new branch or a new pull request.
1633
- ${attributionInstructions}
1632
+ ${signedCommitInstructions}
1634
1633
  `;
1635
1634
  }
1636
1635
 
@@ -1641,9 +1640,8 @@ This task already has an open pull request: ${prUrl}
1641
1640
 
1642
1641
  After completing the requested changes:
1643
1642
  1. Check out the existing PR branch with \`gh pr checkout ${prUrl}\`
1644
- 2. Stage and commit all changes with a clear commit message
1645
- 3. Push to the existing PR branch
1646
- 4. For every PR review comment or review thread you addressed, treat the thread as done only after BOTH of these:
1643
+ 2. Stage your changes with \`git add\`, then call the \`git_signed_commit\` tool with a clear \`message\` (do NOT use \`git commit\`/\`git push\` — they are blocked). This commits to the existing PR branch.
1644
+ 3. For every PR review comment or review thread you addressed, treat the thread as done only after BOTH of these:
1647
1645
  - Reply on the thread with a short note describing what changed (reference the commit SHA when useful) using \`gh api -X POST /repos/{owner}/{repo}/pulls/{n}/comments/{id}/replies -f body='...'\`.
1648
1646
  - Resolve the thread via the \`resolveReviewThread\` GraphQL mutation: \`gh api graphql -f query='mutation($id:ID!){resolveReviewThread(input:{threadId:$id}){thread{isResolved}}}' -f id="<thread-node-id>"\`.
1649
1647
  List unresolved threads first with \`gh api graphql -f query='{repository(owner:"<owner>",name:"<repo>"){pullRequest(number:<n>){reviewThreads(first:100){nodes{id isResolved comments(first:1){nodes{body}}}}}}}'\` so you can resolve each one you fixed.
@@ -1651,7 +1649,7 @@ After completing the requested changes:
1651
1649
  Important:
1652
1650
  - Do NOT create a new branch or a new pull request.
1653
1651
  - Do NOT push fixes for review comments without replying to and resolving each related thread.
1654
- ${attributionInstructions}
1652
+ ${signedCommitInstructions}
1655
1653
  `;
1656
1654
  }
1657
1655
 
@@ -1666,7 +1664,7 @@ When the user asks for code changes:
1666
1664
  When the user explicitly asks to clone or work in a GitHub repository:
1667
1665
  - Clone the repository into /tmp/workspace/repos/<owner>/<repo> using \`gh repo clone <owner>/<repo> /tmp/workspace/repos/<owner>/<repo>\`
1668
1666
  - Work from inside that cloned repository for follow-up code changes
1669
- - If the user explicitly asks you to open or update a pull request, create a branch, commit the requested changes, push it, and open a draft pull request from inside the clone. Before opening the PR, check the cloned repo for a PR template at \`.github/pull_request_template.md\` (or variants; fall back to the org's \`.github\` repo via \`gh api\`) and use it as the body structure, and search for matching open issues with \`gh issue list --search\` to include \`Closes #<n>\` / \`Refs #<n>\` links.
1667
+ - If the user explicitly asks you to open or update a pull request, create a branch, stage your changes with \`git add\` and commit them with the \`git_signed_commit\` tool (do NOT use \`git commit\`/\`git push\` — they are blocked), and open a draft pull request from inside the clone. Before opening the PR, check the cloned repo for a PR template at \`.github/pull_request_template.md\` (or variants; fall back to the org's \`.github\` repo via \`gh api\`) and use it as the body structure, and search for matching open issues with \`gh issue list --search\` to include \`Closes #<n>\` / \`Refs #<n>\` links.
1670
1668
  - Do NOT create branches, commits, push changes, or open pull requests unless the user explicitly asks for that`;
1671
1669
 
1672
1670
  return `
@@ -1686,7 +1684,7 @@ ${publishInstructions}
1686
1684
 
1687
1685
  Important:
1688
1686
  - Prefer using MCP tools to answer questions with real data over giving generic advice.
1689
- ${attributionInstructions}
1687
+ ${signedCommitInstructions}
1690
1688
  `;
1691
1689
  }
1692
1690
 
@@ -1698,7 +1696,7 @@ Do the requested work, but stop with local changes ready for review.
1698
1696
 
1699
1697
  Important:
1700
1698
  - Do NOT create a branch, commit, push, or open a pull request unless the user explicitly asks.
1701
- ${attributionInstructions}
1699
+ ${signedCommitInstructions}
1702
1700
  `;
1703
1701
  }
1704
1702
 
@@ -1706,14 +1704,13 @@ ${attributionInstructions}
1706
1704
  # Cloud Task Execution
1707
1705
 
1708
1706
  After completing the requested changes:
1709
- 1. Create a new branch prefixed with \`posthog-code/\` (e.g. \`posthog-code/fix-login-redirect\`) based on the work done
1710
- 2. Stage and commit all changes with a clear commit message
1711
- 3. Push the branch to origin
1712
- 4. Before opening the PR, prepare the body:
1707
+ 1. Pick a new branch name prefixed with \`posthog-code/\` (e.g. \`posthog-code/fix-login-redirect\`)
1708
+ 2. Stage your changes with \`git add\`, then call the \`git_signed_commit\` tool with \`branch\` set to that name and a clear \`message\` (do NOT use \`git commit\`/\`git push\` — they are blocked). The tool creates the branch on the remote and a signed commit on it.
1709
+ 3. Before opening the PR, prepare the body:
1713
1710
  - Check the repo for a PR template at \`.github/pull_request_template.md\` (also try \`.github/PULL_REQUEST_TEMPLATE.md\`, \`docs/pull_request_template.md\`, and root variants). If one exists, use its exact section headings as the PR body — do NOT fall back to a generic Summary/Test plan format.
1714
1711
  - If no repo-level template exists, check the org's \`.github\` repo via \`gh api /repos/<owner>/.github/contents/.github/pull_request_template.md\` (and other common paths) and use that as a fallback.
1715
1712
  - Search for matching open issues with \`gh issue list --state open --search '<keywords>'\` (derive keywords from the branch name, commits, and changed files; \`gh issue view <n>\` to confirm relevance). For every issue this PR would resolve, include a \`Closes #<n>\` line in the body so GitHub auto-links and auto-closes it on merge. For issues that are related but not fully resolved, use \`Refs #<n>\` instead.
1716
- 5. Create a draft pull request using \`gh pr create --draft${this.config.baseBranch ? ` --base ${this.config.baseBranch}` : ""}\` with a descriptive title and the body prepared above. Add the following footer at the end of the PR description:
1713
+ 4. Create a draft pull request using \`gh pr create --draft${this.config.baseBranch ? ` --base ${this.config.baseBranch}` : ""}\` with a descriptive title and the body prepared above. Add the following footer at the end of the PR description:
1717
1714
  \`\`\`
1718
1715
  ---
1719
1716
  *Created with [PostHog Code](https://posthog.com/code?ref=pr)*
@@ -1721,7 +1718,7 @@ After completing the requested changes:
1721
1718
 
1722
1719
  Important:
1723
1720
  - Always create the PR as a draft. Do not ask for confirmation.
1724
- ${attributionInstructions}
1721
+ ${signedCommitInstructions}
1725
1722
  `;
1726
1723
  }
1727
1724
 
@@ -1,3 +1,4 @@
1
+ import { readGithubTokenFromEnv } from "@posthog/git/signed-commit";
1
2
  import type { Logger } from "./logger";
2
3
 
3
4
  /**
@@ -25,6 +26,19 @@ export const IS_ROOT =
25
26
 
26
27
  export const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
27
28
 
29
+ /**
30
+ * A cloud sandbox run, as opposed to a local desktop session. Cloud sandboxes
31
+ * always set IS_SANDBOX and carry a taskRunId; desktop sessions have neither.
32
+ */
33
+ export function isCloudRun(meta: { taskRunId?: string } | undefined): boolean {
34
+ return !!process.env.IS_SANDBOX || !!meta?.taskRunId;
35
+ }
36
+
37
+ /** The GitHub token available to the sandbox, if any. */
38
+ export function resolveGithubToken(): string | undefined {
39
+ return readGithubTokenFromEnv();
40
+ }
41
+
28
42
  export function unreachable(value: never, logger: Logger): void {
29
43
  let valueAsString: string;
30
44
  try {