diff --git a/apps/docs/content/docs/en/workflows/blocks/pi.mdx b/apps/docs/content/docs/en/workflows/blocks/pi.mdx
index 2bee2901742..59114d04796 100644
--- a/apps/docs/content/docs/en/workflows/blocks/pi.mdx
+++ b/apps/docs/content/docs/en/workflows/blocks/pi.mdx
@@ -1,17 +1,18 @@
---
title: Pi Coding Agent
-description: The Pi Coding Agent block runs an autonomous coding agent on a real repository — in an isolated cloud sandbox that opens a pull request, or on your own machine over SSH.
+description: The Pi Coding Agent block runs an autonomous coding agent on a real repository — opening a pull request, reviewing an existing PR, or editing files on your machine over SSH.
pageType: reference
---
import { BlockPreview } from '@/components/workflow-preview'
import { FAQ } from '@/components/ui/faq'
-The **Pi Coding Agent block** runs the [Pi](https://github.com/earendil-works/pi-mono) coding harness against a real repository. You give it a task and a model; it reads, edits, and runs files, then either opens a pull request or changes your files in place. It reuses your models, [skills](/agents/skills), and multi-turn [memory](#memory), and streams its progress as it works.
+The **Pi Coding Agent block** runs the [Pi](https://github.com/earendil-works/pi-mono) coding harness against a real repository. You give it a task and a model; it reads, edits, and runs files, then either opens a pull request, posts a PR review, or changes your files in place. It reuses your models, [skills](/agents/skills), and multi-turn [memory](#memory), and streams its progress as it works.
-It has two modes that decide *where* it runs and *how* its changes land:
+It has three modes that decide *where* it runs and *how* its work lands:
-- **Cloud** — spins up an isolated sandbox, clones a connected GitHub repo, edits and tests with native shell + git, and opens a **pull request**.
+- **Cloud PR** — spins up an isolated sandbox, clones a connected GitHub repo, edits and tests with native shell + git, and opens a **pull request**.
+- **Cloud Code Review** — checks out an existing PR in a sandbox, analyzes the changes with full codebase access, and posts a **GitHub review** (summary + optional inline comments).
- **Local** — connects to your own machine over **SSH** and edits files there directly.
@@ -20,15 +21,24 @@ It has two modes that decide *where* it runs and *how* its changes land:
Pick the mode with the **Mode** dropdown. The fields below it change to match.
-### Cloud
+### Cloud PR
-Cloud runs entirely inside a disposable sandbox, so it never touches your machine. It clones the repo, lets the agent work with full read/shell/edit/git, pushes a branch, and opens a PR you review and merge.
+Cloud PR runs entirely inside a disposable sandbox, so it never touches your machine. It clones the repo, lets the agent work with full read/shell/edit/git, pushes a branch, and opens a PR you review and merge.
-- Requires sandbox execution to be enabled (the Cloud option only appears when it is).
+- Requires sandbox execution to be enabled (Cloud options only appear when it is).
- Requires **your own provider API key (BYOK)** — the model key is handed to the sandbox.
-- Needs a **GitHub token** with permission to clone, push, and open a PR (see [Setup](#setup-cloud)).
+- Needs a **GitHub token** with permission to clone, push, and open a PR (see [Setup](#setup-cloud-pr)).
- The deliverable is a **pull request** — nothing is committed to your default branch directly.
+### Cloud Code Review
+
+Cloud Code Review also runs in a disposable sandbox, but it does not create a PR. It fetches an existing pull request, checks out the PR head, and asks the agent to review the changes. Sim then submits one GitHub review with a summary body and optional inline line comments.
+
+- Same sandbox + BYOK requirements as Cloud PR.
+- Needs a **GitHub token** that can clone the repo and submit reviews (see [Setup](#setup-cloud-code-review)).
+- Needs the **Pull Request Number** to review.
+- The deliverable is a **submitted review** — read `reviewUrl` and `commentsPosted`.
+
### Local
Local runs the agent against a repository on a machine you control, reached over SSH. Changes are written **in place** — there's no PR; you review them as normal git changes on that machine.
@@ -41,7 +51,7 @@ Local runs the agent against a repository on a machine you control, reached over
### Task
-What the agent should do, in plain language — for example *"Add input validation to the signup form and a test for it."* Insert a [connection tag](/workflows/connections) to pass an earlier output, like ``.
+What the agent should do, in plain language — for example *"Add input validation to the signup form and a test for it."* or *"Review this PR for security and correctness issues."* Insert a [connection tag](/workflows/connections) to pass an earlier output, like ``.
### Model
@@ -49,17 +59,25 @@ The model that drives the agent. Defaults to `claude-sonnet-4-6`. The dropdown l
### API Key
-Your key for the chosen provider. On hosted Sim it's optional for Local runs (a hosted key is used and metered to your workspace), but **Cloud always requires your own key** — enter it in this field. For OpenAI, Anthropic, Google, and Mistral you can instead store a workspace key in **Settings → BYOK**; other providers must use this field.
+Your key for the chosen provider. On hosted Sim it's optional for Local runs (a hosted key is used and metered to your workspace), but **Cloud modes always require your own key** — enter it in this field. For OpenAI, Anthropic, Google, and Mistral you can instead store a workspace key in **Settings → BYOK**; other providers must use this field.
+
+### Repository (Cloud PR / Cloud Code Review)
+
+- **Repository Owner / Repository Name** — the GitHub repo (for example `your-org` / `your-repo`).
+- **GitHub Token** — a personal access token used for GitHub access. Permissions differ by mode; see [Setup](#setup-cloud-pr).
-### Repository (Cloud)
+### Cloud PR fields
-- **Repository Owner / Repository Name** — the GitHub repo to clone and open the PR against (for example `your-org` / `your-repo`).
-- **GitHub Token** — a personal access token used to clone, push, and open the PR. See [Setup](#setup-cloud) for the exact permissions.
- **Base Branch** — the branch the PR is opened against and cloned from. Defaults to the repository's default branch.
- **Branch Name** *(advanced)* — the branch to push. Auto-generated when blank.
- **Open as Draft PR** *(advanced)* — opens the PR as a draft. On by default.
- **PR Title / PR Body** *(advanced)* — generated from the run when blank.
+### Cloud Code Review fields
+
+- **Pull Request Number** — the PR to review (for example `42`).
+- **Review Event** — the GitHub review action to submit: `Comment` (default), `Request changes`, or `Approve`.
+
### Connection (Local)
- **Host** — the public hostname or tunnel for the target machine (for example `2.tcp.ngrok.io`). Not `localhost` or a LAN address.
@@ -98,7 +116,7 @@ Reuse the same **Conversation ID** across runs to continue a thread. Each turn s
Memory is folded into the agent's first prompt, and two layers keep it within the model's context window:
- **Sim trims before the run.** The selected memory type bounds what's injected: **Conversation** is automatically capped to a fraction of the model's context window (for models in Sim's catalog), **Sliding window (messages)** keeps the last N messages, and **Sliding window (tokens)** keeps history up to an explicit token budget.
-- **Pi compacts during the run.** As the agent works (reading files, running commands), Pi automatically summarizes older turns to stay under the window — in both Cloud and Local mode, on by default. You don't need to configure anything for context growth mid-run.
+- **Pi compacts during the run.** As the agent works (reading files, running commands), Pi automatically summarizes older turns to stay under the window — in all modes, on by default. You don't need to configure anything for context growth mid-run.
The one case neither layer can rescue is a *first* prompt that already exceeds the window — Pi can only compact once there are older turns to summarize. This is only reachable with **Conversation** memory plus a model typed in manually (not in Sim's catalog), where the automatic cap can't look up a context window. For long histories — and whenever you use a manually entered model — choose **Sliding window (tokens)**: its budget applies regardless of the model, so the first prompt always fits.
@@ -109,8 +127,10 @@ The one case neither layer can rescue is a *first* prompt that already exceeds t
| `` | The agent's final message / run summary |
| `` | The files the agent changed |
| `` | A unified diff of the changes |
-| `` | URL of the opened pull request *(Cloud)* |
-| `` | The branch pushed with the changes *(Cloud)* |
+| `` | URL of the opened pull request *(Cloud PR)* |
+| `` | The branch pushed with the changes *(Cloud PR)* |
+| `` | URL of the submitted GitHub review *(Cloud Code Review)* |
+| `` | Number of inline review comments posted *(Cloud Code Review)* |
| `` | The model that ran |
| `` | Token usage, an object `{ input, output, total }` |
| `` | Estimated cost of the run |
@@ -118,16 +138,23 @@ The one case neither layer can rescue is a *first* prompt that already exceeds t
## Setup
-### Cloud [#setup-cloud]
+### Cloud PR [#setup-cloud-pr]
-Cloud runs in a sandbox image with the Pi CLI and git baked in.
+Cloud PR runs in a sandbox image with the Pi CLI and git baked in.
-1. **Enable sandbox execution.** On self-hosted Sim, set `E2B_ENABLED=true`, `E2B_API_KEY`, `E2B_PI_TEMPLATE_ID` (the Pi template id), and `NEXT_PUBLIC_E2B_ENABLED=true` (this reveals the Cloud option in the UI). Build the template with `bun run apps/sim/scripts/build-pi-e2b-template.ts`. The Cloud option stays hidden until `NEXT_PUBLIC_E2B_ENABLED` is set.
+1. **Enable sandbox execution.** On self-hosted Sim, set `E2B_ENABLED=true`, `E2B_API_KEY`, `E2B_PI_TEMPLATE_ID` (the Pi template id), and `NEXT_PUBLIC_E2B_ENABLED=true` (this reveals the Cloud options in the UI). Build the template with `bun run apps/sim/scripts/build-pi-e2b-template.ts`. Cloud options stay hidden until `NEXT_PUBLIC_E2B_ENABLED` is set.
2. **Bring your own model key.** Set the provider API key in the block's API Key field (or, for OpenAI/Anthropic/Google/Mistral, in **Settings → BYOK**).
3. **Create a GitHub token** with permission to clone, push, and open a PR:
- *Fine-grained:* select the repo, then **Contents: Read and write** + **Pull requests: Read and write**.
- *Classic:* the **`repo`** scope. For org repos, authorize the token for SSO.
+### Cloud Code Review [#setup-cloud-code-review]
+
+Same sandbox and BYOK setup as Cloud PR. The GitHub token needs enough access to clone the repo and submit a review — push permission is not required:
+
+- *Fine-grained:* select the repo, then **Contents: Read** + **Pull requests: Read and write**.
+- *Classic:* the **`repo`** scope (or a narrower token that can read contents and write pull-request reviews). For org repos, authorize the token for SSO.
+
### Local [#setup-local]
1. **Enable SSH** on the target machine (on macOS: System Settings → General → Sharing → Remote Login).
@@ -137,16 +164,17 @@ Cloud runs in a sandbox image with the Pi CLI and git baked in.
## Best Practices
- **Scope the task.** A specific instruction ("fix the failing `auth` test and add a regression case") produces far better results than a vague one.
-- **Use Cloud for hands-off PRs, Local for your working tree.** Cloud is safest for unattended changes (everything lands in a reviewable PR); Local is for iterating on a repo you already have checked out.
+- **Match the mode to the deliverable.** Cloud PR for unattended changes, Cloud Code Review for feedback on an existing PR, Local for iterating on a repo you already have checked out.
- **Prefer key auth and tear down tunnels.** A public SSH tunnel is a real attack surface — use a private key and stop the tunnel when you're done.
- **Reuse a Conversation ID for follow-ups.** It carries the prior task and outcome into the next run so the agent can build on its own work.
diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts
index b466ef77587..48a6c848aae 100644
--- a/apps/sim/blocks/blocks/pi.ts
+++ b/apps/sim/blocks/blocks/pi.ts
@@ -17,6 +17,8 @@ interface PiResponse extends ToolResponse {
diff?: string
prUrl?: string
branch?: string
+ reviewUrl?: string
+ commentsPosted?: number
tokens?: {
input?: number
output?: number
@@ -36,6 +38,14 @@ interface PiResponse extends ToolResponse {
}
const CLOUD: { field: 'mode'; value: 'cloud' } = { field: 'mode', value: 'cloud' }
+const CLOUD_REVIEW: { field: 'mode'; value: 'cloud_review' } = {
+ field: 'mode',
+ value: 'cloud_review',
+}
+const CLOUD_ANY: { field: 'mode'; value: Array<'cloud' | 'cloud_review'> } = {
+ field: 'mode',
+ value: ['cloud', 'cloud_review'],
+}
const LOCAL: { field: 'mode'; value: 'local' } = { field: 'mode', value: 'local' }
const MEMORY_TYPES = ['conversation', 'sliding_window', 'sliding_window_tokens']
@@ -45,11 +55,12 @@ export const PiBlock: BlockConfig = {
description: 'Run an autonomous coding agent on a repo',
authMode: AuthMode.ApiKey,
longDescription:
- 'The Pi Coding Agent runs the Pi harness against a real repository. In Cloud mode it spins up an isolated sandbox, clones a connected GitHub repo, edits and tests with native shell + git, and opens a pull request. In Local mode it edits files on your own machine over SSH. Both modes stream progress and reuse your models, skills, and multi-turn memory.',
+ 'The Pi Coding Agent runs the Pi harness against a real repository. Cloud PR spins up an isolated sandbox, clones a GitHub repo, edits with native shell + git, and opens a pull request. Cloud Code Review checks out an existing PR and posts a structured review with optional inline comments. Local mode edits files on your own machine over SSH. All modes stream progress and reuse your models, skills, and multi-turn memory.',
bestPractices: `
- - Use Cloud mode for hands-off changes against a GitHub repo where a reviewable PR is the deliverable.
+ - Use Cloud PR for hands-off changes against a GitHub repo where a reviewable PR is the deliverable.
+ - Use Cloud Code Review to analyze an existing PR and leave summary + inline review comments.
- Use Local mode to edit a repo on your own machine; expose the machine on a public hostname/tunnel so Sim can reach it over SSH.
- - Cloud mode requires your own provider API key (BYOK); the model key is never injected as a hosted key into the sandbox.
+ - Cloud modes require your own provider API key (BYOK); the model key is never injected as a hosted key into the sandbox.
`,
category: 'blocks',
integrationType: IntegrationType.AI,
@@ -60,7 +71,7 @@ export const PiBlock: BlockConfig = {
id: 'mode',
title: 'Mode',
type: 'dropdown',
- // Cloud mode runs in an E2B sandbox; only offer it where E2B is enabled.
+ // Cloud modes run in an E2B sandbox; only offer them where E2B is enabled.
value: () => (isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED')) ? 'cloud' : 'local'),
options: () => {
const options = [
@@ -71,11 +82,18 @@ export const PiBlock: BlockConfig = {
},
]
if (isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED'))) {
- options.unshift({
- label: 'Cloud',
- id: 'cloud',
- description: 'Runs in an isolated sandbox, clones your repo, and opens a PR',
- })
+ options.unshift(
+ {
+ label: 'Cloud PR',
+ id: 'cloud',
+ description: 'Runs in an isolated sandbox, clones your repo, and opens a PR',
+ },
+ {
+ label: 'Cloud Code Review',
+ id: 'cloud_review',
+ description: 'Reviews an existing PR and posts GitHub review comments',
+ }
+ )
}
return options
},
@@ -106,7 +124,7 @@ export const PiBlock: BlockConfig = {
type: 'short-input',
placeholder: 'e.g., your-org',
required: true,
- condition: CLOUD,
+ condition: CLOUD_ANY,
},
{
id: 'repo',
@@ -114,7 +132,7 @@ export const PiBlock: BlockConfig = {
type: 'short-input',
placeholder: 'e.g., my-repo',
required: true,
- condition: CLOUD,
+ condition: CLOUD_ANY,
},
{
id: 'githubToken',
@@ -122,10 +140,11 @@ export const PiBlock: BlockConfig = {
type: 'short-input',
password: true,
paramVisibility: 'user-only',
- placeholder: 'GitHub personal access token (repo scope)',
- tooltip: 'Personal access token with repo scope, used to clone, push, and open the PR.',
+ placeholder: 'GitHub personal access token',
+ tooltip:
+ 'Personal access token used for GitHub access. Cloud PR needs clone/push/PR permissions; Cloud Code Review needs clone + review permissions.',
required: true,
- condition: CLOUD,
+ condition: CLOUD_ANY,
},
{
id: 'baseBranch',
@@ -167,6 +186,27 @@ export const PiBlock: BlockConfig = {
mode: 'advanced',
condition: CLOUD,
},
+ {
+ id: 'pullNumber',
+ title: 'Pull Request Number',
+ type: 'short-input',
+ placeholder: 'e.g., 42',
+ required: true,
+ condition: CLOUD_REVIEW,
+ },
+ {
+ id: 'reviewEvent',
+ title: 'Review Event',
+ type: 'dropdown',
+ defaultValue: 'COMMENT',
+ options: [
+ { label: 'Comment', id: 'COMMENT' },
+ { label: 'Request changes', id: 'REQUEST_CHANGES' },
+ { label: 'Approve', id: 'APPROVE' },
+ ],
+ tooltip: 'GitHub review action submitted with the agent findings.',
+ condition: CLOUD_REVIEW,
+ },
{
id: 'host',
@@ -336,17 +376,25 @@ export const PiBlock: BlockConfig = {
access: [],
},
inputs: {
- mode: { type: 'string', description: 'Execution mode: cloud or local' },
+ mode: {
+ type: 'string',
+ description: 'Execution mode: cloud, cloud_review, or local',
+ },
task: { type: 'string', description: 'Instruction for the coding agent' },
model: { type: 'string', description: 'AI model to use' },
- owner: { type: 'string', description: 'GitHub repository owner (cloud mode)' },
- repo: { type: 'string', description: 'GitHub repository name (cloud mode)' },
- githubToken: { type: 'string', description: 'GitHub token override (cloud mode)' },
- baseBranch: { type: 'string', description: 'Base branch for the PR (cloud mode)' },
- branchName: { type: 'string', description: 'Branch to create (cloud mode)' },
- draft: { type: 'boolean', description: 'Open the PR as a draft (cloud mode)' },
- prTitle: { type: 'string', description: 'Pull request title (cloud mode)' },
- prBody: { type: 'string', description: 'Pull request body (cloud mode)' },
+ owner: { type: 'string', description: 'GitHub repository owner (cloud modes)' },
+ repo: { type: 'string', description: 'GitHub repository name (cloud modes)' },
+ githubToken: { type: 'string', description: 'GitHub token (cloud modes)' },
+ baseBranch: { type: 'string', description: 'Base branch for the PR (Cloud PR)' },
+ branchName: { type: 'string', description: 'Branch to create (Cloud PR)' },
+ draft: { type: 'boolean', description: 'Open the PR as a draft (Cloud PR)' },
+ prTitle: { type: 'string', description: 'Pull request title (Cloud PR)' },
+ prBody: { type: 'string', description: 'Pull request body (Cloud PR)' },
+ pullNumber: { type: 'number', description: 'Pull request number (Cloud Code Review)' },
+ reviewEvent: {
+ type: 'string',
+ description: 'GitHub review event: COMMENT, REQUEST_CHANGES, or APPROVE',
+ },
host: { type: 'string', description: 'SSH host (local mode)' },
port: { type: 'number', description: 'SSH port (local mode)' },
username: { type: 'string', description: 'SSH username (local mode)' },
@@ -379,6 +427,16 @@ export const PiBlock: BlockConfig = {
description: 'Branch pushed with the changes',
condition: CLOUD,
},
+ reviewUrl: {
+ type: 'string',
+ description: 'URL of the submitted GitHub review',
+ condition: CLOUD_REVIEW,
+ },
+ commentsPosted: {
+ type: 'number',
+ description: 'Number of inline review comments posted',
+ condition: CLOUD_REVIEW,
+ },
tokens: { type: 'json', description: 'Token usage statistics' },
cost: { type: 'json', description: 'Cost of the run' },
providerTiming: { type: 'json', description: 'Provider timing information' },
diff --git a/apps/sim/executor/handlers/pi/backend.ts b/apps/sim/executor/handlers/pi/backend.ts
index 6da3c48e2b1..bce0dc6064b 100644
--- a/apps/sim/executor/handlers/pi/backend.ts
+++ b/apps/sim/executor/handlers/pi/backend.ts
@@ -62,7 +62,7 @@ export interface PiLocalRunParams extends PiRunBaseParams {
tools: PiToolSpec[]
}
-/** Parameters for a cloud (E2B) Pi run. */
+/** Parameters for a cloud (E2B) Pi run that opens a PR. */
export interface PiCloudRunParams extends PiRunBaseParams {
mode: 'cloud'
owner: string
@@ -75,7 +75,17 @@ export interface PiCloudRunParams extends PiRunBaseParams {
prBody?: string
}
-export type PiRunParams = PiLocalRunParams | PiCloudRunParams
+/** Parameters for a cloud (E2B) Pi run that reviews an existing PR. */
+export interface PiCloudReviewRunParams extends PiRunBaseParams {
+ mode: 'cloud_review'
+ owner: string
+ repo: string
+ githubToken: string
+ pullNumber: number
+ reviewEvent: 'COMMENT' | 'REQUEST_CHANGES' | 'APPROVE'
+}
+
+export type PiRunParams = PiLocalRunParams | PiCloudRunParams | PiCloudReviewRunParams
/** Progress callbacks and cancellation passed into a backend run. */
export interface PiRunContext {
@@ -90,6 +100,8 @@ export interface PiRunResult {
diff?: string
prUrl?: string
branch?: string
+ reviewUrl?: string
+ commentsPosted?: number
}
/** A Pi execution backend. Implemented by the local (SSH) and cloud (E2B) runners. */
diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud-backend.ts
index f635eade0d0..07c53f29f66 100644
--- a/apps/sim/executor/handlers/pi/cloud-backend.ts
+++ b/apps/sim/executor/handlers/pi/cloud-backend.ts
@@ -1,5 +1,5 @@
/**
- * Cloud-mode backend: runs the Pi CLI inside an E2B sandbox against a cloned
+ * Cloud PR backend: runs the Pi CLI inside an E2B sandbox against a cloned
* GitHub repo, then pushes a branch and opens a PR. Secrets are isolated per
* command (S2/KTD10): the GitHub token is present only for the clone and push
* commands (and stripped from the cloned remote), while the Pi loop runs with a
@@ -15,9 +15,18 @@
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
-import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { withPiSandbox } from '@/lib/execution/e2b'
import type { PiBackendRun, PiCloudRunParams } from '@/executor/handlers/pi/backend'
+import {
+ CLONE_TIMEOUT_MS,
+ extractMarkerValues,
+ PI_SCRIPT,
+ PI_TIMEOUT_MS,
+ PROMPT_PATH,
+ REPO_DIR,
+ raceAbort,
+ scrubGitSecrets,
+} from '@/executor/handlers/pi/cloud-shared'
import { buildPiPrompt } from '@/executor/handlers/pi/context'
import {
applyPiEvent,
@@ -30,16 +39,9 @@ import { executeTool } from '@/tools'
const logger = createLogger('PiCloudBackend')
-const REPO_DIR = '/workspace/repo'
const DIFF_PATH = '/workspace/pi.diff'
-
-const PROMPT_PATH = '/workspace/pi-prompt.txt'
const COMMIT_MSG_PATH = '/workspace/pi-commit.txt'
-
const PUSH_ERR_PATH = '/workspace/pi-push-err.txt'
-const CLONE_TIMEOUT_MS = 10 * 60 * 1000
-
-const PI_TIMEOUT_MS = getMaxExecutionTimeout()
const FINALIZE_TIMEOUT_MS = 10 * 60 * 1000
const MAX_DIFF_BYTES = 200_000
const COMMIT_TITLE_MAX = 72
@@ -68,9 +70,6 @@ echo "__DEFAULT_BRANCH__=$DEFAULT_BRANCH"
git checkout -b "$BRANCH"
git remote set-url origin "https://github.com/$REPO_OWNER/$REPO_NAME.git"`
-const PI_SCRIPT = `cd ${REPO_DIR}
-pi -p --mode json --provider "$PI_PROVIDER" --model "$PI_MODEL" --thinking "$PI_THINKING" < ${PROMPT_PATH}`
-
// Finalize is split so the GitHub token is in scope for ONLY the push. `git add`,
// `commit`, and `diff` run repo-config-driven programs that `core.hooksPath` does
// NOT disable — gitattributes clean/smudge filters (on add), `core.fsmonitor`
@@ -95,43 +94,6 @@ if git diff --quiet "$BASE_SHA" HEAD; then echo "__NO_CHANGES__=1"; else echo "_
const PUSH_SCRIPT = `cd ${REPO_DIR}
git -c core.hooksPath=/dev/null -c credential.helper= -c core.fsmonitor= push "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" "$BRANCH" >/dev/null 2>${PUSH_ERR_PATH} && echo "__PUSHED__=1"`
-function raceAbort(promise: Promise, signal?: AbortSignal): Promise {
- if (!signal) return promise
- if (signal.aborted) return Promise.reject(new Error('Pi run aborted'))
- return new Promise((resolve, reject) => {
- const onAbort = () => reject(new Error('Pi run aborted'))
- signal.addEventListener('abort', onAbort, { once: true })
- promise.then(
- (value) => {
- signal.removeEventListener('abort', onAbort)
- resolve(value)
- },
- (error) => {
- signal.removeEventListener('abort', onAbort)
- reject(error)
- }
- )
- })
-}
-
-function extractMarkerValues(stdout: string, prefix: string): string[] {
- return stdout
- .split('\n')
- .filter((line) => line.startsWith(prefix))
- .map((line) => line.slice(prefix.length).trim())
- .filter(Boolean)
-}
-
-/**
- * Redacts the GitHub token from git output before it is surfaced in an error.
- * Removes the literal token and any URL userinfo (`//user:token@`), so a failure
- * message can quote git's real stderr without leaking the credential.
- */
-function scrubGitSecrets(text: string, token: string): string {
- const withoutToken = token ? text.split(token).join('***') : text
- return withoutToken.replace(/\/\/[^/@\s]+@/g, '//***@')
-}
-
function buildPrBody(task: string, finalText: string): string {
const summary = finalText.trim()
? truncate(finalText.trim(), PR_SUMMARY_MAX)
diff --git a/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts
new file mode 100644
index 00000000000..a2c2e3c4f47
--- /dev/null
+++ b/apps/sim/executor/handlers/pi/cloud-review-backend.test.ts
@@ -0,0 +1,257 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockRun, mockReadFile, mockWriteFile, mockExecuteTool, mockProviderEnvVar } = vi.hoisted(
+ () => ({
+ mockRun: vi.fn(),
+ mockReadFile: vi.fn(),
+ mockWriteFile: vi.fn(),
+ mockExecuteTool: vi.fn(),
+ mockProviderEnvVar: vi.fn(),
+ })
+)
+
+vi.mock('@/lib/execution/e2b', () => ({
+ withPiSandbox: (fn: (runner: unknown) => unknown) =>
+ fn({ run: mockRun, readFile: mockReadFile, writeFile: mockWriteFile }),
+}))
+vi.mock('@/tools', () => ({ executeTool: mockExecuteTool }))
+vi.mock('@/executor/handlers/pi/keys', () => ({
+ providerApiKeyEnvVar: mockProviderEnvVar,
+ mapThinkingLevel: () => 'medium',
+}))
+vi.mock('@/executor/handlers/pi/context', () => ({ buildPiPrompt: () => 'PROMPT' }))
+
+import type { PiCloudReviewRunParams } from '@/executor/handlers/pi/backend'
+import { runCloudReviewPi } from '@/executor/handlers/pi/cloud-review-backend'
+
+function baseParams(overrides: Partial = {}): PiCloudReviewRunParams {
+ return {
+ mode: 'cloud_review',
+ model: 'claude',
+ providerId: 'anthropic',
+ apiKey: 'sk-byok',
+ isBYOK: true,
+ task: 'review this PR',
+ skills: [],
+ initialMessages: [],
+ owner: 'octo',
+ repo: 'demo',
+ githubToken: 'ghp_secret',
+ pullNumber: 7,
+ reviewEvent: 'COMMENT',
+ ...overrides,
+ }
+}
+
+const reviewJson = JSON.stringify({
+ body: 'Overall looks solid.',
+ comments: [{ path: 'src/x.ts', body: 'Consider a null check', line: 12, side: 'RIGHT' }],
+})
+
+describe('runCloudReviewPi', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockProviderEnvVar.mockReturnValue('ANTHROPIC_API_KEY')
+ mockReadFile.mockResolvedValue(reviewJson)
+ mockExecuteTool.mockImplementation((toolId: string) => {
+ if (toolId === 'github_pr_v2') {
+ return Promise.resolve({
+ success: true,
+ output: {
+ title: 'Add feature',
+ body: 'Does the thing',
+ html_url: 'https://github.com/octo/demo/pull/7',
+ head: { sha: 'deadbeef' },
+ files: [
+ {
+ filename: 'src/x.ts',
+ status: 'modified',
+ additions: 3,
+ deletions: 1,
+ patch: '@@ -1 +1 @@\n+hello',
+ },
+ ],
+ },
+ })
+ }
+ return Promise.resolve({
+ success: true,
+ output: {
+ metadata: { html_url: 'https://github.com/octo/demo/pull/7#pullrequestreview-9' },
+ },
+ })
+ })
+ mockRun.mockImplementation(
+ (command: string, options: { onStdout?: (chunk: string) => void }) => {
+ if (command.includes('git clone') || command.includes('git fetch')) {
+ return Promise.resolve({
+ stdout: '__HEAD_SHA__=deadbeef',
+ stderr: '',
+ exitCode: 0,
+ })
+ }
+ if (command.includes('pi -p')) {
+ options.onStdout?.(
+ '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"reviewing"}}\n'
+ )
+ return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
+ }
+ return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
+ }
+ )
+ })
+
+ it('isolates secrets: token only in clone, model key only in the Pi loop, no push', async () => {
+ const onEvent = vi.fn()
+ await runCloudReviewPi(baseParams(), { onEvent })
+
+ expect(mockRun).toHaveBeenCalledTimes(2)
+ const [cloneCmd, cloneOpts] = mockRun.mock.calls[0]
+ const [piCmd, piOpts] = mockRun.mock.calls[1]
+
+ expect(cloneCmd).toContain('pull/$PULL_NUMBER/head')
+ expect(cloneOpts.envs.GITHUB_TOKEN).toBe('ghp_secret')
+ expect(cloneOpts.envs.ANTHROPIC_API_KEY).toBeUndefined()
+ expect(cloneOpts.envs.PULL_NUMBER).toBe('7')
+
+ expect(piCmd).toContain('pi -p')
+ expect(piOpts.envs.ANTHROPIC_API_KEY).toBe('sk-byok')
+ expect(piOpts.envs.GITHUB_TOKEN).toBeUndefined()
+
+ expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false)
+ expect(onEvent).toHaveBeenCalledWith({ type: 'text', text: 'reviewing' })
+ })
+
+ it('writes prompt and PR context via files, then posts a review with comments', async () => {
+ const result = await runCloudReviewPi(baseParams(), { onEvent: vi.fn() })
+
+ expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-prompt.txt', 'PROMPT')
+ expect(mockWriteFile).toHaveBeenCalledWith(
+ '/workspace/pi-pr-context.md',
+ expect.stringContaining('Pull request #7')
+ )
+
+ expect(mockExecuteTool).toHaveBeenCalledWith(
+ 'github_pr_v2',
+ expect.objectContaining({
+ owner: 'octo',
+ repo: 'demo',
+ pullNumber: 7,
+ apiKey: 'ghp_secret',
+ })
+ )
+ expect(mockExecuteTool).toHaveBeenCalledWith(
+ 'github_create_pr_review',
+ expect.objectContaining({
+ owner: 'octo',
+ repo: 'demo',
+ pullNumber: 7,
+ event: 'COMMENT',
+ body: 'Overall looks solid.',
+ commit_id: 'deadbeef',
+ comments: [{ path: 'src/x.ts', body: 'Consider a null check', line: 12, side: 'RIGHT' }],
+ apiKey: 'ghp_secret',
+ })
+ )
+ expect(result.reviewUrl).toBe('https://github.com/octo/demo/pull/7#pullrequestreview-9')
+ expect(result.commentsPosted).toBe(1)
+ expect(result.prUrl).toBeUndefined()
+ })
+
+ it('submits against the checked-out SHA when the API head moved', async () => {
+ mockRun.mockImplementation((command: string) => {
+ if (command.includes('git clone') || command.includes('git fetch')) {
+ return Promise.resolve({
+ stdout: '__HEAD_SHA__=clonedsha99',
+ stderr: '',
+ exitCode: 0,
+ })
+ }
+ if (command.includes('pi -p')) {
+ return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
+ }
+ return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 })
+ })
+
+ await runCloudReviewPi(baseParams(), { onEvent: vi.fn() })
+
+ expect(mockExecuteTool).toHaveBeenCalledWith(
+ 'github_create_pr_review',
+ expect.objectContaining({ commit_id: 'clonedsha99' })
+ )
+ })
+
+ it('treats null comments as empty and drops invalid inline comments', async () => {
+ mockReadFile.mockResolvedValue(
+ JSON.stringify({
+ body: 'Summary only',
+ comments: [
+ null,
+ { path: 'a.ts', body: 'missing line' },
+ { path: 'b.ts', body: 'bad line', line: 0 },
+ { path: 'c.ts', body: 'ok', line: 4, side: 'RIGHT' },
+ ],
+ })
+ )
+
+ const result = await runCloudReviewPi(baseParams(), { onEvent: vi.fn() })
+
+ expect(mockExecuteTool).toHaveBeenCalledWith(
+ 'github_create_pr_review',
+ expect.objectContaining({
+ body: 'Summary only',
+ comments: [{ path: 'c.ts', body: 'ok', line: 4, side: 'RIGHT' }],
+ })
+ )
+ expect(result.commentsPosted).toBe(1)
+ })
+
+ it('allows comments: null without aborting the review', async () => {
+ mockReadFile.mockResolvedValue(JSON.stringify({ body: 'No inline notes', comments: null }))
+
+ const result = await runCloudReviewPi(baseParams(), { onEvent: vi.fn() })
+
+ expect(mockExecuteTool).toHaveBeenCalledWith(
+ 'github_create_pr_review',
+ expect.objectContaining({
+ body: 'No inline notes',
+ comments: [],
+ })
+ )
+ expect(result.commentsPosted).toBe(0)
+ })
+
+ it('rejects a non-BYOK key', async () => {
+ await expect(
+ runCloudReviewPi(baseParams({ isBYOK: false }), { onEvent: vi.fn() })
+ ).rejects.toThrow(/BYOK/)
+ })
+
+ it('fails when review JSON is missing or invalid', async () => {
+ mockReadFile.mockResolvedValue('not-json')
+ await expect(runCloudReviewPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(
+ /not valid JSON/
+ )
+ expect(
+ mockExecuteTool.mock.calls.some(([toolId]: [string]) => toolId === 'github_create_pr_review')
+ ).toBe(false)
+ })
+
+ it('scrubs the token from clone failures', async () => {
+ // Avoid embedding a basic-auth URL (GitGuardian); scrubbing still covers bare tokens.
+ mockRun.mockResolvedValue({
+ stdout: '',
+ stderr: 'fatal: Authentication failed for token ghp_secret',
+ exitCode: 1,
+ })
+
+ const error = (await runCloudReviewPi(baseParams(), { onEvent: vi.fn() }).catch(
+ (e) => e
+ )) as Error
+ expect(error.message).toMatch(/git clone\/fetch PR failed/)
+ expect(error.message).not.toContain('ghp_secret')
+ })
+})
diff --git a/apps/sim/executor/handlers/pi/cloud-review-backend.ts b/apps/sim/executor/handlers/pi/cloud-review-backend.ts
new file mode 100644
index 00000000000..16fb3badf6c
--- /dev/null
+++ b/apps/sim/executor/handlers/pi/cloud-review-backend.ts
@@ -0,0 +1,365 @@
+/**
+ * Cloud Code Review backend: runs the Pi CLI inside an E2B sandbox against a
+ * checked-out PR head, then posts a structured GitHub review (summary + optional
+ * inline comments). Secrets are isolated per command: the GitHub token is present
+ * only for the clone/fetch step, while the Pi loop runs with a BYOK model key
+ * only. Review posting happens on the host via executeTool (never inside the
+ * sandbox). The agent is read-only — no commit/push.
+ */
+
+import { createLogger } from '@sim/logger'
+import { truncate } from '@sim/utils/string'
+import { withPiSandbox } from '@/lib/execution/e2b'
+import type { PiBackendRun, PiCloudReviewRunParams } from '@/executor/handlers/pi/backend'
+import {
+ CLONE_TIMEOUT_MS,
+ extractMarkerValues,
+ PI_SCRIPT,
+ PI_TIMEOUT_MS,
+ PROMPT_PATH,
+ REPO_DIR,
+ raceAbort,
+ scrubGitSecrets,
+} from '@/executor/handlers/pi/cloud-shared'
+import { buildPiPrompt } from '@/executor/handlers/pi/context'
+import { applyPiEvent, createPiTotals, parseJsonLine } from '@/executor/handlers/pi/events'
+import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/keys'
+import { executeTool } from '@/tools'
+import type { CreatePRReviewComment } from '@/tools/github/types'
+
+const logger = createLogger('PiCloudReviewBackend')
+
+const REVIEW_PATH = '/workspace/pi-review.json'
+const PR_CONTEXT_PATH = '/workspace/pi-pr-context.md'
+const MAX_CONTEXT_BYTES = 400_000
+const REVIEW_BODY_MAX = 65_000
+
+const REVIEW_GUIDANCE =
+ 'You are reviewing an existing pull request inside an automated sandbox. ' +
+ 'Explore the checked-out PR branch and the PR context file at /workspace/pi-pr-context.md. ' +
+ 'Do not edit files, do not run git commands that modify state (commit, push, branch, remote), ' +
+ 'do not configure git credentials, and do not call GitHub APIs — after you finish, Sim posts ' +
+ 'the review for you. When done, write your findings to /workspace/pi-review.json as JSON with ' +
+ 'this exact shape: {"body":"","comments":[{"path":"","body":"",' +
+ '"line":,"side":"RIGHT"}]}. The body is required. comments is optional; omit it or use ' +
+ '[] when you have no inline comments. Prefer RIGHT-side line numbers from the PR diff. Keep ' +
+ 'comments specific and actionable.'
+
+const CLONE_PR_SCRIPT = `set -e
+rm -rf ${REPO_DIR}
+git clone --no-checkout "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" ${REPO_DIR}
+cd ${REPO_DIR}
+git fetch origin "pull/$PULL_NUMBER/head:pr-$PULL_NUMBER"
+git checkout "pr-$PULL_NUMBER"
+git rev-parse HEAD | sed "s/^/__HEAD_SHA__=/"
+git remote set-url origin "https://github.com/$REPO_OWNER/$REPO_NAME.git"`
+
+interface PrContext {
+ headSha: string
+ title: string
+ body: string
+ htmlUrl: string
+ files: Array<{
+ filename?: string
+ status?: string
+ additions?: number
+ deletions?: number
+ changes?: number
+ patch?: string
+ }>
+}
+
+interface ParsedReviewFindings {
+ body: string
+ comments: CreatePRReviewComment[]
+}
+
+async function fetchPrContext(params: PiCloudReviewRunParams): Promise {
+ const result = await executeTool('github_pr_v2', {
+ owner: params.owner,
+ repo: params.repo,
+ pullNumber: params.pullNumber,
+ apiKey: params.githubToken,
+ })
+
+ if (!result.success) {
+ throw new Error(`Failed to fetch PR #${params.pullNumber}: ${result.error ?? 'unknown error'}`)
+ }
+
+ const output = result.output as {
+ title?: string
+ body?: string | null
+ html_url?: string
+ head?: { sha?: string }
+ files?: PrContext['files']
+ }
+
+ const headSha = output.head?.sha?.trim()
+ if (!headSha) {
+ throw new Error(`PR #${params.pullNumber} did not include a head commit SHA`)
+ }
+
+ return {
+ headSha,
+ title: output.title?.trim() || `PR #${params.pullNumber}`,
+ body: typeof output.body === 'string' ? output.body : '',
+ htmlUrl: output.html_url ?? '',
+ files: Array.isArray(output.files) ? output.files : [],
+ }
+}
+
+function buildPrContextMarkdown(params: PiCloudReviewRunParams, pr: PrContext): string {
+ const fileSections = pr.files.map((file) => {
+ const name = file.filename || 'unknown'
+ const stats = `status=${file.status ?? 'unknown'} +${file.additions ?? 0}/-${file.deletions ?? 0}`
+ const patch = file.patch?.trim()
+ ? `\n\`\`\`diff\n${truncate(file.patch, 20_000)}\n\`\`\``
+ : '\n_(patch omitted)_'
+ return `### ${name}\n${stats}${patch}`
+ })
+
+ const content = [
+ `# Pull request #${params.pullNumber}`,
+ '',
+ `Title: ${pr.title}`,
+ pr.htmlUrl ? `URL: ${pr.htmlUrl}` : '',
+ `Head SHA: ${pr.headSha}`,
+ '',
+ '## Description',
+ '',
+ pr.body.trim() || '_No description_',
+ '',
+ '## Changed files',
+ '',
+ fileSections.length > 0 ? fileSections.join('\n\n') : '_No files returned_',
+ ]
+ .filter((line) => line !== '')
+ .join('\n')
+
+ return content.length > MAX_CONTEXT_BYTES
+ ? `${content.slice(0, MAX_CONTEXT_BYTES)}\n\n[context truncated]`
+ : content
+}
+
+function isPositiveInt(value: unknown): value is number {
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1
+}
+
+/**
+ * Parses agent review JSON. Invalid inline comments are skipped so a usable
+ * summary body can still be submitted; comments without a valid line are dropped
+ * because GitHub rejects line-less review comments when commit_id is set.
+ */
+function parseReviewFindings(raw: string): ParsedReviewFindings {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(raw)
+ } catch {
+ throw new Error('Pi review output was not valid JSON at /workspace/pi-review.json')
+ }
+
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error('Pi review output must be a JSON object with a body field')
+ }
+
+ const record = parsed as Record
+ if (typeof record.body !== 'string' || !record.body.trim()) {
+ throw new Error('Pi review output must include a non-empty body string')
+ }
+
+ const comments: CreatePRReviewComment[] = []
+ // Treat null/undefined as "no comments" — agents often emit null for optional fields.
+ if (record.comments != null) {
+ if (!Array.isArray(record.comments)) {
+ throw new Error('Pi review output comments must be an array when present')
+ }
+ for (const item of record.comments) {
+ if (!item || typeof item !== 'object') continue
+ const comment = item as Record
+ if (typeof comment.path !== 'string' || !comment.path.trim()) continue
+ if (typeof comment.body !== 'string' || !comment.body.trim()) continue
+ if (!isPositiveInt(comment.line)) continue
+
+ const normalized: CreatePRReviewComment = {
+ path: comment.path.trim(),
+ body: comment.body,
+ line: comment.line,
+ side: comment.side === 'LEFT' || comment.side === 'RIGHT' ? comment.side : 'RIGHT',
+ }
+ if (
+ isPositiveInt(comment.start_line) &&
+ comment.start_line < comment.line &&
+ (comment.start_side === undefined ||
+ comment.start_side === 'LEFT' ||
+ comment.start_side === 'RIGHT')
+ ) {
+ normalized.start_line = comment.start_line
+ if (comment.start_side === 'LEFT' || comment.start_side === 'RIGHT') {
+ normalized.start_side = comment.start_side
+ }
+ }
+ comments.push(normalized)
+ }
+ }
+
+ return {
+ body: truncate(record.body.trim(), REVIEW_BODY_MAX),
+ comments,
+ }
+}
+
+async function submitReview(
+ params: PiCloudReviewRunParams,
+ headSha: string,
+ findings: ParsedReviewFindings
+): Promise<{ reviewUrl?: string; commentsPosted: number }> {
+ const result = await executeTool('github_create_pr_review', {
+ owner: params.owner,
+ repo: params.repo,
+ pullNumber: params.pullNumber,
+ event: params.reviewEvent,
+ body: findings.body,
+ commit_id: headSha,
+ comments: findings.comments,
+ apiKey: params.githubToken,
+ })
+
+ if (!result.success) {
+ throw new Error(
+ `Failed to submit review for PR #${params.pullNumber}: ${result.error ?? 'unknown error'}`
+ )
+ }
+
+ const output = result.output as { metadata?: { html_url?: string }; html_url?: string }
+ const reviewUrl = output.metadata?.html_url ?? output.html_url
+ return { reviewUrl, commentsPosted: findings.comments.length }
+}
+
+export const runCloudReviewPi: PiBackendRun = async (params, context) => {
+ if (!params.isBYOK) {
+ throw new Error(
+ 'Cloud mode requires your own provider API key (BYOK). Set one in Settings > BYOK.'
+ )
+ }
+ const keyEnvVar = providerApiKeyEnvVar(params.providerId)
+ if (!keyEnvVar) {
+ throw new Error(
+ `Provider "${params.providerId}" is not supported in cloud mode. Use a key-based provider or run in local mode.`
+ )
+ }
+
+ const pr = await fetchPrContext(params)
+ const prContextMarkdown = buildPrContextMarkdown(params, pr)
+ const prompt = buildPiPrompt({
+ skills: params.skills,
+ initialMessages: params.initialMessages,
+ task: params.task,
+ guidance: REVIEW_GUIDANCE,
+ })
+ const totals = createPiTotals()
+ const thinking = mapThinkingLevel(params.thinkingLevel) ?? 'medium'
+
+ return withPiSandbox(async (runner) => {
+ try {
+ const clone = await raceAbort(
+ runner.run(CLONE_PR_SCRIPT, {
+ envs: {
+ GITHUB_TOKEN: params.githubToken,
+ REPO_OWNER: params.owner,
+ REPO_NAME: params.repo,
+ PULL_NUMBER: String(params.pullNumber),
+ },
+ timeoutMs: CLONE_TIMEOUT_MS,
+ }),
+ context.signal
+ )
+ if (clone.exitCode !== 0) {
+ throw new Error(
+ `git clone/fetch PR failed: ${scrubGitSecrets(clone.stderr || clone.stdout || 'unknown error', params.githubToken)}`
+ )
+ }
+ const clonedHead = extractMarkerValues(clone.stdout, '__HEAD_SHA__=')[0]
+ if (!clonedHead) {
+ throw new Error('PR checkout did not report a head commit')
+ }
+
+ await runner.writeFile(PROMPT_PATH, prompt)
+ await runner.writeFile(PR_CONTEXT_PATH, prContextMarkdown)
+
+ let buffer = ''
+ const handleChunk = (chunk: string) => {
+ buffer += chunk
+ const lines = buffer.split('\n')
+ buffer = lines.pop() ?? ''
+ for (const line of lines) {
+ const event = parseJsonLine(line)
+ if (!event) continue
+ applyPiEvent(totals, event)
+ context.onEvent(event)
+ }
+ }
+ const piRun = await raceAbort(
+ runner.run(PI_SCRIPT, {
+ envs: {
+ [keyEnvVar]: params.apiKey,
+ PI_PROVIDER: params.providerId,
+ PI_MODEL: params.model,
+ PI_THINKING: thinking,
+ },
+ timeoutMs: PI_TIMEOUT_MS,
+ onStdout: handleChunk,
+ }),
+ context.signal
+ )
+ const remaining = buffer.trim() ? parseJsonLine(buffer) : null
+ if (remaining) {
+ applyPiEvent(totals, remaining)
+ context.onEvent(remaining)
+ }
+ if (piRun.exitCode !== 0) {
+ throw new Error(
+ `Pi agent failed (exit ${piRun.exitCode}): ${piRun.stderr || piRun.stdout}`.trim()
+ )
+ }
+ if (totals.errorMessage) {
+ throw new Error(`Pi agent failed: ${totals.errorMessage}`)
+ }
+
+ let reviewRaw: string
+ try {
+ reviewRaw = await runner.readFile(REVIEW_PATH)
+ } catch {
+ throw new Error(
+ 'Pi agent did not write /workspace/pi-review.json — ensure the agent ends by writing the review JSON file'
+ )
+ }
+
+ const findings = parseReviewFindings(reviewRaw)
+ if (!totals.finalText.trim()) {
+ totals.finalText = findings.body
+ }
+
+ // Submit against the SHA we actually checked out, not the earlier API fetch —
+ // the PR head can move between fetchPrContext and clone.
+ const { reviewUrl, commentsPosted } = await submitReview(params, clonedHead, findings)
+
+ logger.info('Pi cloud review submitted', {
+ owner: params.owner,
+ repo: params.repo,
+ pullNumber: params.pullNumber,
+ commentsPosted,
+ })
+
+ return { totals, reviewUrl, commentsPosted }
+ } catch (error) {
+ if (context.signal?.aborted) {
+ logger.info('Pi cloud review aborted', {
+ owner: params.owner,
+ repo: params.repo,
+ pullNumber: params.pullNumber,
+ })
+ }
+ throw error
+ }
+ })
+}
diff --git a/apps/sim/executor/handlers/pi/cloud-shared.ts b/apps/sim/executor/handlers/pi/cloud-shared.ts
new file mode 100644
index 00000000000..9896b8e2374
--- /dev/null
+++ b/apps/sim/executor/handlers/pi/cloud-shared.ts
@@ -0,0 +1,52 @@
+/**
+ * Shared helpers for Pi cloud backends (Cloud PR and Cloud Code Review).
+ * Keeps E2B path constants, abort racing, marker parsing, and secret scrubbing
+ * in one place so the two backends cannot drift on security-sensitive details.
+ */
+
+import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
+
+export const REPO_DIR = '/workspace/repo'
+export const PROMPT_PATH = '/workspace/pi-prompt.txt'
+export const CLONE_TIMEOUT_MS = 10 * 60 * 1000
+export const PI_TIMEOUT_MS = getMaxExecutionTimeout()
+
+export const PI_SCRIPT = `cd ${REPO_DIR}
+pi -p --mode json --provider "$PI_PROVIDER" --model "$PI_MODEL" --thinking "$PI_THINKING" < ${PROMPT_PATH}`
+
+export function raceAbort(promise: Promise, signal?: AbortSignal): Promise {
+ if (!signal) return promise
+ if (signal.aborted) return Promise.reject(new Error('Pi run aborted'))
+ return new Promise((resolve, reject) => {
+ const onAbort = () => reject(new Error('Pi run aborted'))
+ signal.addEventListener('abort', onAbort, { once: true })
+ promise.then(
+ (value) => {
+ signal.removeEventListener('abort', onAbort)
+ resolve(value)
+ },
+ (error) => {
+ signal.removeEventListener('abort', onAbort)
+ reject(error)
+ }
+ )
+ })
+}
+
+export function extractMarkerValues(stdout: string, prefix: string): string[] {
+ return stdout
+ .split('\n')
+ .filter((line) => line.startsWith(prefix))
+ .map((line) => line.slice(prefix.length).trim())
+ .filter(Boolean)
+}
+
+/**
+ * Redacts the GitHub token from git output before it is surfaced in an error.
+ * Removes the literal token and any URL userinfo (`//user:token@`), so a failure
+ * message can quote git's real stderr without leaking the credential.
+ */
+export function scrubGitSecrets(text: string, token: string): string {
+ const withoutToken = token ? text.split(token).join('***') : text
+ return withoutToken.replace(/\/\/[^/@\s]+@/g, '//***@')
+}
diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts
index 17b407cc0ad..ae71f6651e0 100644
--- a/apps/sim/executor/handlers/pi/keys.test.ts
+++ b/apps/sim/executor/handlers/pi/keys.test.ts
@@ -143,4 +143,28 @@ describe('resolvePiModelKey', () => {
).rejects.toThrow(/your own provider API key/)
expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
})
+
+ it('cloud_review mode uses the same BYOK path as cloud', async () => {
+ mockGetProviderFromModel.mockReturnValue('anthropic')
+
+ const result = await resolvePiModelKey({
+ model: 'claude',
+ mode: 'cloud_review',
+ workspaceId: 'ws-1',
+ apiKey: 'sk-user',
+ })
+
+ expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-user', isBYOK: true })
+ expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
+ })
+
+ it('cloud_review mode rejects when no user key is available', async () => {
+ mockGetProviderFromModel.mockReturnValue('anthropic')
+ mockGetBYOKKey.mockResolvedValue(null)
+
+ await expect(
+ resolvePiModelKey({ model: 'claude', mode: 'cloud_review', workspaceId: 'ws-1' })
+ ).rejects.toThrow(/your own provider API key/)
+ expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled()
+ })
})
diff --git a/apps/sim/executor/handlers/pi/keys.ts b/apps/sim/executor/handlers/pi/keys.ts
index 9d85eb8a4ee..d9f6d73d7d6 100644
--- a/apps/sim/executor/handlers/pi/keys.ts
+++ b/apps/sim/executor/handlers/pi/keys.ts
@@ -1,11 +1,11 @@
/**
- * Model, provider-key, and cost resolution shared by both Pi backends. Local
- * mode mirrors the Agent block — keys resolve through `getApiKeyWithBYOK`, so a
- * Sim-hosted key may be used and billed. Cloud mode requires the user's own key
- * (the block's API Key field, or a stored workspace BYOK key) and never a hosted
- * key, since the key is handed to an untrusted sandbox. Vertex resolves through
- * `resolveVertexCredential`; cost uses the billing multiplier and is zeroed for
- * BYOK / non-billable models.
+ * Model, provider-key, and cost resolution shared by Pi backends. Local mode
+ * mirrors the Agent block — keys resolve through `getApiKeyWithBYOK`, so a
+ * Sim-hosted key may be used and billed. Cloud modes (Cloud PR and Cloud Code
+ * Review) require the user's own key (the block's API Key field, or a stored
+ * workspace BYOK key) and never a hosted key, since the key is handed to an
+ * untrusted sandbox. Vertex resolves through `resolveVertexCredential`; cost
+ * uses the billing multiplier and is zeroed for BYOK / non-billable models.
*/
import type { CreateAgentSessionOptions } from '@earendil-works/pi-coding-agent'
@@ -23,9 +23,11 @@ export interface PiKeyResolution {
isBYOK: boolean
}
+type PiKeyMode = 'cloud' | 'cloud_review' | 'local'
+
interface ResolvePiModelKeyParams {
model: string
- mode: 'cloud' | 'local'
+ mode: PiKeyMode
workspaceId?: string
userId?: string
apiKey?: string
@@ -35,6 +37,10 @@ interface ResolvePiModelKeyParams {
/** Providers whose key Sim can store as a workspace BYOK key (read back for cloud). */
const WORKSPACE_BYOK_PROVIDERS = new Set(['anthropic', 'openai', 'google', 'mistral'])
+function isCloudSandboxMode(mode: PiKeyMode): boolean {
+ return mode === 'cloud' || mode === 'cloud_review'
+}
+
/** Resolves the provider and a usable API key for the selected model. */
export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promise {
const providerId = getProviderFromModel(params.model)
@@ -51,7 +57,7 @@ export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promis
// Cloud hands the model key to an untrusted sandbox, so it must be the user's
// own key — never a Sim-hosted/rotating key. Prefer the block's API Key field,
// then a stored workspace BYOK key; refuse to fall back to a hosted key.
- if (params.mode === 'cloud') {
+ if (isCloudSandboxMode(params.mode)) {
if (params.apiKey) {
return { providerId, apiKey: params.apiKey, isBYOK: true }
}
diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts
index 3e1f951f647..c98a3c12606 100644
--- a/apps/sim/executor/handlers/pi/pi-handler.test.ts
+++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts
@@ -3,9 +3,10 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockRunLocal, mockRunCloud, mockResolveKey } = vi.hoisted(() => ({
+const { mockRunLocal, mockRunCloud, mockRunCloudReview, mockResolveKey } = vi.hoisted(() => ({
mockRunLocal: vi.fn(),
mockRunCloud: vi.fn(),
+ mockRunCloudReview: vi.fn(),
mockResolveKey: vi.fn(),
}))
@@ -23,6 +24,9 @@ vi.mock('@/executor/handlers/pi/sim-tools', () => ({
}))
vi.mock('@/executor/handlers/pi/local-backend', () => ({ runLocalPi: mockRunLocal }))
vi.mock('@/executor/handlers/pi/cloud-backend', () => ({ runCloudPi: mockRunCloud }))
+vi.mock('@/executor/handlers/pi/cloud-review-backend', () => ({
+ runCloudReviewPi: mockRunCloudReview,
+}))
vi.mock('@/blocks/utils', () => ({
parseOptionalNumberInput: (value: unknown) => {
const parsed = Number(value)
@@ -75,6 +79,11 @@ describe('PiBlockHandler', () => {
changedFiles: ['a.ts'],
diff: 'diff',
})
+ mockRunCloudReview.mockResolvedValue({
+ totals: { finalText: 'looks good', inputTokens: 0, outputTokens: 0, toolCalls: [] },
+ reviewUrl: 'https://github.com/o/r/pull/7#pullrequestreview-1',
+ commentsPosted: 2,
+ })
})
it('canHandle matches the pi block type', () => {
@@ -88,10 +97,17 @@ describe('PiBlockHandler', () => {
await expect(handler.execute(ctx(), block, { mode: 'local', task: '' })).rejects.toThrow(/Task/)
})
+ it('throws on an invalid mode', async () => {
+ await expect(
+ handler.execute(ctx(), block, { mode: 'spaceship', task: 'x', model: 'claude' })
+ ).rejects.toThrow(/Invalid Pi mode/)
+ })
+
it('routes local mode to the local backend with SSH params', async () => {
const output = await handler.execute(ctx(), block, localInputs())
expect(mockRunLocal).toHaveBeenCalledTimes(1)
expect(mockRunCloud).not.toHaveBeenCalled()
+ expect(mockRunCloudReview).not.toHaveBeenCalled()
const params = mockRunLocal.mock.calls[0][0]
expect(params.mode).toBe('local')
expect(params.ssh.host).toBe('box.example.com')
@@ -109,10 +125,34 @@ describe('PiBlockHandler', () => {
githubToken: 'ghp',
})) as Record
expect(mockRunCloud).toHaveBeenCalledTimes(1)
+ expect(mockRunCloudReview).not.toHaveBeenCalled()
expect(output.prUrl).toBe('https://github.com/o/r/pull/1')
expect(output.branch).toBe('pi/abc')
})
+ it('routes cloud_review mode and surfaces review output', async () => {
+ const output = (await handler.execute(ctx(), block, {
+ mode: 'cloud_review',
+ task: 'review it',
+ model: 'claude',
+ owner: 'o',
+ repo: 'r',
+ githubToken: 'ghp',
+ pullNumber: '7',
+ reviewEvent: 'REQUEST_CHANGES',
+ })) as Record
+
+ expect(mockRunCloudReview).toHaveBeenCalledTimes(1)
+ expect(mockRunCloud).not.toHaveBeenCalled()
+ const params = mockRunCloudReview.mock.calls[0][0]
+ expect(params.mode).toBe('cloud_review')
+ expect(params.pullNumber).toBe(7)
+ expect(params.reviewEvent).toBe('REQUEST_CHANGES')
+ expect(output.reviewUrl).toBe('https://github.com/o/r/pull/7#pullrequestreview-1')
+ expect(output.commentsPosted).toBe(2)
+ expect(output.content).toBe('looks good')
+ })
+
it('requires SSH fields in local mode', async () => {
await expect(
handler.execute(ctx(), block, { mode: 'local', task: 'x', model: 'claude', host: 'h' })
@@ -125,6 +165,19 @@ describe('PiBlockHandler', () => {
).rejects.toThrow(/Cloud mode requires/)
})
+ it('requires pullNumber in cloud_review mode', async () => {
+ await expect(
+ handler.execute(ctx(), block, {
+ mode: 'cloud_review',
+ task: 'x',
+ model: 'claude',
+ owner: 'o',
+ repo: 'r',
+ githubToken: 'ghp',
+ })
+ ).rejects.toThrow(/Cloud Code Review mode requires/)
+ })
+
it('streams text when the block is selected for streaming output', async () => {
mockRunLocal.mockImplementation(async (_params, runCtx) => {
runCtx.onEvent({ type: 'text', text: 'streamed' })
diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts
index 986ab4a211c..1d12e56b070 100644
--- a/apps/sim/executor/handlers/pi/pi-handler.ts
+++ b/apps/sim/executor/handlers/pi/pi-handler.ts
@@ -12,12 +12,14 @@ import { parseOptionalNumberInput } from '@/blocks/utils'
import { BlockType } from '@/executor/constants'
import type {
PiBackendRun,
+ PiCloudReviewRunParams,
PiCloudRunParams,
PiLocalRunParams,
PiRunParams,
PiRunResult,
} from '@/executor/handlers/pi/backend'
import { runCloudPi } from '@/executor/handlers/pi/cloud-backend'
+import { runCloudReviewPi } from '@/executor/handlers/pi/cloud-review-backend'
import {
appendPiMemory,
loadPiMemory,
@@ -38,6 +40,7 @@ import type { SerializedBlock } from '@/serializer/types'
const logger = createLogger('PiBlockHandler')
const DEFAULT_MODEL = 'claude-sonnet-5'
+const REVIEW_EVENTS = new Set(['COMMENT', 'REQUEST_CHANGES', 'APPROVE'])
function asOptString(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
@@ -65,10 +68,10 @@ export class PiBlockHandler implements BlockHandler {
// Validate the mode up front so an invalid value reports a mode error rather
// than a misattributed credential error from key resolution below.
- if (inputs.mode !== 'cloud' && inputs.mode !== 'local') {
+ if (inputs.mode !== 'cloud' && inputs.mode !== 'cloud_review' && inputs.mode !== 'local') {
throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`)
}
- const mode: 'cloud' | 'local' = inputs.mode
+ const mode: 'cloud' | 'cloud_review' | 'local' = inputs.mode
const { providerId, apiKey, isBYOK } = await resolvePiModelKey({
model,
@@ -149,6 +152,37 @@ export class PiBlockHandler implements BlockHandler {
return this.runPi(ctx, block, runCloudPi, params, memoryConfig)
}
+ if (mode === 'cloud_review') {
+ const owner = asOptString(inputs.owner)
+ const repo = asOptString(inputs.repo)
+ const githubToken = asRawString(inputs.githubToken)
+ const pullNumber = parseOptionalNumberInput(inputs.pullNumber, 'pullNumber', {
+ integer: true,
+ min: 1,
+ })
+ if (!owner || !repo || !githubToken || pullNumber === undefined) {
+ throw new Error(
+ 'Cloud Code Review mode requires repository owner, name, a GitHub token, and a pull request number'
+ )
+ }
+ const reviewEventRaw = asOptString(inputs.reviewEvent) ?? 'COMMENT'
+ if (!REVIEW_EVENTS.has(reviewEventRaw)) {
+ throw new Error(
+ `Invalid review event: ${reviewEventRaw}. Use COMMENT, REQUEST_CHANGES, or APPROVE.`
+ )
+ }
+ const params: PiCloudReviewRunParams = {
+ ...base,
+ mode: 'cloud_review',
+ owner,
+ repo,
+ githubToken,
+ pullNumber,
+ reviewEvent: reviewEventRaw as PiCloudReviewRunParams['reviewEvent'],
+ }
+ return this.runPi(ctx, block, runCloudReviewPi, params, memoryConfig)
+ }
+
throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`)
}
@@ -178,6 +212,10 @@ export class PiBlockHandler implements BlockHandler {
diff: result.diff ?? '',
...(result.prUrl ? { prUrl: result.prUrl } : {}),
...(result.branch ? { branch: result.branch } : {}),
+ ...(result.reviewUrl ? { reviewUrl: result.reviewUrl } : {}),
+ ...(typeof result.commentsPosted === 'number'
+ ? { commentsPosted: result.commentsPosted }
+ : {}),
tokens: {
input: totals.inputTokens,
output: totals.outputTokens,
diff --git a/apps/sim/tools/github/create_pr_review.test.ts b/apps/sim/tools/github/create_pr_review.test.ts
new file mode 100644
index 00000000000..6f9d261e2d5
--- /dev/null
+++ b/apps/sim/tools/github/create_pr_review.test.ts
@@ -0,0 +1,60 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { createPRReviewTool } from '@/tools/github/create_pr_review'
+
+describe('createPRReviewTool request body', () => {
+ const base = {
+ owner: 'octo',
+ repo: 'demo',
+ pullNumber: 7,
+ event: 'COMMENT' as const,
+ apiKey: 'ghp_test',
+ }
+
+ it('includes comments and commit_id when provided', () => {
+ const body = createPRReviewTool.request.body!({
+ ...base,
+ body: 'Looks good',
+ commit_id: 'abc123',
+ comments: [{ path: 'src/a.ts', body: 'nit', line: 3, side: 'RIGHT' }],
+ })
+
+ expect(body).toEqual({
+ event: 'COMMENT',
+ body: 'Looks good',
+ commit_id: 'abc123',
+ comments: [{ path: 'src/a.ts', body: 'nit', line: 3, side: 'RIGHT' }],
+ })
+ })
+
+ it('parses comments from a JSON string', () => {
+ const body = createPRReviewTool.request.body!({
+ ...base,
+ commit_id: 'abc123',
+ comments: JSON.stringify([{ path: 'a.ts', body: 'fix me', line: 1 }]) as any,
+ })
+
+ expect(body.comments).toEqual([{ path: 'a.ts', body: 'fix me', line: 1 }])
+ })
+
+ it('requires commit_id when comments are present', () => {
+ expect(() =>
+ createPRReviewTool.request.body!({
+ ...base,
+ comments: [{ path: 'a.ts', body: 'x', line: 1 }],
+ })
+ ).toThrow(/commit_id is required/)
+ })
+
+ it('omits comments when none are provided', () => {
+ const body = createPRReviewTool.request.body!({
+ ...base,
+ body: 'summary only',
+ })
+
+ expect(body).toEqual({ event: 'COMMENT', body: 'summary only' })
+ expect(body.comments).toBeUndefined()
+ })
+})
diff --git a/apps/sim/tools/github/create_pr_review.ts b/apps/sim/tools/github/create_pr_review.ts
index 4384205fca4..ad1ccaf92e6 100644
--- a/apps/sim/tools/github/create_pr_review.ts
+++ b/apps/sim/tools/github/create_pr_review.ts
@@ -1,7 +1,51 @@
-import type { CreatePRReviewParams, PRReviewResponse } from '@/tools/github/types'
+import type {
+ CreatePRReviewComment,
+ CreatePRReviewParams,
+ PRReviewResponse,
+} from '@/tools/github/types'
import { USER_OUTPUT } from '@/tools/github/types'
import type { ToolConfig } from '@/tools/types'
+function normalizeReviewComments(
+ comments: CreatePRReviewParams['comments'] | string | undefined
+): CreatePRReviewComment[] {
+ if (!comments) return []
+ let parsed: unknown = comments
+ if (typeof comments === 'string') {
+ try {
+ parsed = JSON.parse(comments)
+ } catch {
+ throw new Error('comments must be a JSON array of inline review comments')
+ }
+ }
+ if (!Array.isArray(parsed)) {
+ throw new Error('comments must be an array of inline review comments')
+ }
+ return parsed.map((item, index) => {
+ if (!item || typeof item !== 'object') {
+ throw new Error(`comments[${index}] must be an object`)
+ }
+ const comment = item as Record
+ if (typeof comment.path !== 'string' || !comment.path.trim()) {
+ throw new Error(`comments[${index}].path is required`)
+ }
+ if (typeof comment.body !== 'string' || !comment.body.trim()) {
+ throw new Error(`comments[${index}].body is required`)
+ }
+ const normalized: CreatePRReviewComment = {
+ path: comment.path.trim(),
+ body: comment.body,
+ }
+ if (typeof comment.line === 'number') normalized.line = comment.line
+ if (comment.side === 'LEFT' || comment.side === 'RIGHT') normalized.side = comment.side
+ if (typeof comment.start_line === 'number') normalized.start_line = comment.start_line
+ if (comment.start_side === 'LEFT' || comment.start_side === 'RIGHT') {
+ normalized.start_side = comment.start_side
+ }
+ return normalized
+ })
+}
+
export const createPRReviewTool: ToolConfig = {
id: 'github_create_pr_review',
name: 'GitHub Create PR Review',
@@ -44,7 +88,15 @@ export const createPRReviewTool: ToolConfig {
+ const comments = normalizeReviewComments(params.comments)
+ if (comments.length > 0 && !params.commit_id) {
+ throw new Error('commit_id is required when posting inline review comments')
+ }
+
const body: Record = {
event: params.event,
}
if (params.body) body.body = params.body
if (params.commit_id) body.commit_id = params.commit_id
+ if (comments.length > 0) body.comments = comments
return body
},
},
diff --git a/apps/sim/tools/github/types.ts b/apps/sim/tools/github/types.ts
index 2693ecdf92c..e0aa0946b06 100644
--- a/apps/sim/tools/github/types.ts
+++ b/apps/sim/tools/github/types.ts
@@ -965,12 +965,24 @@ export interface RequestReviewersParams extends BaseGitHubParams {
team_reviewers?: string
}
+/** Inline review comment attached to a submitted PR review. */
+export interface CreatePRReviewComment {
+ path: string
+ body: string
+ line?: number
+ side?: 'LEFT' | 'RIGHT'
+ start_line?: number
+ start_side?: 'LEFT' | 'RIGHT'
+}
+
// Create PR review parameters
export interface CreatePRReviewParams extends BaseGitHubParams {
pullNumber: number
event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT'
body?: string
commit_id?: string
+ /** Inline line comments submitted atomically with the review. */
+ comments?: CreatePRReviewComment[]
}
// Response metadata interfaces