From 835ba61aaea51f81d0dcfa2bcd4bea8fc0e884df Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 22 Jun 2026 20:17:41 -0700 Subject: [PATCH 1/9] feat(pi): add pi coding agent harness --- .../docs/en/workflows/blocks/meta.json | 1 + .../content/docs/en/workflows/blocks/pi.mdx | 142 +++++++ .../components/tool-input/tool-input.tsx | 54 ++- apps/sim/blocks/blocks/pi.ts | 382 ++++++++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/blocks/types.ts | 7 + apps/sim/blocks/utils.ts | 19 + apps/sim/components/icons.tsx | 22 + apps/sim/executor/constants.ts | 1 + apps/sim/executor/handlers/pi/backend.ts | 99 +++++ .../handlers/pi/cloud-backend.test.ts | 237 +++++++++++ .../sim/executor/handlers/pi/cloud-backend.ts | 330 +++++++++++++++ apps/sim/executor/handlers/pi/context.ts | 110 +++++ apps/sim/executor/handlers/pi/events.test.ts | 116 ++++++ apps/sim/executor/handlers/pi/events.ts | 160 ++++++++ apps/sim/executor/handlers/pi/keys.test.ts | 146 +++++++ apps/sim/executor/handlers/pi/keys.ts | 127 ++++++ .../sim/executor/handlers/pi/local-backend.ts | 187 +++++++++ .../executor/handlers/pi/pi-handler.test.ts | 153 +++++++ apps/sim/executor/handlers/pi/pi-handler.ts | 262 ++++++++++++ .../executor/handlers/pi/sim-tools.test.ts | 84 ++++ apps/sim/executor/handlers/pi/sim-tools.ts | 107 +++++ .../executor/handlers/pi/ssh-tools.test.ts | 106 +++++ apps/sim/executor/handlers/pi/ssh-tools.ts | 224 ++++++++++ apps/sim/executor/handlers/registry.ts | 2 + apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/execution/e2b.ts | 95 ++++- apps/sim/next.config.ts | 1 + apps/sim/package.json | 1 + apps/sim/providers/pi-providers.ts | 29 ++ apps/sim/scripts/build-pi-e2b-template.ts | 57 +++ apps/sim/tools/index.ts | 3 + apps/sim/trigger.config.ts | 10 +- bun.lock | 114 +++++- 34 files changed, 3375 insertions(+), 16 deletions(-) create mode 100644 apps/docs/content/docs/en/workflows/blocks/pi.mdx create mode 100644 apps/sim/blocks/blocks/pi.ts create mode 100644 apps/sim/executor/handlers/pi/backend.ts create mode 100644 apps/sim/executor/handlers/pi/cloud-backend.test.ts create mode 100644 apps/sim/executor/handlers/pi/cloud-backend.ts create mode 100644 apps/sim/executor/handlers/pi/context.ts create mode 100644 apps/sim/executor/handlers/pi/events.test.ts create mode 100644 apps/sim/executor/handlers/pi/events.ts create mode 100644 apps/sim/executor/handlers/pi/keys.test.ts create mode 100644 apps/sim/executor/handlers/pi/keys.ts create mode 100644 apps/sim/executor/handlers/pi/local-backend.ts create mode 100644 apps/sim/executor/handlers/pi/pi-handler.test.ts create mode 100644 apps/sim/executor/handlers/pi/pi-handler.ts create mode 100644 apps/sim/executor/handlers/pi/sim-tools.test.ts create mode 100644 apps/sim/executor/handlers/pi/sim-tools.ts create mode 100644 apps/sim/executor/handlers/pi/ssh-tools.test.ts create mode 100644 apps/sim/executor/handlers/pi/ssh-tools.ts create mode 100644 apps/sim/providers/pi-providers.ts create mode 100644 apps/sim/scripts/build-pi-e2b-template.ts diff --git a/apps/docs/content/docs/en/workflows/blocks/meta.json b/apps/docs/content/docs/en/workflows/blocks/meta.json index c00d0ce097..567a0c3417 100644 --- a/apps/docs/content/docs/en/workflows/blocks/meta.json +++ b/apps/docs/content/docs/en/workflows/blocks/meta.json @@ -2,6 +2,7 @@ "title": "Core Blocks", "pages": [ "agent", + "pi", "api", "function", "condition", diff --git a/apps/docs/content/docs/en/workflows/blocks/pi.mdx b/apps/docs/content/docs/en/workflows/blocks/pi.mdx new file mode 100644 index 0000000000..adba4b49ac --- /dev/null +++ b/apps/docs/content/docs/en/workflows/blocks/pi.mdx @@ -0,0 +1,142 @@ +--- +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. +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. + +It has two modes that decide *where* it runs and *how* its changes land: + +- **Cloud** — spins up an isolated sandbox, clones a connected GitHub repo, edits and tests with native shell + git, and opens a **pull request**. +- **Local** — connects to your own machine over **SSH** and edits files there directly. + + + +## Modes + +Pick the mode with the **Mode** dropdown. The fields below it change to match. + +### Cloud + +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. + +- Requires sandbox execution to be enabled (the Cloud option only appears when it is). +- Requires **your own provider API key (BYOK)** — the model key is handed to the sandbox, so Sim never injects a hosted key there. +- Needs a **GitHub token** with permission to clone, push, and open a PR (see [Setup](#setup-cloud)). +- The deliverable is a **pull request** — nothing is committed to your default branch directly. + +### 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. + +- The machine must be reachable on a **public hostname** — `localhost` and LAN/private addresses are blocked. Expose it with a tunnel (see [Setup](#setup-local)). +- The agent's file and shell tools are confined to the **Repository Path** you configure. +- You can also expose **Sim tools** (Gmail, Slack, Exa, …) to the agent so it can act beyond the repo while it works. + +## Configuration + +### 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 ``. + +### Model + +The model that drives the agent. Defaults to `claude-sonnet-4-6`. The dropdown lists only models the Pi harness can run: **OpenAI, Anthropic, Google (Gemini), xAI, DeepSeek, Mistral, Groq, Cerebras, and OpenRouter**. + +### 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. + +### Repository (Cloud) + +- **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. + +### Connection (Local) + +- **Host** — the public hostname or tunnel for the target machine (for example `2.tcp.ngrok.io`). Not `localhost` or a LAN address. +- **Username** — the SSH user (for example `ubuntu`, `root`, or your macOS account). +- **Authentication Method** — `Password` or `Private Key`. +- **Password / Private Key** — the credential for that method. Use a key where you can. +- **Repository Path** — the absolute path to the repo on the target machine (for example `/home/user/my-repo`). The agent's tools are confined to this directory. +- **Port** *(advanced)* — the SSH port. Defaults to `22`; set this to your tunnel's port if it differs. +- **Passphrase** *(advanced)* — for an encrypted private key. + +### Tools (Local) + +Sim tools the agent can call while it works — search a knowledge base, send a Slack message, call any of the [integrations](/integrations). They run through Sim with your connected credentials, exactly like the [Agent block](/workflows/blocks/agent). MCP and custom tools aren't supported here yet (they appear greyed out). + +### Skills + +[Agent skills](/agents/skills) the agent can use — reusable instruction packages like a coding standard or a review playbook. They're shared with the Agent block, so a skill you author once works in both. + +### Thinking Level + +For models with extended reasoning, how much the model thinks before acting. Higher is more thorough but slower and costs more tokens. Defaults to `medium`. + +### Memory + +Multi-turn memory keyed by a conversation ID, shared with the [Agent block](/workflows/blocks/agent): + +- **None.** Each run is independent. +- **Conversation.** The full history for that conversation ID. +- **Sliding window (messages).** The most recent N messages. +- **Sliding window (tokens).** Recent messages up to a token budget. + +Reuse the same **Conversation ID** across runs to continue a thread. Each turn stores your task and the agent's final summary, which are folded into the next run's prompt. + +## Outputs + +| Output | What it is | +| --- | --- | +| `` | 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)* | +| `` | The model that ran | +| `` | Token usage, an object `{ input, output, total }` | +| `` | Estimated cost of the run | +| `` | Timing, an object `{ startTime, endTime, duration }` | + +## Setup + +### Cloud + +Cloud 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. +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. + +### Local + +1. **Enable SSH** on the target machine (on macOS: System Settings → General → Sharing → Remote Login). +2. **Expose it on a public host.** Sim blocks `localhost`/LAN, so use a TCP tunnel — for example `ngrok tcp 22`, which gives a `host:port` to put in **Host** and **Port**. +3. **Use a model your provider supports** (for example a Claude model with an Anthropic key). Set the credential method and **Repository Path**, then run. + +## 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. +- **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/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 2faba4da3b..976119bd96 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -454,6 +454,27 @@ function IconComponent({ return } +const UNSUPPORTED_CUSTOM_TOOL_MESSAGE = 'Custom tools are not supported by this block yet' +const UNSUPPORTED_MCP_TOOL_MESSAGE = 'MCP tools are not supported by this block yet' + +/** + * Trailing "Unavailable" affordance for a tool category the consuming block + * cannot execute. Rendered as the combobox item's suffix so the greyed-out row + * still surfaces a tooltip explaining why on hover. + */ +function UnsupportedToolBadge({ message }: { message: string }) { + return ( + + + Unavailable + + + {message} + + + ) +} + export const ToolInput = memo(function ToolInput({ blockId, subBlockId, @@ -495,6 +516,16 @@ export const ToolInput = memo(function ToolInput({ ? (value as StoredTool[]) : [] + // Tool categories the consuming block can't run (declared on its tool-input + // subBlock): shown in the picker but greyed out with a tooltip instead of added. + const blockType = useWorkflowStore(useCallback((state) => state.blocks[blockId]?.type, [blockId])) + const unsupportedToolTypes = useMemo(() => { + const block = getAllBlocks().find((b) => b.type === blockType) + return block?.subBlocks.find((sb) => sb.id === subBlockId)?.unsupportedToolTypes ?? [] + }, [blockType, subBlockId]) + const mcpUnsupported = unsupportedToolTypes.includes('mcp') + const customUnsupported = unsupportedToolTypes.includes('custom-tool') + // Look up credential type for reactive condition filtering (e.g. service account detection). // Uses canonical resolution so the active field (basic vs advanced) is respected. const toolCredentialId = useMemo(() => { @@ -1346,7 +1377,12 @@ export const ToolInput = memo(function ToolInput({ const groups: ComboboxOptionGroup[] = [] // MCP Server drill-down: when navigated into a server, show only its tools - if (mcpServerDrilldown && !permissionConfig.disableMcpTools && mcpToolsByServer.size > 0) { + if ( + mcpServerDrilldown && + !permissionConfig.disableMcpTools && + !mcpUnsupported && + mcpToolsByServer.size > 0 + ) { const tools = mcpToolsByServer.get(mcpServerDrilldown) if (tools && tools.length > 0) { const server = mcpServers.find((s) => s.id === mcpServerDrilldown) @@ -1458,7 +1494,10 @@ export const ToolInput = memo(function ToolInput({ setCustomToolModalOpen(true) setOpen(false) }, - disabled: isPreview, + disabled: isPreview || customUnsupported, + suffixElement: customUnsupported ? ( + + ) : undefined, }) } if (!permissionConfig.disableMcpTools) { @@ -1470,14 +1509,17 @@ export const ToolInput = memo(function ToolInput({ setOpen(false) setMcpModalOpen(true) }, - disabled: isPreview, + disabled: isPreview || mcpUnsupported, + suffixElement: mcpUnsupported ? ( + + ) : undefined, }) } if (actionItems.length > 0) { groups.push({ items: actionItems }) } - if (!permissionConfig.disableCustomTools && customTools.length > 0) { + if (!permissionConfig.disableCustomTools && !customUnsupported && customTools.length > 0) { groups.push({ section: 'Custom Tools', items: customTools.map((customTool) => { @@ -1507,7 +1549,7 @@ export const ToolInput = memo(function ToolInput({ } // MCP Servers — root folder view - if (!permissionConfig.disableMcpTools && mcpToolsByServer.size > 0) { + if (!permissionConfig.disableMcpTools && !mcpUnsupported && mcpToolsByServer.size > 0) { const serverItems: ComboboxOption[] = [] for (const [serverId, tools] of mcpToolsByServer) { @@ -1620,6 +1662,8 @@ export const ToolInput = memo(function ToolInput({ handleSelectTool, permissionConfig.disableCustomTools, permissionConfig.disableMcpTools, + mcpUnsupported, + customUnsupported, availableWorkflows, isToolAlreadySelected, ]) diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts new file mode 100644 index 0000000000..5e7b45738a --- /dev/null +++ b/apps/sim/blocks/blocks/pi.ts @@ -0,0 +1,382 @@ +import { PiIcon } from '@/components/icons' +import { getEnv, isTruthy } from '@/lib/core/config/env' +import type { BlockConfig } from '@/blocks/types' +import { AuthMode, IntegrationType } from '@/blocks/types' +import { + getPiModelOptions, + getProviderCredentialSubBlocks, + PROVIDER_CREDENTIAL_INPUTS, +} from '@/blocks/utils' +import type { ToolResponse } from '@/tools/types' + +interface PiResponse extends ToolResponse { + output: { + content: string + model: string + changedFiles?: string[] + diff?: string + prUrl?: string + branch?: string + tokens?: { + input?: number + output?: number + total?: number + } + cost?: { + input?: number + output?: number + total?: number + } + providerTiming?: { + startTime?: string + endTime?: string + duration?: number + } + } +} + +const CLOUD: { field: 'mode'; value: 'cloud' } = { field: 'mode', value: 'cloud' } +const LOCAL: { field: 'mode'; value: 'local' } = { field: 'mode', value: 'local' } +const MEMORY_TYPES = ['conversation', 'sliding_window', 'sliding_window_tokens'] + +export const PiBlock: BlockConfig = { + type: 'pi', + name: 'Pi Coding Agent', + 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.', + bestPractices: ` + - Use Cloud mode for hands-off changes against a GitHub repo where a reviewable PR is the deliverable. + - 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. + `, + category: 'blocks', + integrationType: IntegrationType.AI, + bgColor: '#6E56CF', + icon: PiIcon, + subBlocks: [ + { + id: 'mode', + title: 'Mode', + type: 'dropdown', + // Cloud mode runs in an E2B sandbox; only offer it where E2B is enabled. + value: () => (isTruthy(getEnv('NEXT_PUBLIC_E2B_ENABLED')) ? 'cloud' : 'local'), + options: () => { + const options = [ + { + label: 'Local', + id: 'local', + description: 'Edits files on your own machine over SSH', + }, + ] + 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', + }) + } + return options + }, + }, + { + id: 'task', + title: 'Task', + type: 'long-input', + placeholder: 'Describe what the coding agent should do...', + required: true, + }, + { + id: 'model', + title: 'Model', + type: 'combobox', + placeholder: 'Type or select a model...', + required: true, + defaultValue: 'claude-sonnet-4-6', + options: getPiModelOptions, + commandSearchable: true, + }, + + ...getProviderCredentialSubBlocks(), + + { + id: 'owner', + title: 'Repository Owner', + type: 'short-input', + placeholder: 'e.g., your-org', + required: true, + condition: CLOUD, + }, + { + id: 'repo', + title: 'Repository Name', + type: 'short-input', + placeholder: 'e.g., my-repo', + required: true, + condition: CLOUD, + }, + { + id: 'githubToken', + title: 'GitHub Token', + type: 'short-input', + password: true, + placeholder: 'GitHub personal access token (repo scope)', + tooltip: 'Personal access token with repo scope, used to clone, push, and open the PR.', + required: true, + condition: CLOUD, + }, + { + id: 'baseBranch', + title: 'Base Branch', + type: 'short-input', + placeholder: 'e.g., main (defaults to the repository default branch)', + tooltip: 'The branch the pull request is opened against; the repo is cloned from it too.', + condition: CLOUD, + }, + { + id: 'branchName', + title: 'Branch Name', + type: 'short-input', + placeholder: 'Auto-generated when blank', + mode: 'advanced', + condition: CLOUD, + }, + { + id: 'draft', + title: 'Open as Draft PR', + type: 'switch', + defaultValue: true, + mode: 'advanced', + condition: CLOUD, + }, + { + id: 'prTitle', + title: 'PR Title', + type: 'short-input', + placeholder: 'Generated from the run when blank', + mode: 'advanced', + condition: CLOUD, + }, + { + id: 'prBody', + title: 'PR Body', + type: 'long-input', + placeholder: 'Generated from the run when blank', + mode: 'advanced', + condition: CLOUD, + }, + + { + id: 'host', + title: 'Host', + type: 'short-input', + placeholder: 'Public hostname from a TCP tunnel (e.g., 2.tcp.ngrok.io)', + tooltip: + 'The machine must be reachable on a public hostname — localhost/LAN addresses are blocked. Use a raw TCP tunnel such as `ngrok tcp 22`.', + required: true, + condition: LOCAL, + }, + { + id: 'username', + title: 'Username', + type: 'short-input', + placeholder: 'ubuntu, root, or deploy', + required: true, + condition: LOCAL, + }, + { + id: 'authMethod', + title: 'Authentication Method', + type: 'dropdown', + defaultValue: 'password', + options: [ + { label: 'Password', id: 'password' }, + { label: 'Private Key', id: 'privateKey' }, + ], + condition: LOCAL, + }, + { + id: 'password', + title: 'Password', + type: 'short-input', + password: true, + placeholder: 'Your SSH password', + required: { field: 'mode', value: 'local', and: { field: 'authMethod', value: 'password' } }, + condition: { field: 'mode', value: 'local', and: { field: 'authMethod', value: 'password' } }, + dependsOn: ['authMethod'], + }, + { + id: 'privateKey', + title: 'Private Key', + type: 'code', + placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', + required: { + field: 'mode', + value: 'local', + and: { field: 'authMethod', value: 'privateKey' }, + }, + condition: { + field: 'mode', + value: 'local', + and: { field: 'authMethod', value: 'privateKey' }, + }, + dependsOn: ['authMethod'], + }, + { + id: 'repoPath', + title: 'Repository Path', + type: 'short-input', + placeholder: '/home/user/my-repo', + tooltip: 'Absolute path to the repository on the target machine.', + required: true, + condition: LOCAL, + }, + { + id: 'port', + title: 'Port', + type: 'short-input', + placeholder: '22', + defaultValue: '22', + mode: 'advanced', + condition: LOCAL, + }, + { + id: 'passphrase', + title: 'Passphrase', + type: 'short-input', + password: true, + placeholder: 'Passphrase for encrypted key (optional)', + mode: 'advanced', + condition: { + field: 'mode', + value: 'local', + and: { field: 'authMethod', value: 'privateKey' }, + }, + dependsOn: ['authMethod'], + }, + { + id: 'tools', + title: 'Tools', + type: 'tool-input', + defaultValue: [], + mode: 'advanced', + condition: LOCAL, + unsupportedToolTypes: ['mcp', 'custom-tool'], + }, + + { + id: 'skills', + title: 'Skills', + type: 'skill-input', + defaultValue: [], + mode: 'advanced', + }, + { + id: 'thinkingLevel', + title: 'Thinking Level', + type: 'dropdown', + defaultValue: 'medium', + options: [ + { label: 'none', id: 'none' }, + { label: 'low', id: 'low' }, + { label: 'medium', id: 'medium' }, + { label: 'high', id: 'high' }, + { label: 'max', id: 'max' }, + ], + mode: 'advanced', + }, + { + id: 'memoryType', + title: 'Memory', + type: 'dropdown', + defaultValue: 'none', + options: [ + { label: 'None', id: 'none' }, + { label: 'Conversation', id: 'conversation' }, + { label: 'Sliding window (messages)', id: 'sliding_window' }, + { label: 'Sliding window (tokens)', id: 'sliding_window_tokens' }, + ], + mode: 'advanced', + }, + { + id: 'conversationId', + title: 'Conversation ID', + type: 'short-input', + placeholder: 'e.g., user-123, session-abc', + mode: 'advanced', + required: { field: 'memoryType', value: MEMORY_TYPES }, + condition: { field: 'memoryType', value: MEMORY_TYPES }, + dependsOn: ['memoryType'], + }, + { + id: 'slidingWindowSize', + title: 'Sliding Window Size', + type: 'short-input', + placeholder: 'Enter number of messages (e.g., 10)...', + mode: 'advanced', + condition: { field: 'memoryType', value: ['sliding_window'] }, + dependsOn: ['memoryType'], + }, + { + id: 'slidingWindowTokens', + title: 'Max Tokens', + type: 'short-input', + placeholder: 'Enter max tokens (e.g., 4000)...', + mode: 'advanced', + condition: { field: 'memoryType', value: ['sliding_window_tokens'] }, + dependsOn: ['memoryType'], + }, + ], + tools: { + access: [], + }, + inputs: { + mode: { type: 'string', description: 'Execution mode: cloud 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)' }, + host: { type: 'string', description: 'SSH host (local mode)' }, + port: { type: 'number', description: 'SSH port (local mode)' }, + username: { type: 'string', description: 'SSH username (local mode)' }, + authMethod: { type: 'string', description: 'SSH authentication method (local mode)' }, + password: { type: 'string', description: 'SSH password (local mode)' }, + privateKey: { type: 'string', description: 'SSH private key (local mode)' }, + passphrase: { type: 'string', description: 'SSH key passphrase (local mode)' }, + repoPath: { type: 'string', description: 'Repository path on the target (local mode)' }, + tools: { type: 'json', description: 'Sim tools exposed to the agent (local mode)' }, + skills: { type: 'json', description: 'Selected skills configuration' }, + thinkingLevel: { type: 'string', description: 'Thinking level for the model' }, + memoryType: { type: 'string', description: 'Memory type for multi-turn conversations' }, + conversationId: { type: 'string', description: 'Conversation ID for memory' }, + slidingWindowSize: { type: 'string', description: 'Number of messages for sliding window' }, + slidingWindowTokens: { type: 'string', description: 'Max tokens for token-based window' }, + ...PROVIDER_CREDENTIAL_INPUTS, + }, + outputs: { + content: { type: 'string', description: 'Final agent message / run summary' }, + model: { type: 'string', description: 'Model used for the run' }, + changedFiles: { type: 'json', description: 'Files changed by the agent' }, + diff: { type: 'string', description: 'Unified diff of the changes' }, + prUrl: { + type: 'string', + description: 'URL of the opened pull request', + condition: CLOUD, + }, + branch: { + type: 'string', + description: 'Branch pushed with the changes', + condition: CLOUD, + }, + 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/blocks/registry.ts b/apps/sim/blocks/registry.ts index a03be20796..8ee9b75e7d 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -226,6 +226,7 @@ import { ParallelBlock, ParallelBlockMeta } from '@/blocks/blocks/parallel' import { PeopleDataLabsBlock, PeopleDataLabsBlockMeta } from '@/blocks/blocks/peopledatalabs' import { PerplexityBlock, PerplexityBlockMeta } from '@/blocks/blocks/perplexity' import { PersonaBlock, PersonaBlockMeta } from '@/blocks/blocks/persona' +import { PiBlock } from '@/blocks/blocks/pi' import { PineconeBlock, PineconeBlockMeta } from '@/blocks/blocks/pinecone' import { PipedriveBlock, PipedriveBlockMeta } from '@/blocks/blocks/pipedrive' import { PolymarketBlock, PolymarketBlockMeta } from '@/blocks/blocks/polymarket' @@ -530,6 +531,7 @@ const BLOCK_REGISTRY: Record = { peopledatalabs: PeopleDataLabsBlock, perplexity: PerplexityBlock, persona: PersonaBlock, + pi: PiBlock, pinecone: PineconeBlock, pipedrive: PipedriveBlock, polymarket: PolymarketBlock, diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 0d6b846aa5..a511f72923 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -457,6 +457,13 @@ export interface BlockConfig { } } hideFromToolbar?: boolean + /** + * tool-input only: tool categories the consuming block cannot execute. They + * stay visible in the picker but are greyed out with a tooltip rather than + * hidden. Block/integration tools always run via `executeTool`, so only the + * non-registry categories (`mcp`, `custom-tool`) can be marked unsupported. + */ + unsupportedToolTypes?: ('mcp' | 'custom-tool')[] triggers?: { enabled: boolean available: string[] // List of trigger IDs this block supports diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index 8fc80b1009..a803bdf8c2 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -15,6 +15,8 @@ import { getProviderModels, orderModelIdsByReleaseDate, } from '@/providers/models' +import { isPiSupportedProvider } from '@/providers/pi-providers' +import { getProviderFromModel } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' export const VERTEX_MODELS = getProviderModels('vertex') @@ -78,6 +80,23 @@ export function getModelOptions() { }) } +/** + * Model options filtered to providers the Pi Coding Agent can run (see + * {@link isPiSupportedProvider}), so the Pi block never offers a model that would + * error at execution. Uses the same `getProviderFromModel` resolution as the Pi + * handler, so the dropdown matches runtime behavior; unresolved/blacklisted + * models (which `getProviderFromModel` can throw on) are excluded. + */ +export function getPiModelOptions() { + return getModelOptions().filter((option) => { + try { + return isPiSupportedProvider(getProviderFromModel(option.id)) + } catch { + return false + } + }) +} + /** * Gets all dependency fields as a flat array. * Handles both simple array format and object format with all/any fields. diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index c920a30428..3bf3b5efa2 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -5288,6 +5288,28 @@ export function SmtpIcon(props: SVGProps) { ) } +export function PiIcon(props: SVGProps) { + return ( + + + + + + + ) +} + export function SshIcon(props: SVGProps) { return ( + +/** A resolved skill (name + full content) made available to Pi. */ +export interface PiSkill { + name: string + content: string +} + +/** SSH connection parameters for local mode (subset of the shared SSH config). */ +export type PiSshConnection = Pick< + SSHConnectionConfig, + 'host' | 'port' | 'username' | 'password' | 'privateKey' | 'passphrase' +> + +/** Result of invoking a tool Pi called. */ +export interface PiToolResult { + text: string + isError: boolean +} + +/** + * A tool exposed to Pi in a backend-neutral shape (the SSH file/bash tools and + * adapted Sim tools both use it). The local backend converts these into Pi + * `customTools`; keeping them Pi-SDK-free keeps this seam typed. + */ +export interface PiToolSpec { + name: string + description: string + parameters: Record + execute: (args: Record) => Promise +} + +interface PiRunBaseParams { + model: string + providerId: string + apiKey: string + isBYOK: boolean + task: string + thinkingLevel?: string + skills: PiSkill[] + initialMessages: PiMessage[] +} + +/** Parameters for a local (SSH) Pi run. */ +export interface PiLocalRunParams extends PiRunBaseParams { + mode: 'local' + ssh: PiSshConnection + repoPath: string + tools: PiToolSpec[] +} + +/** Parameters for a cloud (E2B) Pi run. */ +export interface PiCloudRunParams extends PiRunBaseParams { + mode: 'cloud' + owner: string + repo: string + githubToken: string + baseBranch?: string + branchName?: string + draft: boolean + prTitle?: string + prBody?: string +} + +export type PiRunParams = PiLocalRunParams | PiCloudRunParams + +/** Progress callbacks and cancellation passed into a backend run. */ +export interface PiRunContext { + onEvent: (event: PiEvent) => void + signal?: AbortSignal +} + +/** Final result of a Pi run. */ +export interface PiRunResult { + totals: PiRunTotals + changedFiles?: string[] + diff?: string + prUrl?: string + branch?: string +} + +/** A Pi execution backend. Implemented by the local (SSH) and cloud (E2B) runners. */ +export type PiBackendRun

= ( + params: P, + context: PiRunContext +) => Promise diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-backend.test.ts new file mode 100644 index 0000000000..43eeb3afad --- /dev/null +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -0,0 +1,237 @@ +/** + * @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 { PiCloudRunParams } from '@/executor/handlers/pi/backend' +import { runCloudPi } from '@/executor/handlers/pi/cloud-backend' + +function baseParams(overrides: Partial = {}): PiCloudRunParams { + return { + mode: 'cloud', + model: 'claude', + providerId: 'anthropic', + apiKey: 'sk-byok', + isBYOK: true, + task: 'do it', + skills: [], + initialMessages: [], + owner: 'octo', + repo: 'demo', + githubToken: 'ghp_secret', + branchName: 'feature-x', + draft: true, + ...overrides, + } +} + +describe('runCloudPi', () => { + beforeEach(() => { + vi.clearAllMocks() + mockProviderEnvVar.mockReturnValue('ANTHROPIC_API_KEY') + mockReadFile.mockResolvedValue('diff content') + mockExecuteTool.mockResolvedValue({ + success: true, + output: { metadata: { html_url: 'https://github.com/octo/demo/pull/1' } }, + }) + mockRun.mockImplementation( + (command: string, options: { onStdout?: (chunk: string) => void }) => { + if (command.includes('git clone')) { + return Promise.resolve({ + stdout: '__BASE_SHA__=abc123\n__DEFAULT_BRANCH__=main', + stderr: '', + exitCode: 0, + }) + } + if (command.includes('pi -p')) { + options.onStdout?.( + '{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"done"}}\n' + ) + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + if (command.includes('push')) { + return Promise.resolve({ stdout: '__PUSHED__=1', stderr: '', exitCode: 0 }) + } + return Promise.resolve({ + stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1', + stderr: '', + exitCode: 0, + }) + } + ) + }) + + it('isolates secrets per command: token only in clone/push, model key only in the Pi loop', async () => { + const onEvent = vi.fn() + await runCloudPi(baseParams(), { onEvent }) + + const [cloneCmd, cloneOpts] = mockRun.mock.calls[0] + const [piCmd, piOpts] = mockRun.mock.calls[1] + const [prepareCmd, prepareOpts] = mockRun.mock.calls[2] + const [pushCmd, pushOpts] = mockRun.mock.calls[3] + + expect(cloneCmd).toContain('git clone') + expect(cloneOpts.envs.GITHUB_TOKEN).toBe('ghp_secret') + expect(cloneOpts.envs.ANTHROPIC_API_KEY).toBeUndefined() + + expect(piCmd).toContain('pi -p') + expect(piCmd).toContain('--provider') + expect(piOpts.envs.ANTHROPIC_API_KEY).toBe('sk-byok') + expect(piOpts.envs.GITHUB_TOKEN).toBeUndefined() + expect(piOpts.envs.PI_MODEL).toBe('claude') + expect(piOpts.envs.PI_PROVIDER).toBe('anthropic') + + // PREPARE (add/commit/diff) must NOT carry the token: a repo-config-driven + // program the agent may have planted (clean filter, fsmonitor, textconv) runs + // on these commands and `core.hooksPath` does not stop it, so the credential + // must simply be absent. + expect(prepareCmd).toContain('add -A') + expect(prepareCmd).toContain('core.hooksPath=/dev/null') + expect(prepareOpts.envs.GITHUB_TOKEN).toBeUndefined() + expect(prepareOpts.envs.ANTHROPIC_API_KEY).toBeUndefined() + + // PUSH is the only token-bearing command, hardened against planted git-config + // program execution (hooks, credential.helper, fsmonitor). + expect(pushCmd).toContain('push') + expect(pushCmd).toContain('core.hooksPath=/dev/null') + expect(pushCmd).toContain('credential.helper=') + expect(pushCmd).toContain('core.fsmonitor=') + expect(pushOpts.envs.GITHUB_TOKEN).toBe('ghp_secret') + expect(pushOpts.envs.ANTHROPIC_API_KEY).toBeUndefined() + + expect(onEvent).toHaveBeenCalledWith({ type: 'text', text: 'done' }) + }) + + it('delivers the prompt and commit message via files, never the command line', async () => { + await runCloudPi(baseParams(), { onEvent: vi.fn() }) + + // Untrusted text is written through the sandbox FS API, not interpolated into a shell command. + expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-prompt.txt', 'PROMPT') + expect(mockWriteFile).toHaveBeenCalledWith('/workspace/pi-commit.txt', 'Pi: do it') + + const [piCmd, piOpts] = mockRun.mock.calls[1] + // Prompt arrives on stdin from a fixed path; never a CLI arg or env value. + expect(piCmd).toContain('< /workspace/pi-prompt.txt') + expect(piCmd).not.toContain('PROMPT') + expect(piOpts.envs.PI_TASK).toBeUndefined() + + const [prepareCmd, prepareOpts] = mockRun.mock.calls[2] + // Commit message is read from a file, not passed as -m "...". + expect(prepareCmd).toContain('commit -F /workspace/pi-commit.txt') + expect(prepareCmd).not.toContain('commit -m') + expect(prepareOpts.envs.COMMIT_MSG).toBeUndefined() + }) + + it('opens a PR from the pushed branch and returns its URL', async () => { + const result = await runCloudPi(baseParams(), { onEvent: vi.fn() }) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'github_create_pr', + expect.objectContaining({ + owner: 'octo', + repo: 'demo', + head: 'feature-x', + base: 'main', + draft: true, + apiKey: 'ghp_secret', + }) + ) + expect(result.prUrl).toBe('https://github.com/octo/demo/pull/1') + expect(result.branch).toBe('feature-x') + expect(result.changedFiles).toEqual(['src/x.ts']) + expect(result.diff).toBe('diff content') + }) + + it('skips the PR when nothing was pushed', async () => { + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + return Promise.resolve({ stdout: '__NO_CHANGES__=1', stderr: '', exitCode: 0 }) + }) + + const result = await runCloudPi(baseParams(), { onEvent: vi.fn() }) + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(result.prUrl).toBeUndefined() + // No changes => the token-bearing push command must never run. + expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false) + }) + + it('rejects a non-BYOK key (no Sim-owned key in the sandbox)', async () => { + await expect(runCloudPi(baseParams({ isBYOK: false }), { onEvent: vi.fn() })).rejects.toThrow( + /BYOK/ + ) + }) + + it('rejects providers that cannot run via a single key', async () => { + mockProviderEnvVar.mockReturnValue(null) + await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/not supported/) + }) + + it('fails when the Pi CLI exits non-zero (no PR opened)', async () => { + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: 'model not found', exitCode: 1 }) + } + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + }) + await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/Pi agent failed/) + expect(mockExecuteTool).not.toHaveBeenCalled() + }) + + it('surfaces the real git push error when the push fails, with the token scrubbed', async () => { + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + if (command.includes('push')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 1 }) + } + return Promise.resolve({ + stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1', + stderr: '', + exitCode: 0, + }) + }) + // The push step writes its stderr to a file; the backend reads + scrubs it. + mockReadFile.mockResolvedValue( + "remote: Permission to octo/demo.git denied.\nfatal: unable to access 'https://x-access-token:ghp_secret@github.com/octo/demo.git/': 403" + ) + + const error = (await runCloudPi(baseParams(), { onEvent: vi.fn() }).catch((e) => e)) as Error + expect(error.message).toMatch(/git push failed/) + expect(error.message).toMatch(/Permission to octo\/demo\.git denied/) + expect(error.message).not.toContain('ghp_secret') + expect(mockExecuteTool).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud-backend.ts new file mode 100644 index 0000000000..6b844b4c30 --- /dev/null +++ b/apps/sim/executor/handlers/pi/cloud-backend.ts @@ -0,0 +1,330 @@ +/** + * Cloud-mode 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 + * BYOK model key only. The model key is never a Sim-owned hosted key (S1). + * + * Untrusted text (the assembled prompt, which folds in workspace-shared skills + * and memory, and the commit message) is never placed on a shell command line. + * It is written into sandbox files via the E2B filesystem API and read back from + * fixed paths (Pi's prompt on stdin, `git commit -F `), so a collaborator- + * authored skill cannot inject shell into the Pi step where the model key lives. + */ + +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 { buildPiPrompt } from '@/executor/handlers/pi/context' +import { + applyPiEvent, + createPiTotals, + type PiRunTotals, + parseJsonLine, +} from '@/executor/handlers/pi/events' +import { mapThinkingLevel, providerApiKeyEnvVar } from '@/executor/handlers/pi/keys' +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 +const PR_SUMMARY_MAX = 2000 +const PUSH_ERROR_MAX = 1000 + +const CLONE_SCRIPT = `set -e +rm -rf ${REPO_DIR} +git clone "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" ${REPO_DIR} +cd ${REPO_DIR} +if [ -n "$BASE_BRANCH" ]; then git checkout "$BASE_BRANCH"; fi +git rev-parse HEAD | sed "s/^/__BASE_SHA__=/" +DEFAULT_BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed "s#^origin/##" || true) +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` +// (on add/diff), and `diff.external`/textconv (on diff). The untrusted Pi loop can +// plant `.gitattributes` + `.git/config` to run code during these. Keeping the +// token out of PREPARE's env means a planted program has no credential to steal; +// hooks are disabled too as defense-in-depth. Commit runs unconditionally +// (`|| true` tolerates an empty commit); the push decision is gated on HEAD +// advancing past base, so commits the agent made itself are still pushed. +const PREPARE_SCRIPT = `cd ${REPO_DIR} +git -c core.hooksPath=/dev/null add -A +git -c core.hooksPath=/dev/null -c user.email="pi@sim.ai" -c user.name="Sim Pi Agent" commit -F ${COMMIT_MSG_PATH} >/dev/null 2>&1 || true +git diff --name-only "$BASE_SHA" HEAD | sed "s/^/__CHANGED__=/" +git diff "$BASE_SHA" HEAD > ${DIFF_PATH} 2>/dev/null || true +if git diff --quiet "$BASE_SHA" HEAD; then echo "__NO_CHANGES__=1"; else echo "__NEEDS_PUSH__=1"; fi` + +// The only token-bearing command. The agent-planted `.git/config` is still active, +// so neutralize every config key that could run a program during push: hooks +// (pre-push), `credential.helper` (runs during auth), and `core.fsmonitor`. +// Filters/textconv don't run on push (no checkout/add/diff here). +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) + : 'Automated changes by the Pi Coding Agent.' + return `## Task\n\n${task}\n\n## Summary\n\n${summary}` +} + +/** The commit message and PR title share one default, derived from the PR title or task. */ +function defaultTitle(params: PiCloudRunParams): string { + return params.prTitle?.trim() || truncate(`Pi: ${params.task}`, COMMIT_TITLE_MAX) +} + +async function openPullRequest( + params: PiCloudRunParams, + branch: string, + detectedBase: string | undefined, + totals: PiRunTotals +): Promise { + const base = params.baseBranch?.trim() || detectedBase + if (!base) { + throw new Error( + `Branch ${branch} pushed, but the base branch could not be determined — set "Base Branch" on the block and re-run.` + ) + } + const title = defaultTitle(params) + const body = params.prBody?.trim() || buildPrBody(params.task, totals.finalText) + + const result = await executeTool('github_create_pr', { + owner: params.owner, + repo: params.repo, + title, + head: branch, + base, + body, + draft: params.draft, + apiKey: params.githubToken, + }) + + if (!result.success) { + throw new Error( + `Branch ${branch} pushed but PR creation failed: ${result.error ?? 'unknown error'}` + ) + } + + const output = result.output as { metadata?: { html_url?: string } } | undefined + return output?.metadata?.html_url +} + +export const runCloudPi: 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 branch = params.branchName?.trim() || `pi/${generateShortId(8)}` + const commitMessage = defaultTitle(params) + const prompt = buildPiPrompt({ + skills: params.skills, + initialMessages: params.initialMessages, + task: params.task, + }) + const totals = createPiTotals() + const thinking = mapThinkingLevel(params.thinkingLevel) ?? 'medium' + + return withPiSandbox(async (runner) => { + try { + const clone = await raceAbort( + runner.run(CLONE_SCRIPT, { + envs: { + GITHUB_TOKEN: params.githubToken, + REPO_OWNER: params.owner, + REPO_NAME: params.repo, + BASE_BRANCH: params.baseBranch?.trim() ?? '', + BRANCH: branch, + }, + timeoutMs: CLONE_TIMEOUT_MS, + }), + context.signal + ) + if (clone.exitCode !== 0) { + throw new Error( + `git clone failed: ${scrubGitSecrets(clone.stderr || clone.stdout || 'unknown error', params.githubToken)}` + ) + } + const baseSha = extractMarkerValues(clone.stdout, '__BASE_SHA__=')[0] + if (!baseSha) { + throw new Error('Clone did not report a base commit') + } + const detectedBase = extractMarkerValues(clone.stdout, '__DEFAULT_BRANCH__=')[0] + + // Deliver the prompt as a file (read back on Pi's stdin), not a CLI + // arg/env, so its skill/memory content can't be parsed by the shell that + // launches the Pi loop. + await runner.writeFile(PROMPT_PATH, prompt) + + 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() + ) + } + + // Same rationale as the prompt: keep the commit message off the command line. + await runner.writeFile(COMMIT_MSG_PATH, commitMessage) + + // PREPARE stages, commits, and diffs WITHOUT the GitHub token in scope, so a + // repo-config-driven program the agent may have planted can't exfiltrate it. + const prepare = await raceAbort( + runner.run(PREPARE_SCRIPT, { + envs: { BASE_SHA: baseSha }, + timeoutMs: FINALIZE_TIMEOUT_MS, + }), + context.signal + ) + const changedFiles = extractMarkerValues(prepare.stdout, '__CHANGED__=') + // Push only when PREPARE explicitly reports HEAD advanced. If it emitted + // neither marker (a git error), treat it as no-op rather than pushing. + const needsPush = prepare.stdout.includes('__NEEDS_PUSH__=1') + + let diff: string | undefined + try { + const raw = await runner.readFile(DIFF_PATH) + diff = + raw.length > MAX_DIFF_BYTES ? `${raw.slice(0, MAX_DIFF_BYTES)}\n[diff truncated]` : raw + } catch { + diff = undefined + } + + if (!needsPush) { + logger.info('Pi cloud run produced no changes to push', { + owner: params.owner, + repo: params.repo, + }) + return { totals, changedFiles, diff } + } + + // PUSH is the only command that carries the token, hardened against any + // git-config program execution the agent may have planted. + const push = await raceAbort( + runner.run(PUSH_SCRIPT, { + envs: { + GITHUB_TOKEN: params.githubToken, + REPO_OWNER: params.owner, + REPO_NAME: params.repo, + BRANCH: branch, + }, + timeoutMs: FINALIZE_TIMEOUT_MS, + }), + context.signal + ) + if (!push.stdout.includes('__PUSHED__=1')) { + let reason = push.stderr?.trim() + try { + const pushErr = (await runner.readFile(PUSH_ERR_PATH)).trim() + if (pushErr) reason = pushErr + } catch {} + const scrubbed = scrubGitSecrets(reason || 'unknown error', params.githubToken) + throw new Error(`git push failed: ${truncate(scrubbed, PUSH_ERROR_MAX)}`) + } + + const prUrl = await openPullRequest(params, branch, detectedBase, totals) + return { totals, changedFiles, diff, prUrl, branch } + } catch (error) { + // Aborts propagate as errors so a cancelled/timed-out run is not reported as + // success and no partial memory turn is persisted (local mode mirrors this). + if (context.signal?.aborted) { + logger.info('Pi cloud run aborted', { owner: params.owner, repo: params.repo }) + } + throw error + } + }) +} diff --git a/apps/sim/executor/handlers/pi/context.ts b/apps/sim/executor/handlers/pi/context.ts new file mode 100644 index 0000000000..cd9f99e2e8 --- /dev/null +++ b/apps/sim/executor/handlers/pi/context.ts @@ -0,0 +1,110 @@ +/** + * Reuses the Agent block's skills and memory subsystems for Pi runs. Skills + * resolve to full `{ name, content }` entries (so a backend can surface them as + * Pi skills), and multi-turn memory goes through the shared `memoryService` + * keyed by `memoryType`/`conversationId` — seeding the run and persisting the + * user task plus the agent's final message. + */ + +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { memoryService } from '@/executor/handlers/agent/memory' +import { resolveSkillContentById } from '@/executor/handlers/agent/skills-resolver' +import type { AgentInputs, Message, SkillInput } from '@/executor/handlers/agent/types' +import type { PiMessage, PiSkill } from '@/executor/handlers/pi/backend' +import type { ExecutionContext } from '@/executor/types' + +const logger = createLogger('PiContext') + +/** Memory configuration — the Agent block's memory input fields, reused as-is. */ +export type PiMemoryConfig = Pick< + AgentInputs, + 'memoryType' | 'conversationId' | 'slidingWindowSize' | 'slidingWindowTokens' | 'model' +> + +function isMemoryEnabled(config: PiMemoryConfig): boolean { + return !!config.memoryType && config.memoryType !== 'none' +} + +/** Resolves selected skill inputs to full `{ name, content }` entries for Pi. */ +export async function resolvePiSkills( + skillInputs: unknown, + workspaceId: string | undefined +): Promise { + if (!Array.isArray(skillInputs) || !workspaceId) return [] + + const skills: PiSkill[] = [] + for (const input of skillInputs as SkillInput[]) { + if (!input?.skillId) continue + try { + const resolved = await resolveSkillContentById(input.skillId, workspaceId) + if (resolved) skills.push({ name: resolved.name, content: resolved.content }) + } catch (error) { + logger.warn('Failed to resolve skill for Pi', { + skillId: input.skillId, + error: getErrorMessage(error), + }) + } + } + return skills +} + +/** Loads prior conversation messages to seed the Pi run. */ +export async function loadPiMemory( + ctx: ExecutionContext, + config: PiMemoryConfig +): Promise { + if (!isMemoryEnabled(config)) return [] + try { + const messages = await memoryService.fetchMemoryMessages(ctx, config) + return messages.map((message: Message) => ({ role: message.role, content: message.content })) + } catch (error) { + logger.warn('Failed to load Pi memory', { error: getErrorMessage(error) }) + return [] + } +} + +/** Builds the prompt preamble (skills + prior memory) followed by the task. */ +export function buildPiPrompt(input: { + skills: PiSkill[] + initialMessages: PiMessage[] + task: string +}): string { + const parts: string[] = [] + + if (input.skills.length > 0) { + parts.push('# Available skills') + for (const skill of input.skills) { + parts.push(`## ${skill.name}\n${skill.content}`) + } + } + + if (input.initialMessages.length > 0) { + parts.push('# Prior conversation') + for (const message of input.initialMessages) { + parts.push(`${message.role}: ${message.content}`) + } + } + + parts.push('# Task') + parts.push(input.task) + return parts.join('\n\n') +} + +/** Persists the user task and the agent's final message to memory. */ +export async function appendPiMemory( + ctx: ExecutionContext, + config: PiMemoryConfig, + task: string, + finalText: string +): Promise { + if (!isMemoryEnabled(config)) return + try { + await memoryService.appendToMemory(ctx, config, { role: 'user', content: task }) + if (finalText) { + await memoryService.appendToMemory(ctx, config, { role: 'assistant', content: finalText }) + } + } catch (error) { + logger.warn('Failed to append Pi memory', { error: getErrorMessage(error) }) + } +} diff --git a/apps/sim/executor/handlers/pi/events.test.ts b/apps/sim/executor/handlers/pi/events.test.ts new file mode 100644 index 0000000000..c34e41549a --- /dev/null +++ b/apps/sim/executor/handlers/pi/events.test.ts @@ -0,0 +1,116 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + applyPiEvent, + createPiTotals, + normalizePiEvent, + parseJsonLine, + streamTextForEvent, +} from '@/executor/handlers/pi/events' + +describe('normalizePiEvent', () => { + it('maps a text_delta message_update to a text event', () => { + expect( + normalizePiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'hello' }, + }) + ).toEqual({ type: 'text', text: 'hello' }) + }) + + it('maps a thinking_delta message_update to a thinking event', () => { + expect( + normalizePiEvent({ + type: 'message_update', + assistantMessageEvent: { type: 'thinking_delta', delta: 'hmm' }, + }) + ).toEqual({ type: 'thinking', text: 'hmm' }) + }) + + it('maps tool execution start and end', () => { + expect(normalizePiEvent({ type: 'tool_execution_start', toolName: 'bash' })).toEqual({ + type: 'tool_start', + toolName: 'bash', + }) + expect( + normalizePiEvent({ type: 'tool_execution_end', toolName: 'bash', isError: true }) + ).toEqual({ + type: 'tool_end', + toolName: 'bash', + isError: true, + }) + }) + + it('extracts usage from turn_end via message.usage and direct usage', () => { + expect( + normalizePiEvent({ type: 'turn_end', message: { usage: { input: 5, output: 7 } } }) + ).toEqual({ type: 'usage', inputTokens: 5, outputTokens: 7 }) + expect( + normalizePiEvent({ type: 'turn_end', usage: { prompt_tokens: 3, completion_tokens: 2 } }) + ).toEqual({ type: 'usage', inputTokens: 3, outputTokens: 2 }) + }) + + it('maps agent_end to final and error to error', () => { + expect(normalizePiEvent({ type: 'agent_end' })).toEqual({ type: 'final' }) + expect(normalizePiEvent({ type: 'error', error: 'boom' })).toEqual({ + type: 'error', + message: 'boom', + }) + }) + + it('returns other for unknown types and null for non-objects', () => { + expect(normalizePiEvent({ type: 'queue_update' })).toEqual({ type: 'other' }) + expect(normalizePiEvent('nope')).toBeNull() + expect(normalizePiEvent(null)).toBeNull() + }) +}) + +describe('parseJsonLine', () => { + it('parses a valid json line', () => { + expect(parseJsonLine('{"type":"agent_end"}')).toEqual({ type: 'final' }) + }) + + it('returns null for blank or malformed lines', () => { + expect(parseJsonLine(' ')).toBeNull() + expect(parseJsonLine('{not json')).toBeNull() + }) +}) + +describe('applyPiEvent', () => { + it('accumulates text, sums usage, records tool calls and errors', () => { + const totals = createPiTotals() + applyPiEvent(totals, { type: 'text', text: 'a' }) + applyPiEvent(totals, { type: 'text', text: 'b' }) + applyPiEvent(totals, { type: 'usage', inputTokens: 3, outputTokens: 4 }) + applyPiEvent(totals, { type: 'usage', inputTokens: 1, outputTokens: 1 }) + applyPiEvent(totals, { type: 'tool_end', toolName: 'read', isError: false }) + applyPiEvent(totals, { type: 'error', message: 'boom' }) + + expect(totals.finalText).toBe('ab') + expect(totals.inputTokens).toBe(4) + expect(totals.outputTokens).toBe(5) + expect(totals.toolCalls).toEqual([{ name: 'read', isError: false }]) + expect(totals.errorMessage).toBe('boom') + }) + + it('uses final text only when no streamed text was seen', () => { + const empty = createPiTotals() + applyPiEvent(empty, { type: 'final', text: 'fallback' }) + expect(empty.finalText).toBe('fallback') + + const streamed = createPiTotals() + applyPiEvent(streamed, { type: 'text', text: 'streamed' }) + applyPiEvent(streamed, { type: 'final', text: 'fallback' }) + expect(streamed.finalText).toBe('streamed') + }) +}) + +describe('streamTextForEvent', () => { + it('returns text for text events and null otherwise', () => { + expect(streamTextForEvent({ type: 'text', text: 'x' })).toBe('x') + expect(streamTextForEvent({ type: 'thinking', text: 'x' })).toBeNull() + expect(streamTextForEvent({ type: 'final' })).toBeNull() + }) +}) diff --git a/apps/sim/executor/handlers/pi/events.ts b/apps/sim/executor/handlers/pi/events.ts new file mode 100644 index 0000000000..3686a23eb5 --- /dev/null +++ b/apps/sim/executor/handlers/pi/events.ts @@ -0,0 +1,160 @@ +/** + * Normalization layer for the Pi agent event stream. Both backends produce the + * same logical events — the local backend via the SDK `session.subscribe` + * callback, the cloud backend via `pi --mode json` stdout lines — so this module + * maps either source into a single {@link PiEvent} union and accumulates the + * run totals (final text, token usage, tool calls) the handler reports. + */ + +/** A single normalized event emitted during a Pi run. */ +export type PiEvent = + | { type: 'text'; text: string } + | { type: 'thinking'; text: string } + | { type: 'tool_start'; toolName: string } + | { type: 'tool_end'; toolName: string; isError: boolean } + | { type: 'usage'; inputTokens: number; outputTokens: number } + | { type: 'final'; text?: string } + | { type: 'error'; message: string } + | { type: 'other' } + +/** A tool invocation observed during the run. */ +export interface PiToolCallRecord { + name: string + isError?: boolean +} + +/** Running totals accumulated across a Pi run. */ +export interface PiRunTotals { + finalText: string + inputTokens: number + outputTokens: number + toolCalls: PiToolCallRecord[] + errorMessage?: string +} + +/** Creates an empty totals accumulator. */ +export function createPiTotals(): PiRunTotals { + return { finalText: '', inputTokens: 0, outputTokens: 0, toolCalls: [] } +} + +/** + * Folds a normalized event into the totals. Text deltas accumulate into + * `finalText`; usage events sum (Pi reports per-turn usage on `turn_end`). + */ +export function applyPiEvent(totals: PiRunTotals, event: PiEvent): void { + switch (event.type) { + case 'text': + totals.finalText += event.text + break + case 'final': + if (event.text && totals.finalText.length === 0) { + totals.finalText = event.text + } + break + case 'usage': + totals.inputTokens += event.inputTokens + totals.outputTokens += event.outputTokens + break + case 'tool_end': + totals.toolCalls.push({ name: event.toolName, isError: event.isError }) + break + case 'error': + totals.errorMessage = event.message + break + default: + break + } +} + +/** Returns the text to enqueue onto the content stream for an event, if any. */ +export function streamTextForEvent(event: PiEvent): string | null { + return event.type === 'text' ? event.text : null +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function asNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} + +/** + * Extracts token usage from an event, tolerating the field names Pi and common + * provider payloads use (`input`/`output`, `inputTokens`/`outputTokens`, + * `prompt_tokens`/`completion_tokens`), checked on the event and on a nested + * `message`/`usage` object. + */ +function extractUsage( + ev: Record +): { inputTokens: number; outputTokens: number } | null { + const candidates: Array> = [] + const direct = asRecord(ev.usage) + if (direct) candidates.push(direct) + const message = asRecord(ev.message) + if (message) { + const messageUsage = asRecord(message.usage) + if (messageUsage) candidates.push(messageUsage) + } + + for (const usage of candidates) { + const input = + asNumber(usage.input) || asNumber(usage.inputTokens) || asNumber(usage.prompt_tokens) + const output = + asNumber(usage.output) || asNumber(usage.outputTokens) || asNumber(usage.completion_tokens) + if (input > 0 || output > 0) { + return { inputTokens: input, outputTokens: output } + } + } + + return null +} + +/** Normalizes a raw Pi/SDK event object into a {@link PiEvent}. */ +export function normalizePiEvent(raw: unknown): PiEvent | null { + const ev = asRecord(raw) + if (!ev) return null + + switch (asString(ev.type)) { + case 'message_update': { + const assistantEvent = asRecord(ev.assistantMessageEvent) + const deltaType = assistantEvent ? asString(assistantEvent.type) : '' + const delta = assistantEvent ? asString(assistantEvent.delta) : '' + if (deltaType === 'text_delta') return { type: 'text', text: delta } + if (deltaType === 'thinking_delta') return { type: 'thinking', text: delta } + return { type: 'other' } + } + case 'tool_execution_start': + return { type: 'tool_start', toolName: asString(ev.toolName) } + case 'tool_execution_end': + return { type: 'tool_end', toolName: asString(ev.toolName), isError: ev.isError === true } + case 'turn_end': { + const usage = extractUsage(ev) + return usage ? { type: 'usage', ...usage } : { type: 'other' } + } + case 'agent_end': + return { type: 'final' } + case 'error': + return { + type: 'error', + message: asString(ev.error) || asString(ev.message) || 'Pi run failed', + } + default: + return { type: 'other' } + } +} + +/** Parses one `pi --mode json` stdout line into a {@link PiEvent}. */ +export function parseJsonLine(line: string): PiEvent | null { + const trimmed = line.trim() + if (!trimmed) return null + try { + return normalizePiEvent(JSON.parse(trimmed)) + } catch { + return null + } +} diff --git a/apps/sim/executor/handlers/pi/keys.test.ts b/apps/sim/executor/handlers/pi/keys.test.ts new file mode 100644 index 0000000000..17b407cc0a --- /dev/null +++ b/apps/sim/executor/handlers/pi/keys.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetApiKeyWithBYOK, + mockGetBYOKKey, + mockGetProviderFromModel, + mockCalculateCost, + mockShouldBill, + mockResolveVertex, +} = vi.hoisted(() => ({ + mockGetApiKeyWithBYOK: vi.fn(), + mockGetBYOKKey: vi.fn(), + mockGetProviderFromModel: vi.fn(), + mockCalculateCost: vi.fn(), + mockShouldBill: vi.fn(), + mockResolveVertex: vi.fn(), +})) + +vi.mock('@/lib/api-key/byok', () => ({ + getApiKeyWithBYOK: mockGetApiKeyWithBYOK, + getBYOKKey: mockGetBYOKKey, +})) +vi.mock('@/providers/utils', () => ({ + getProviderFromModel: mockGetProviderFromModel, + calculateCost: mockCalculateCost, + shouldBillModelUsage: mockShouldBill, +})) +vi.mock('@/executor/utils/vertex-credential', () => ({ + resolveVertexCredential: mockResolveVertex, +})) +vi.mock('@/lib/core/config/env-flags', () => ({ getCostMultiplier: () => 2 })) + +import { computePiCost, providerApiKeyEnvVar, resolvePiModelKey } from '@/executor/handlers/pi/keys' + +describe('providerApiKeyEnvVar', () => { + it('maps key-based providers and rejects unsupported ones', () => { + expect(providerApiKeyEnvVar('anthropic')).toBe('ANTHROPIC_API_KEY') + expect(providerApiKeyEnvVar('openai')).toBe('OPENAI_API_KEY') + expect(providerApiKeyEnvVar('vertex')).toBeNull() + expect(providerApiKeyEnvVar('bedrock')).toBeNull() + expect(providerApiKeyEnvVar('something-else')).toBeNull() + }) +}) + +describe('computePiCost', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns zero cost for BYOK keys without billing', () => { + expect(computePiCost('claude', 100, 200, true)).toEqual({ input: 0, output: 0, total: 0 }) + expect(mockCalculateCost).not.toHaveBeenCalled() + }) + + it('returns zero cost for non-billable models', () => { + mockShouldBill.mockReturnValue(false) + expect(computePiCost('local-model', 100, 200, false)).toEqual({ input: 0, output: 0, total: 0 }) + expect(mockCalculateCost).not.toHaveBeenCalled() + }) + + it('computes billed cost with the cost multiplier', () => { + mockShouldBill.mockReturnValue(true) + mockCalculateCost.mockReturnValue({ input: 1, output: 2, total: 3 }) + expect(computePiCost('claude', 10, 20, false)).toEqual({ input: 1, output: 2, total: 3 }) + expect(mockCalculateCost).toHaveBeenCalledWith('claude', 10, 20, false, 2, 2) + }) +}) + +describe('resolvePiModelKey', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves Vertex credentials when the provider is vertex', async () => { + mockGetProviderFromModel.mockReturnValue('vertex') + mockResolveVertex.mockResolvedValue('vertex-token') + + const result = await resolvePiModelKey({ + model: 'gemini-pro', + mode: 'local', + userId: 'user-1', + vertexCredential: 'cred-1', + }) + + expect(result).toEqual({ providerId: 'vertex', apiKey: 'vertex-token', isBYOK: true }) + expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() + }) + + it('local mode resolves keys through getApiKeyWithBYOK (hosted keys allowed)', async () => { + mockGetProviderFromModel.mockReturnValue('anthropic') + mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-test', isBYOK: false }) + + const result = await resolvePiModelKey({ + model: 'claude', + mode: 'local', + workspaceId: 'ws-1', + apiKey: 'sk-test', + }) + + expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-test', isBYOK: false }) + expect(mockGetApiKeyWithBYOK).toHaveBeenCalledWith('anthropic', 'claude', 'ws-1', 'sk-test') + }) + + it('cloud mode uses the block API Key field directly as a BYOK key', async () => { + mockGetProviderFromModel.mockReturnValue('anthropic') + + const result = await resolvePiModelKey({ + model: 'claude', + mode: 'cloud', + workspaceId: 'ws-1', + apiKey: 'sk-user', + }) + + expect(result).toEqual({ providerId: 'anthropic', apiKey: 'sk-user', isBYOK: true }) + expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() + expect(mockGetBYOKKey).not.toHaveBeenCalled() + }) + + it('cloud mode falls back to a stored workspace key when the field is empty', async () => { + mockGetProviderFromModel.mockReturnValue('openai') + mockGetBYOKKey.mockResolvedValue({ apiKey: 'sk-workspace', isBYOK: true }) + + const result = await resolvePiModelKey({ + model: 'gpt-5', + mode: 'cloud', + workspaceId: 'ws-1', + }) + + expect(result).toEqual({ providerId: 'openai', apiKey: 'sk-workspace', isBYOK: true }) + expect(mockGetBYOKKey).toHaveBeenCalledWith('ws-1', 'openai') + expect(mockGetApiKeyWithBYOK).not.toHaveBeenCalled() + }) + + it('cloud mode rejects when no user key is available (never a hosted key)', async () => { + mockGetProviderFromModel.mockReturnValue('anthropic') + mockGetBYOKKey.mockResolvedValue(null) + + await expect( + resolvePiModelKey({ model: 'claude', mode: 'cloud', 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 new file mode 100644 index 0000000000..9d85eb8a4e --- /dev/null +++ b/apps/sim/executor/handlers/pi/keys.ts @@ -0,0 +1,127 @@ +/** + * 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. + */ + +import type { CreateAgentSessionOptions } from '@earendil-works/pi-coding-agent' +import { getApiKeyWithBYOK, getBYOKKey } from '@/lib/api-key/byok' +import { getCostMultiplier } from '@/lib/core/config/env-flags' +import { resolveVertexCredential } from '@/executor/utils/vertex-credential' +import { isPiSupportedProvider, type PiSupportedProvider } from '@/providers/pi-providers' +import { calculateCost, getProviderFromModel, shouldBillModelUsage } from '@/providers/utils' +import type { BYOKProviderId } from '@/tools/types' + +/** Resolved provider, key, and BYOK flag for a Pi run. */ +export interface PiKeyResolution { + providerId: string + apiKey: string + isBYOK: boolean +} + +interface ResolvePiModelKeyParams { + model: string + mode: 'cloud' | 'local' + workspaceId?: string + userId?: string + apiKey?: string + vertexCredential?: string +} + +/** 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']) + +/** Resolves the provider and a usable API key for the selected model. */ +export async function resolvePiModelKey(params: ResolvePiModelKeyParams): Promise { + const providerId = getProviderFromModel(params.model) + + if (providerId === 'vertex' && params.vertexCredential) { + const apiKey = await resolveVertexCredential( + params.vertexCredential, + params.userId, + 'vertex-pi' + ) + return { providerId, apiKey, isBYOK: true } + } + + // 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 (params.apiKey) { + return { providerId, apiKey: params.apiKey, isBYOK: true } + } + if (params.workspaceId && WORKSPACE_BYOK_PROVIDERS.has(providerId)) { + const byok = await getBYOKKey(params.workspaceId, providerId as BYOKProviderId) + if (byok) { + return { providerId, apiKey: byok.apiKey, isBYOK: true } + } + } + throw new Error( + WORKSPACE_BYOK_PROVIDERS.has(providerId) + ? 'Cloud mode requires your own provider API key (BYOK). Enter it in the API Key field, or store one in Settings > BYOK.' + : 'Cloud mode requires your own provider API key (BYOK). Enter it in the API Key field.' + ) + } + + const { apiKey, isBYOK } = await getApiKeyWithBYOK( + providerId, + params.model, + params.workspaceId, + params.apiKey + ) + return { providerId, apiKey, isBYOK } +} + +/** Run cost, zeroed for BYOK keys and models Sim does not bill. */ +export function computePiCost( + model: string, + inputTokens: number, + outputTokens: number, + isBYOK: boolean +) { + if (isBYOK || !shouldBillModelUsage(model)) { + return { input: 0, output: 0, total: 0 } + } + const multiplier = getCostMultiplier() + return calculateCost(model, inputTokens, outputTokens, false, multiplier, multiplier) +} + +/** + * Env var the Pi CLI reads each provider's key from in the cloud sandbox. Keyed + * by {@link PiSupportedProvider}, so this map and the shared support set (which + * also drives the block's model dropdown) cannot drift — adding a provider to the + * set forces adding its env var here. + */ +const PROVIDER_API_KEY_ENV_VARS: Record = { + anthropic: 'ANTHROPIC_API_KEY', + openai: 'OPENAI_API_KEY', + google: 'GEMINI_API_KEY', + xai: 'XAI_API_KEY', + deepseek: 'DEEPSEEK_API_KEY', + mistral: 'MISTRAL_API_KEY', + groq: 'GROQ_API_KEY', + cerebras: 'CEREBRAS_API_KEY', + openrouter: 'OPENROUTER_API_KEY', +} + +/** + * Env var name a provider's API key is exposed under for the Pi CLI in the cloud + * sandbox, or `null` when Pi cannot run the provider via a single key. The cloud + * backend rejects `null` providers with a clear error rather than guessing. + */ +export function providerApiKeyEnvVar(providerId: string): string | null { + return isPiSupportedProvider(providerId) ? PROVIDER_API_KEY_ENV_VARS[providerId] : null +} + +/** Maps a Sim thinking level to Pi's `ThinkingLevel` (shared by both backends). */ +export function mapThinkingLevel(level?: string): CreateAgentSessionOptions['thinkingLevel'] { + if (!level || level === 'none') return 'off' + if (level === 'max') return 'xhigh' + if (level === 'low' || level === 'medium' || level === 'high') return level + return undefined +} diff --git a/apps/sim/executor/handlers/pi/local-backend.ts b/apps/sim/executor/handlers/pi/local-backend.ts new file mode 100644 index 0000000000..c7582e341f --- /dev/null +++ b/apps/sim/executor/handlers/pi/local-backend.ts @@ -0,0 +1,187 @@ +/** + * Local-mode backend: runs the Pi harness embedded in Sim with its built-in + * tools disabled and replaced by SSH-backed file/bash tools (plus any adapted + * Sim tools), all over a single reused SSH connection. The provider key stays in + * Sim's process (injected via `authStorage.setRuntimeApiKey`); only file/bash + * operations cross to the target machine. + * + * The Pi SDK is imported dynamically and externalized from the bundle, mirroring + * how `@e2b/code-interpreter` is loaded, so the package is resolved at runtime. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { ModelRegistry, ToolDefinition } from '@earendil-works/pi-coding-agent' +import { createLogger } from '@sim/logger' +import type { PiBackendRun, PiLocalRunParams, PiToolSpec } from '@/executor/handlers/pi/backend' +import { buildPiPrompt } from '@/executor/handlers/pi/context' +import { applyPiEvent, createPiTotals, normalizePiEvent } from '@/executor/handlers/pi/events' +import { mapThinkingLevel } from '@/executor/handlers/pi/keys' +import { + buildSshToolSpecs, + captureRepoChanges, + openSshSession, +} from '@/executor/handlers/pi/ssh-tools' + +const logger = createLogger('PiLocalBackend') + +const MAX_DIFF_BYTES = 200_000 + +/** The Pi SDK module, loaded dynamically so it stays externalized from the bundle. */ +type PiSdk = typeof import('@earendil-works/pi-coding-agent') + +let sdkPromise: Promise | undefined + +function loadPiSdk(): Promise { + if (!sdkPromise) { + // A static specifier (not a variable) is required so Next's dependency tracer + // copies the package + its transitive deps into the standalone Docker output, + // the same way `@e2b/code-interpreter` is handled. Clear the cache on failure + // so a transient import error doesn't permanently break later local runs. + sdkPromise = import('@earendil-works/pi-coding-agent').catch((error) => { + sdkPromise = undefined + throw error + }) + } + return sdkPromise +} + +function toPiTool(sdk: PiSdk, spec: PiToolSpec): ToolDefinition { + return sdk.defineTool({ + name: spec.name, + label: spec.name, + description: spec.description, + // double-cast-allowed: Pi accepts a plain JSON Schema at runtime (pi-ai validation.js coerceWithJsonSchema); the static type requires a TypeBox TSchema + parameters: spec.parameters as unknown as ToolDefinition['parameters'], + execute: async (_toolCallId, params) => { + const result = await spec.execute(params as Record) + return { + content: [{ type: 'text', text: result.text }], + details: { isError: result.isError }, + } + }, + }) +} + +/** + * Builds a model definition for a provider Pi supports but whose bundled catalog + * doesn't list this exact id (e.g. a newer model Pi wires to a different + * provider). Mirrors the cloud CLI's passthrough: clone one of the provider's + * models as a template, swap in the requested id, and force reasoning when a + * thinking level is requested. Returns undefined only when the provider has no + * models at all, so even passthrough can't route it. + */ +function buildPiFallbackModel( + modelRegistry: ModelRegistry, + provider: string, + modelId: string, + thinkingLevel: ReturnType +) { + const providerModels = modelRegistry.getAll().filter((m) => m.provider === provider) + if (providerModels.length === 0) return undefined + const fallback = { ...providerModels[0], id: modelId, name: modelId } + return thinkingLevel && thinkingLevel !== 'off' ? { ...fallback, reasoning: true } : fallback +} + +export const runLocalPi: PiBackendRun = async (params, context) => { + // Isolate Pi resource discovery: an empty cwd/agentDir keeps DefaultResourceLoader + // from loading the Sim server's own .agents/skills, AGENTS.md, extensions, or settings. + const isolatedDir = await mkdtemp(join(tmpdir(), 'sim-pi-')) + const session = await openSshSession(params.ssh) + + try { + const sdk = await loadPiSdk() + + const authStorage = sdk.AuthStorage.create() + authStorage.setRuntimeApiKey(params.providerId, params.apiKey) + + const modelRegistry = sdk.ModelRegistry.create(authStorage) + const thinkingLevel = mapThinkingLevel(params.thinkingLevel) + // Parity with cloud: when the model isn't in Pi's bundled catalog under the + // resolved provider, pass it through on that provider instead of failing. + const model = + modelRegistry.find(params.providerId, params.model) ?? + buildPiFallbackModel(modelRegistry, params.providerId, params.model, thinkingLevel) + if (!model) { + throw new Error( + `Pi has no models for provider "${params.providerId}" (cannot run ${params.model})` + ) + } + + const specs = [...buildSshToolSpecs(session, params.repoPath), ...params.tools] + const customTools = specs.map((spec) => toPiTool(sdk, spec)) + + const { session: agentSession } = await sdk.createAgentSession({ + cwd: isolatedDir, + agentDir: isolatedDir, + model, + thinkingLevel, + noTools: 'builtin', + customTools, + authStorage, + modelRegistry, + sessionManager: sdk.SessionManager.inMemory(isolatedDir), + }) + + const totals = createPiTotals() + const unsubscribe = agentSession.subscribe((raw) => { + const event = normalizePiEvent(raw) + if (!event) return + applyPiEvent(totals, event) + context.onEvent(event) + }) + + const onAbort = () => { + void agentSession.abort() + } + if (context.signal?.aborted) { + onAbort() + } else { + context.signal?.addEventListener('abort', onAbort, { once: true }) + } + + try { + await agentSession.prompt( + buildPiPrompt({ + skills: params.skills, + initialMessages: params.initialMessages, + task: params.task, + }) + ) + } finally { + unsubscribe() + context.signal?.removeEventListener('abort', onAbort) + try { + agentSession.dispose() + } catch (error) { + logger.warn('Failed to dispose Pi session', { error }) + } + } + + // Aborts propagate as errors so a cancelled/timed-out run is not reported as + // success and no partial memory turn is persisted (cloud mode mirrors this). + // Pi resolves `prompt()` on abort rather than rejecting, so check explicitly. + if (context.signal?.aborted) { + throw new Error('Pi run aborted') + } + + // Pi has no error event; a failed run surfaces on the agent state. + if (agentSession.agent.state.errorMessage) { + totals.errorMessage = agentSession.agent.state.errorMessage + return { totals } + } + + // Local mode edits in place (no PR), so report what changed via the repo's + // working-tree diff over the same SSH session. + const { changedFiles, diff } = await captureRepoChanges( + session, + params.repoPath, + MAX_DIFF_BYTES + ) + return { totals, changedFiles, diff } + } finally { + session.close() + await rm(isolatedDir, { recursive: true, force: true }).catch(() => {}) + } +} diff --git a/apps/sim/executor/handlers/pi/pi-handler.test.ts b/apps/sim/executor/handlers/pi/pi-handler.test.ts new file mode 100644 index 0000000000..3e1f951f64 --- /dev/null +++ b/apps/sim/executor/handlers/pi/pi-handler.test.ts @@ -0,0 +1,153 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRunLocal, mockRunCloud, mockResolveKey } = vi.hoisted(() => ({ + mockRunLocal: vi.fn(), + mockRunCloud: vi.fn(), + mockResolveKey: vi.fn(), +})) + +vi.mock('@/executor/handlers/pi/keys', () => ({ + resolvePiModelKey: mockResolveKey, + computePiCost: () => ({ input: 0, output: 0, total: 0 }), +})) +vi.mock('@/executor/handlers/pi/context', () => ({ + resolvePiSkills: vi.fn().mockResolvedValue([]), + loadPiMemory: vi.fn().mockResolvedValue([]), + appendPiMemory: vi.fn().mockResolvedValue(undefined), +})) +vi.mock('@/executor/handlers/pi/sim-tools', () => ({ + buildSimToolSpecs: vi.fn().mockResolvedValue([]), +})) +vi.mock('@/executor/handlers/pi/local-backend', () => ({ runLocalPi: mockRunLocal })) +vi.mock('@/executor/handlers/pi/cloud-backend', () => ({ runCloudPi: mockRunCloud })) +vi.mock('@/blocks/utils', () => ({ + parseOptionalNumberInput: (value: unknown) => { + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : undefined + }, +})) + +import { PiBlockHandler } from '@/executor/handlers/pi/pi-handler' +import type { ExecutionContext, StreamingExecution } from '@/executor/types' +import type { SerializedBlock } from '@/serializer/types' + +const block = { id: 'blk', metadata: { id: 'pi' } } as unknown as SerializedBlock + +function ctx(overrides: Partial = {}): ExecutionContext { + return { + workflowId: 'wf', + workspaceId: 'ws', + userId: 'user', + ...overrides, + } as ExecutionContext +} + +function localInputs(extra: Record = {}) { + return { + mode: 'local', + task: 'do the thing', + model: 'claude', + host: 'box.example.com', + username: 'deploy', + authMethod: 'password', + password: 'pw', + repoPath: '/srv/repo', + ...extra, + } +} + +describe('PiBlockHandler', () => { + const handler = new PiBlockHandler() + + beforeEach(() => { + vi.clearAllMocks() + mockResolveKey.mockResolvedValue({ providerId: 'anthropic', apiKey: 'k', isBYOK: true }) + mockRunLocal.mockResolvedValue({ + totals: { finalText: 'hi', inputTokens: 1, outputTokens: 2, toolCalls: [] }, + }) + mockRunCloud.mockResolvedValue({ + totals: { finalText: 'done', inputTokens: 0, outputTokens: 0, toolCalls: [] }, + prUrl: 'https://github.com/o/r/pull/1', + branch: 'pi/abc', + changedFiles: ['a.ts'], + diff: 'diff', + }) + }) + + it('canHandle matches the pi block type', () => { + expect(handler.canHandle(block)).toBe(true) + expect( + handler.canHandle({ id: 'x', metadata: { id: 'agent' } } as unknown as SerializedBlock) + ).toBe(false) + }) + + it('throws when the task is missing', async () => { + await expect(handler.execute(ctx(), block, { mode: 'local', task: '' })).rejects.toThrow(/Task/) + }) + + 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() + const params = mockRunLocal.mock.calls[0][0] + expect(params.mode).toBe('local') + expect(params.ssh.host).toBe('box.example.com') + expect(params.repoPath).toBe('/srv/repo') + expect((output as Record).content).toBe('hi') + }) + + it('routes cloud mode to the cloud backend and surfaces PR output', async () => { + const output = (await handler.execute(ctx(), block, { + mode: 'cloud', + task: 'do it', + model: 'claude', + owner: 'o', + repo: 'r', + githubToken: 'ghp', + })) as Record + expect(mockRunCloud).toHaveBeenCalledTimes(1) + expect(output.prUrl).toBe('https://github.com/o/r/pull/1') + expect(output.branch).toBe('pi/abc') + }) + + it('requires SSH fields in local mode', async () => { + await expect( + handler.execute(ctx(), block, { mode: 'local', task: 'x', model: 'claude', host: 'h' }) + ).rejects.toThrow(/Local mode requires/) + }) + + it('requires repo + token in cloud mode', async () => { + await expect( + handler.execute(ctx(), block, { mode: 'cloud', task: 'x', model: 'claude', owner: 'o' }) + ).rejects.toThrow(/Cloud 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' }) + return { totals: { finalText: 'streamed', inputTokens: 0, outputTokens: 0, toolCalls: [] } } + }) + + const result = (await handler.execute( + ctx({ stream: true, selectedOutputs: ['blk'] }), + block, + localInputs() + )) as StreamingExecution + + expect('stream' in result).toBe(true) + + const reader = result.stream.getReader() + const decoder = new TextDecoder() + let text = '' + for (;;) { + const { done, value } = await reader.read() + if (done) break + text += decoder.decode(value) + } + expect(text).toContain('streamed') + expect(result.execution.output.content).toBe('streamed') + }) +}) diff --git a/apps/sim/executor/handlers/pi/pi-handler.ts b/apps/sim/executor/handlers/pi/pi-handler.ts new file mode 100644 index 0000000000..961859c712 --- /dev/null +++ b/apps/sim/executor/handlers/pi/pi-handler.ts @@ -0,0 +1,262 @@ +/** + * Executor handler for the Pi Coding Agent block. Resolves the model key, + * skills, and memory, selects a backend by `mode`, and runs it — streaming the + * agent's text to the client when the block is selected for streaming output, + * otherwise returning a plain block output. The handler depends only on the + * {@link PiBackendRun} seam and never reaches into backend internals. + */ + +import { createLogger } from '@sim/logger' +import type { BlockOutput } from '@/blocks/types' +import { parseOptionalNumberInput } from '@/blocks/utils' +import { BlockType } from '@/executor/constants' +import type { + PiBackendRun, + PiCloudRunParams, + PiLocalRunParams, + PiRunParams, + PiRunResult, +} from '@/executor/handlers/pi/backend' +import { runCloudPi } from '@/executor/handlers/pi/cloud-backend' +import { + appendPiMemory, + loadPiMemory, + type PiMemoryConfig, + resolvePiSkills, +} from '@/executor/handlers/pi/context' +import { streamTextForEvent } from '@/executor/handlers/pi/events' +import { computePiCost, resolvePiModelKey } from '@/executor/handlers/pi/keys' +import { runLocalPi } from '@/executor/handlers/pi/local-backend' +import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools' +import type { + BlockHandler, + ExecutionContext, + NormalizedBlockOutput, + StreamingExecution, +} from '@/executor/types' +import type { SerializedBlock } from '@/serializer/types' + +const logger = createLogger('PiBlockHandler') +const DEFAULT_MODEL = 'claude-sonnet-4-6' + +function asOptString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed ? trimmed : undefined +} + +function asRawString(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined +} + +export class PiBlockHandler implements BlockHandler { + canHandle(block: SerializedBlock): boolean { + return block.metadata?.id === BlockType.PI + } + + async execute( + ctx: ExecutionContext, + block: SerializedBlock, + inputs: Record + ): Promise { + const task = asOptString(inputs.task) + if (!task) throw new Error('Task is required') + const model = asOptString(inputs.model) ?? DEFAULT_MODEL + + // 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') { + throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`) + } + const mode: 'cloud' | 'local' = inputs.mode + + const { providerId, apiKey, isBYOK } = await resolvePiModelKey({ + model, + mode, + workspaceId: ctx.workspaceId, + userId: ctx.userId, + apiKey: asRawString(inputs.apiKey), + vertexCredential: asOptString(inputs.vertexCredential), + }) + + const skills = await resolvePiSkills(inputs.skills, ctx.workspaceId) + const memoryConfig: PiMemoryConfig = { + memoryType: asOptString(inputs.memoryType) as PiMemoryConfig['memoryType'], + conversationId: asOptString(inputs.conversationId), + slidingWindowSize: asOptString(inputs.slidingWindowSize), + slidingWindowTokens: asOptString(inputs.slidingWindowTokens), + model, + } + const initialMessages = await loadPiMemory(ctx, memoryConfig) + + const base = { + model, + providerId, + apiKey, + isBYOK, + task, + thinkingLevel: asOptString(inputs.thinkingLevel), + skills, + initialMessages, + } + + if (mode === 'local') { + const host = asOptString(inputs.host) + const username = asOptString(inputs.username) + const repoPath = asOptString(inputs.repoPath) + if (!host || !username || !repoPath) { + throw new Error('Local mode requires host, username, and repository path') + } + const usePrivateKey = inputs.authMethod === 'privateKey' + const port = parseOptionalNumberInput(inputs.port, 'port', { integer: true, min: 1 }) ?? 22 + const tools = await buildSimToolSpecs(ctx, inputs.tools) + const params: PiLocalRunParams = { + ...base, + mode: 'local', + repoPath, + tools, + ssh: { + host, + port, + username, + password: usePrivateKey ? undefined : asRawString(inputs.password), + privateKey: usePrivateKey ? asRawString(inputs.privateKey) : undefined, + passphrase: usePrivateKey ? asRawString(inputs.passphrase) : undefined, + }, + } + return this.runPi(ctx, block, runLocalPi, params, memoryConfig) + } + + if (mode === 'cloud') { + const owner = asOptString(inputs.owner) + const repo = asOptString(inputs.repo) + const githubToken = asRawString(inputs.githubToken) + if (!owner || !repo || !githubToken) { + throw new Error('Cloud mode requires repository owner, name, and a GitHub token') + } + const params: PiCloudRunParams = { + ...base, + mode: 'cloud', + owner, + repo, + githubToken, + baseBranch: asOptString(inputs.baseBranch), + branchName: asOptString(inputs.branchName), + draft: inputs.draft !== false, + prTitle: asOptString(inputs.prTitle), + prBody: asOptString(inputs.prBody), + } + return this.runPi(ctx, block, runCloudPi, params, memoryConfig) + } + + throw new Error(`Invalid Pi mode: ${String(inputs.mode)}`) + } + + private isContentSelectedForStreaming(ctx: ExecutionContext, block: SerializedBlock): boolean { + if (!ctx.stream) return false + return ( + ctx.selectedOutputs?.some((outputId) => { + if (outputId === block.id) return true + return outputId === `${block.id}.content` || outputId === `${block.id}_content` + }) ?? false + ) + } + + private buildOutput( + result: PiRunResult, + model: string, + isBYOK: boolean, + startTime: number, + startTimeISO: string + ): NormalizedBlockOutput { + const { totals } = result + const endTime = Date.now() + return { + content: totals.finalText, + model, + changedFiles: result.changedFiles ?? [], + diff: result.diff ?? '', + ...(result.prUrl ? { prUrl: result.prUrl } : {}), + ...(result.branch ? { branch: result.branch } : {}), + tokens: { + input: totals.inputTokens, + output: totals.outputTokens, + total: totals.inputTokens + totals.outputTokens, + }, + cost: computePiCost(model, totals.inputTokens, totals.outputTokens, isBYOK), + providerTiming: { + startTime: startTimeISO, + endTime: new Date(endTime).toISOString(), + duration: endTime - startTime, + }, + } + } + + private async runPi

( + ctx: ExecutionContext, + block: SerializedBlock, + backend: PiBackendRun

, + params: P, + memoryConfig: PiMemoryConfig + ): Promise { + const startTime = Date.now() + const startTimeISO = new Date(startTime).toISOString() + + logger.info('Executing Pi block', { + blockId: block.id, + mode: params.mode, + model: params.model, + workflowId: ctx.workflowId, + executionId: ctx.executionId, + }) + + if (this.isContentSelectedForStreaming(ctx, block)) { + const output: NormalizedBlockOutput = { content: '', model: params.model } + const stream = new ReadableStream({ + start: async (controller) => { + const encoder = new TextEncoder() + try { + const result = await backend(params, { + onEvent: (event) => { + const text = streamTextForEvent(event) + if (text) controller.enqueue(encoder.encode(text)) + }, + signal: ctx.abortSignal, + }) + if (result.totals.errorMessage) { + controller.error(new Error(result.totals.errorMessage)) + return + } + Object.assign( + output, + this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO) + ) + await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText) + controller.close() + } catch (error) { + controller.error(error) + } + }, + }) + + return { + stream, + execution: { + success: true, + output, + blockId: block.id, + logs: [], + metadata: { startTime: startTimeISO, duration: 0 }, + isStreaming: true, + } as StreamingExecution['execution'] & { blockId: string }, + } + } + + const result = await backend(params, { onEvent: () => {}, signal: ctx.abortSignal }) + if (result.totals.errorMessage) { + throw new Error(result.totals.errorMessage) + } + await appendPiMemory(ctx, memoryConfig, params.task, result.totals.finalText) + return this.buildOutput(result, params.model, params.isBYOK, startTime, startTimeISO) + } +} diff --git a/apps/sim/executor/handlers/pi/sim-tools.test.ts b/apps/sim/executor/handlers/pi/sim-tools.test.ts new file mode 100644 index 0000000000..123ce9d3ae --- /dev/null +++ b/apps/sim/executor/handlers/pi/sim-tools.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockTransformBlockTool, mockExecuteTool } = vi.hoisted(() => ({ + mockTransformBlockTool: vi.fn(), + mockExecuteTool: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ transformBlockTool: mockTransformBlockTool })) +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) +vi.mock('@/tools/utils', () => ({ getTool: vi.fn() })) +vi.mock('@/tools/utils.server', () => ({ getToolAsync: vi.fn() })) + +import { buildSimToolSpecs } from '@/executor/handlers/pi/sim-tools' +import type { ExecutionContext } from '@/executor/types' + +const ctx = { workspaceId: 'ws-1' } as ExecutionContext + +describe('buildSimToolSpecs', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('names the Pi tool with the snake_case tool id, not the human label', async () => { + // transformBlockTool returns a human label with a space, which the model + // provider rejects (tool names must match /^[a-zA-Z0-9_-]{1,128}$/). + mockTransformBlockTool.mockResolvedValue({ + id: 'exa_search', + name: 'Exa Search', + description: 'Search the web', + params: {}, + parameters: { type: 'object', properties: {} }, + }) + + const specs = await buildSimToolSpecs(ctx, [ + { type: 'exa', operation: 'exa_search', usageControl: 'auto' }, + ]) + + expect(specs).toHaveLength(1) + expect(specs[0].name).toBe('exa_search') + expect(specs[0].name).toMatch(/^[a-zA-Z0-9_-]{1,128}$/) + }) + + it('skips mcp, custom, and usage-none tools without adapting them', async () => { + const specs = await buildSimToolSpecs(ctx, [ + { type: 'mcp', usageControl: 'auto' }, + { type: 'custom-tool', usageControl: 'auto' }, + { type: 'exa', usageControl: 'none' }, + ]) + + expect(specs).toHaveLength(0) + expect(mockTransformBlockTool).not.toHaveBeenCalled() + }) + + it('forwards a trusted _context that an LLM-supplied _context cannot override', async () => { + mockTransformBlockTool.mockResolvedValue({ + id: 'exa_search', + name: 'Exa Search', + description: 'Search the web', + params: { apiKey: 'k' }, + parameters: { type: 'object', properties: {} }, + }) + mockExecuteTool.mockResolvedValue({ success: true, output: 'ok' }) + const trustedCtx = { + workspaceId: 'ws-1', + workflowId: 'wf-1', + userId: 'user-1', + } as ExecutionContext + + const [spec] = await buildSimToolSpecs(trustedCtx, [ + { type: 'exa', operation: 'exa_search', usageControl: 'auto' }, + ]) + // An attacker-influenced tool arg tries to spoof the execution context. + await spec.execute({ query: 'cats', _context: { userId: 'attacker', workspaceId: 'evil' } }) + + const [toolId, callParams] = mockExecuteTool.mock.calls[0] + expect(toolId).toBe('exa_search') + expect(callParams._context.userId).toBe('user-1') + expect(callParams._context.workspaceId).toBe('ws-1') + expect(callParams._context.workflowId).toBe('wf-1') + }) +}) diff --git a/apps/sim/executor/handlers/pi/sim-tools.ts b/apps/sim/executor/handlers/pi/sim-tools.ts new file mode 100644 index 0000000000..0fb6a3b632 --- /dev/null +++ b/apps/sim/executor/handlers/pi/sim-tools.ts @@ -0,0 +1,107 @@ +/** + * Adapts user-selected Sim tools into backend-neutral {@link PiToolSpec}s that + * Pi can call in local mode. Each spec carries the tool's JSON-schema parameters + * and an `execute` that runs the real Sim tool through `executeTool`, so the + * agent's calls go through the same credential-access checks as any block. + * + * MCP and custom tools are skipped in v1; block/integration tools are supported. + */ + +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { getAllBlocks } from '@/blocks/registry' +import type { ToolInput } from '@/executor/handlers/agent/types' +import type { PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend' +import type { ExecutionContext } from '@/executor/types' +import { transformBlockTool } from '@/providers/utils' +import { executeTool } from '@/tools' +import type { ToolResponse } from '@/tools/types' +import { getTool } from '@/tools/utils' +import { getToolAsync } from '@/tools/utils.server' + +const logger = createLogger('PiSimTools') + +function toToolResult(result: ToolResponse): PiToolResult { + if (result.success) { + const text = + typeof result.output === 'string' ? result.output : JSON.stringify(result.output ?? {}) + return { text, isError: false } + } + return { text: result.error || 'Tool execution failed', isError: true } +} + +/** + * Builds the Sim tool specs exposed to Pi for a local run. Only tools the user + * added to the block are included, and `usageControl: 'none'` tools are dropped. + */ +export async function buildSimToolSpecs( + ctx: ExecutionContext, + inputTools: unknown +): Promise { + if (!Array.isArray(inputTools)) return [] + + const specs: PiToolSpec[] = [] + + for (const tool of inputTools as ToolInput[]) { + if ((tool.usageControl || 'auto') === 'none') continue + if (!tool.type || tool.type === 'mcp' || tool.type === 'custom-tool') continue + + try { + const provider = await transformBlockTool(tool, { + selectedOperation: tool.operation, + getAllBlocks, + getTool, + getToolAsync, + }) + + if (!provider?.id) continue + + const toolId = provider.id + const preseededParams = provider.params || {} + + specs.push({ + name: toolId, + description: provider.description || '', + parameters: (provider.parameters as Record) || { + type: 'object', + properties: {}, + }, + execute: async (args) => { + try { + const result = await executeTool( + toolId, + { + ...preseededParams, + ...args, + // Trusted execution context, spread last so an LLM-supplied + // `_context` arg can't override it. executeTool reads this directly + // for OAuth-credential resolution and internal-route identity, the + // same way the Agent block's tool calls do. + _context: { + workflowId: ctx.workflowId, + workspaceId: ctx.workspaceId, + executionId: ctx.executionId, + userId: ctx.userId, + isDeployedContext: ctx.isDeployedContext, + enforceCredentialAccess: ctx.enforceCredentialAccess, + callChain: ctx.callChain, + }, + }, + { executionContext: ctx } + ) + return toToolResult(result) + } catch (error) { + return { text: getErrorMessage(error, 'Tool execution failed'), isError: true } + } + }, + }) + } catch (error) { + logger.warn('Failed to adapt Sim tool for Pi', { + type: tool.type, + error: getErrorMessage(error), + }) + } + } + + return specs +} diff --git a/apps/sim/executor/handlers/pi/ssh-tools.test.ts b/apps/sim/executor/handlers/pi/ssh-tools.test.ts new file mode 100644 index 0000000000..ff70cdb266 --- /dev/null +++ b/apps/sim/executor/handlers/pi/ssh-tools.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockExecuteSSHCommand } = vi.hoisted(() => ({ + mockExecuteSSHCommand: vi.fn(), +})) + +vi.mock('@/app/api/tools/ssh/utils', () => ({ + createSSHConnection: vi.fn(), + executeSSHCommand: mockExecuteSSHCommand, + escapeShellArg: (value: string) => value.replace(/'/g, "'\\''"), + sanitizeCommand: (value: string) => value, + sanitizePath: (value: string) => { + if (value.split(/[/\\]/).includes('..')) { + throw new Error('Path contains invalid path traversal sequences') + } + return value.trim() + }, +})) + +import type { PiSshSession } from '@/executor/handlers/pi/ssh-tools' +import { buildSshToolSpecs } from '@/executor/handlers/pi/ssh-tools' + +function createSession(files: Record): PiSshSession { + const sftp = { + readFile: (path: string, cb: (err: Error | undefined, data: Buffer) => void) => { + if (!(path in files)) { + cb(new Error(`no such file: ${path}`), Buffer.from('')) + return + } + cb(undefined, Buffer.from(files[path])) + }, + writeFile: (path: string, data: string, cb: (err?: Error) => void) => { + files[path] = data + cb(undefined) + }, + } + return { + client: {} as PiSshSession['client'], + sftp: sftp as unknown as PiSshSession['sftp'], + close: vi.fn(), + } +} + +function getTool(repoPath: string, files: Record, name: string) { + const tools = buildSshToolSpecs(createSession(files), repoPath) + const tool = tools.find((t) => t.name === name) + if (!tool) throw new Error(`tool not found: ${name}`) + return tool +} + +describe('buildSshToolSpecs', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('reads a file resolved against repoPath', async () => { + const read = getTool('/repo', { '/repo/a.txt': 'contents' }, 'read') + expect(await read.execute({ path: 'a.txt' })).toEqual({ text: 'contents', isError: false }) + }) + + it('writes a file resolved against repoPath', async () => { + const files: Record = {} + const write = getTool('/repo', files, 'write') + const result = await write.execute({ path: 'b.txt', content: 'hello' }) + expect(result.isError).toBe(false) + expect(files['/repo/b.txt']).toBe('hello') + }) + + it('edits the first occurrence of old_string', async () => { + const files = { '/repo/c.txt': 'foo bar foo' } + const edit = getTool('/repo', files, 'edit') + const result = await edit.execute({ path: 'c.txt', old_string: 'foo', new_string: 'baz' }) + expect(result.isError).toBe(false) + expect(files['/repo/c.txt']).toBe('baz bar foo') + }) + + it('reports an error when old_string is absent', async () => { + const edit = getTool('/repo', { '/repo/c.txt': 'nothing here' }, 'edit') + const result = await edit.execute({ path: 'c.txt', old_string: 'missing', new_string: 'x' }) + expect(result.isError).toBe(true) + }) + + it('runs bash scoped to the repo directory', async () => { + mockExecuteSSHCommand.mockResolvedValue({ stdout: 'out', stderr: '', exitCode: 0 }) + const bash = getTool('/repo', {}, 'bash') + const result = await bash.execute({ command: 'ls -la' }) + expect(result).toEqual({ text: 'out', isError: false }) + expect(mockExecuteSSHCommand).toHaveBeenCalledWith(expect.anything(), "cd '/repo' && ls -la") + }) + + it('marks a non-zero bash exit as an error', async () => { + mockExecuteSSHCommand.mockResolvedValue({ stdout: '', stderr: 'boom', exitCode: 2 }) + const bash = getTool('/repo', {}, 'bash') + const result = await bash.execute({ command: 'false' }) + expect(result.isError).toBe(true) + }) + + it('rejects path traversal and paths outside the repo', async () => { + const read = getTool('/repo', {}, 'read') + expect((await read.execute({ path: '../etc/passwd' })).isError).toBe(true) + expect((await read.execute({ path: '/outside/repo' })).isError).toBe(true) + }) +}) diff --git a/apps/sim/executor/handlers/pi/ssh-tools.ts b/apps/sim/executor/handlers/pi/ssh-tools.ts new file mode 100644 index 0000000000..454f78b598 --- /dev/null +++ b/apps/sim/executor/handlers/pi/ssh-tools.ts @@ -0,0 +1,224 @@ +/** + * SSH-backed file and shell tools for local-mode Pi runs. A single `ssh2` + * connection is opened per run and reused across every tool call: `read`/`write`/ + * `edit` go over SFTP, `bash` over a shell exec scoped to the repo directory. + * All paths are sanitized and confined to the configured `repoPath` (S4). + */ + +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { Client, SFTPWrapper } from 'ssh2' +import { + createSSHConnection, + escapeShellArg, + executeSSHCommand, + sanitizeCommand, + sanitizePath, +} from '@/app/api/tools/ssh/utils' +import type { PiSshConnection, PiToolResult, PiToolSpec } from '@/executor/handlers/pi/backend' + +const logger = createLogger('PiSshTools') + +/** An open SSH session reused for the duration of a local Pi run. */ +export interface PiSshSession { + client: Client + sftp: SFTPWrapper + close: () => void +} + +/** Opens one SSH connection plus an SFTP channel for the run. */ +export async function openSshSession(connection: PiSshConnection): Promise { + const client = await createSSHConnection({ + host: connection.host, + port: connection.port, + username: connection.username, + password: connection.password ?? null, + privateKey: connection.privateKey ?? null, + passphrase: connection.passphrase ?? null, + }) + + const sftp = await new Promise((resolve, reject) => { + client.sftp((err, channel) => (err ? reject(err) : resolve(channel))) + }) + + return { + client, + sftp, + close: () => { + try { + client.end() + } catch (error) { + logger.warn('Failed to close SSH session', { error: getErrorMessage(error) }) + } + }, + } +} + +function readRemoteFile(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.readFile(path, (err, data) => (err ? reject(err) : resolve(data.toString('utf-8')))) + }) +} + +function writeRemoteFile(sftp: SFTPWrapper, path: string, content: string): Promise { + return new Promise((resolve, reject) => { + sftp.writeFile(path, content, (err) => (err ? reject(err) : resolve())) + }) +} + +/** Resolves a tool-supplied path against `repoPath`, rejecting traversal/escape. */ +function resolveRepoPath(repoPath: string, candidate: string): string { + const clean = sanitizePath(candidate) + const root = repoPath.replace(/\/+$/, '') + if (clean.startsWith('/')) { + if (clean !== root && !clean.startsWith(`${root}/`)) { + throw new Error(`Path is outside the repository: ${candidate}`) + } + return clean + } + return `${root}/${clean}` +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +async function guard(run: () => Promise): Promise { + try { + return await run() + } catch (error) { + return { text: getErrorMessage(error, 'SSH tool failed'), isError: true } + } +} + +/** + * Best-effort working-tree snapshot of the repo over the run's SSH session, for + * the block's `changedFiles`/`diff` outputs — Local mode edits in place rather + * than opening a PR. `changedFiles` covers both tracked modifications and untracked + * (newly created) files so files the agent created are reported; `diff` reflects + * tracked changes against HEAD. Returns empty on any failure (not a git repo, git + * missing, non-zero exit). + */ +export async function captureRepoChanges( + session: PiSshSession, + repoPath: string, + maxDiffBytes: number +): Promise<{ changedFiles: string[]; diff: string }> { + const scoped = `cd '${escapeShellArg(repoPath)}'` + try { + const tracked = await executeSSHCommand( + session.client, + `${scoped} && git diff --name-only HEAD` + ) + const untracked = await executeSSHCommand( + session.client, + `${scoped} && git ls-files --others --exclude-standard` + ) + const fileSet = new Set() + for (const result of [tracked, untracked]) { + if (result.exitCode !== 0) continue + for (const line of result.stdout.split('\n')) { + const file = line.trim() + if (file) fileSet.add(file) + } + } + const raw = await executeSSHCommand(session.client, `${scoped} && git diff HEAD`) + const out = raw.exitCode === 0 ? raw.stdout : '' + const diff = out.length > maxDiffBytes ? `${out.slice(0, maxDiffBytes)}\n[diff truncated]` : out + return { changedFiles: [...fileSet], diff } + } catch { + return { changedFiles: [], diff: '' } + } +} + +/** Builds the SSH-backed `read`/`write`/`edit`/`bash` tools scoped to `repoPath`. */ +export function buildSshToolSpecs(session: PiSshSession, repoPath: string): PiToolSpec[] { + const { client, sftp } = session + + return [ + { + name: 'read', + description: 'Read the full contents of a file in the repository.', + parameters: { + type: 'object', + properties: { path: { type: 'string', description: 'File path within the repository' } }, + required: ['path'], + }, + execute: (args) => + guard(async () => { + const path = asString(args.path) + if (!path) return { text: 'path is required', isError: true } + const content = await readRemoteFile(sftp, resolveRepoPath(repoPath, path)) + return { text: content, isError: false } + }), + }, + { + name: 'write', + description: 'Write (create or overwrite) a file in the repository.', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'File path within the repository' }, + content: { type: 'string', description: 'Full file contents to write' }, + }, + required: ['path', 'content'], + }, + execute: (args) => + guard(async () => { + const path = asString(args.path) + if (!path) return { text: 'path is required', isError: true } + const resolved = resolveRepoPath(repoPath, path) + await writeRemoteFile(sftp, resolved, asString(args.content)) + return { text: `Wrote ${resolved}`, isError: false } + }), + }, + { + name: 'edit', + description: 'Replace the first occurrence of old_string with new_string in a file.', + parameters: { + type: 'object', + properties: { + path: { type: 'string', description: 'File path within the repository' }, + old_string: { type: 'string', description: 'Exact text to replace' }, + new_string: { type: 'string', description: 'Replacement text' }, + }, + required: ['path', 'old_string', 'new_string'], + }, + execute: (args) => + guard(async () => { + const path = asString(args.path) + if (!path) return { text: 'path is required', isError: true } + const oldString = asString(args.old_string) + const resolved = resolveRepoPath(repoPath, path) + const current = await readRemoteFile(sftp, resolved) + if (!current.includes(oldString)) { + return { text: `old_string not found in ${resolved}`, isError: true } + } + const updated = current.replace(oldString, asString(args.new_string)) + await writeRemoteFile(sftp, resolved, updated) + return { text: `Edited ${resolved}`, isError: false } + }), + }, + { + name: 'bash', + description: 'Run a shell command in the repository directory and return its output.', + parameters: { + type: 'object', + properties: { command: { type: 'string', description: 'Shell command to run' } }, + required: ['command'], + }, + execute: (args) => + guard(async () => { + const command = asString(args.command) + if (!command) return { text: 'command is required', isError: true } + const scoped = `cd '${escapeShellArg(repoPath)}' && ${sanitizeCommand(command)}` + const result = await executeSSHCommand(client, scoped) + const text = [result.stdout, result.stderr].filter(Boolean).join('\n') + return { + text: text || `Exited with code ${result.exitCode}`, + isError: result.exitCode !== 0, + } + }), + }, + ] +} diff --git a/apps/sim/executor/handlers/registry.ts b/apps/sim/executor/handlers/registry.ts index f2b3c29202..cd8c57d1c6 100644 --- a/apps/sim/executor/handlers/registry.ts +++ b/apps/sim/executor/handlers/registry.ts @@ -14,6 +14,7 @@ import { FunctionBlockHandler } from '@/executor/handlers/function/function-hand import { GenericBlockHandler } from '@/executor/handlers/generic/generic-handler' import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler' import { MothershipBlockHandler } from '@/executor/handlers/mothership/mothership-handler' +import { PiBlockHandler } from '@/executor/handlers/pi/pi-handler' import { ResponseBlockHandler } from '@/executor/handlers/response/response-handler' import { RouterBlockHandler } from '@/executor/handlers/router/router-handler' import { TriggerBlockHandler } from '@/executor/handlers/trigger/trigger-handler' @@ -39,6 +40,7 @@ export function createBlockHandlers(): BlockHandler[] { new HumanInTheLoopBlockHandler(), new AgentBlockHandler(), new MothershipBlockHandler(), + new PiBlockHandler(), new VariablesBlockHandler(), new WorkflowBlockHandler(), new WaitBlockHandler(), diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 09c2e4fe51..b164a44867 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -390,6 +390,7 @@ export const env = createEnv({ E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation MOTHERSHIP_E2B_TEMPLATE_ID: z.string().optional(), // Custom E2B template with pre-installed CLI tools for shell execution MOTHERSHIP_E2B_DOC_TEMPLATE_ID: z.string().optional(), // Dedicated E2B template with python-pptx/docx/openpyxl/reportlab for document generation; when set (and E2B enabled), docs compile via Python instead of the JS isolated-vm path + E2B_PI_TEMPLATE_ID: z.string().optional(), // E2B template ID/alias with the Pi CLI + git baked in (Pi Coding Agent cloud mode) // Credential Sets (Email Polling) - for self-hosted deployments CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements) diff --git a/apps/sim/lib/execution/e2b.ts b/apps/sim/lib/execution/e2b.ts index 697fc5992d..ccefb86cf9 100644 --- a/apps/sim/lib/execution/e2b.ts +++ b/apps/sim/lib/execution/e2b.ts @@ -107,7 +107,7 @@ async function writeSandboxInputs( }) } -async function createE2BSandbox(kind: 'code' | 'shell' | 'doc'): Promise { +async function createE2BSandbox(kind: 'code' | 'shell' | 'doc' | 'pi'): Promise { const apiKey = env.E2B_API_KEY if (!apiKey) { throw new Error('E2B_API_KEY is required when E2B is enabled') @@ -120,8 +120,18 @@ async function createE2BSandbox(kind: 'code' | 'shell' | 'doc'): Promise + timeoutMs: number + onStdout?: (chunk: string) => void + onStderr?: (chunk: string) => void + } + ): Promise + readFile(path: string): Promise + /** + * Writes a file via the sandbox filesystem API. Bytes go through the E2B SDK, + * never a shell, so untrusted content (the assembled prompt, a commit message) + * is delivered without any shell parsing — callers reference it by a fixed path. + */ + writeFile(path: string, content: string): Promise +} + +/** + * Creates a Pi sandbox, keeps it alive for the duration of `fn` (so the cloned + * repo persists across the clone -> agent -> push commands), streams command + * output, and always kills the sandbox afterward. Per-command envs are isolated, + * so secrets handed to one command never leak into the next. + */ +export async function withPiSandbox(fn: (runner: PiSandboxRunner) => Promise): Promise { + const sandbox = await createE2BSandbox('pi') + const sandboxId = sandbox.sandboxId + logger.info('Started Pi sandbox', { sandboxId }) + + const runner: PiSandboxRunner = { + run: async (command, options) => { + try { + const result = await sandbox.commands.run(command, { + envs: { ...(options.envs ?? {}), PATH: PI_SANDBOX_PATH }, + timeoutMs: options.timeoutMs, + user: 'root', + onStdout: options.onStdout, + onStderr: options.onStderr, + }) + return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode } + } catch (error) { + const failure = error as { + stdout?: string + stderr?: string + message?: string + exitCode?: number + } + return { + stdout: failure.stdout ?? '', + stderr: failure.stderr ?? failure.message ?? getErrorMessage(error), + exitCode: failure.exitCode ?? 1, + } + } + }, + readFile: (path) => sandbox.files.read(path), + writeFile: async (path, content) => { + await sandbox.files.write(path, content) + }, + } + + try { + return await fn(runner) + } finally { + try { + await sandbox.kill() + } catch {} + } +} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 892fa4d391..24d265a580 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -84,6 +84,7 @@ const nextConfig: NextConfig = { 'isolated-vm', '@e2b/code-interpreter', 'e2b', + '@earendil-works/pi-coding-agent', ], outputFileTracingIncludes: { '/api/tools/stagehand/*': ['./node_modules/ws/**/*'], diff --git a/apps/sim/package.json b/apps/sim/package.json index a030bf172a..aec686aadf 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -62,6 +62,7 @@ "@browserbasehq/stagehand": "^3.2.1", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@e2b/code-interpreter": "^2.0.0", + "@earendil-works/pi-coding-agent": "0.79.10", "@floating-ui/dom": "1.7.6", "@google/genai": "1.34.0", "@hookform/resolvers": "5.2.2", diff --git a/apps/sim/providers/pi-providers.ts b/apps/sim/providers/pi-providers.ts new file mode 100644 index 0000000000..af9fd305a4 --- /dev/null +++ b/apps/sim/providers/pi-providers.ts @@ -0,0 +1,29 @@ +/** + * Providers the Pi Coding Agent can run with a single API key. This list is the + * single source of truth for both the cloud env-var mapping (Pi handler) and the + * Pi block's model dropdown (UI), so the block only offers Pi-runnable models. + * + * Excludes providers Pi's key-based flow can't drive: ones needing richer config + * (Vertex OAuth, Bedrock IAM, Azure endpoint+key) and base-URL providers + * (Ollama, vLLM, LiteLLM, Together, Baseten, Ollama Cloud). + */ +export const PI_SUPPORTED_PROVIDER_IDS = [ + 'anthropic', + 'openai', + 'google', + 'xai', + 'deepseek', + 'mistral', + 'groq', + 'cerebras', + 'openrouter', +] as const + +export type PiSupportedProvider = (typeof PI_SUPPORTED_PROVIDER_IDS)[number] + +const PI_SUPPORTED_PROVIDER_SET = new Set(PI_SUPPORTED_PROVIDER_IDS) + +/** Whether the Pi Coding Agent can run a given provider via a single API key. */ +export function isPiSupportedProvider(providerId: string): providerId is PiSupportedProvider { + return PI_SUPPORTED_PROVIDER_SET.has(providerId) +} diff --git a/apps/sim/scripts/build-pi-e2b-template.ts b/apps/sim/scripts/build-pi-e2b-template.ts new file mode 100644 index 0000000000..24ab4a0910 --- /dev/null +++ b/apps/sim/scripts/build-pi-e2b-template.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env bun + +/** + * Builds the E2B sandbox template that powers the Pi Coding Agent cloud mode. + * + * Layers the `pi` CLI plus git onto E2B's `code-interpreter` base (which already + * ships node + python). The cloud backend runs `pi` and `git clone/commit/push` + * inside this sandbox, so both must resolve on PATH — the global npm bin and + * `/usr/bin` both are. + * + * Usage: + * E2B_API_KEY=... bun run apps/sim/scripts/build-pi-e2b-template.ts [--name ] [--no-cache] + * + * After it builds, set the printed value in the Sim app's .env: + * E2B_PI_TEMPLATE_ID= + * `Sandbox.create` resolves by template name, so use the name (not the ID). + */ + +import { defaultBuildLogger, Template } from '@e2b/code-interpreter' + +const DEFAULT_TEMPLATE_NAME = 'sim-pi' + +const piTemplate = Template() + .fromTemplate('code-interpreter-v1') + // git (+ ssh/certs) for clone/commit/push; ripgrep/fd give the agent fast + // file search from its bash tool; gh enables richer GitHub workflows. + .aptInstall(['git', 'gh', 'openssh-client', 'ca-certificates', 'ripgrep', 'fd-find']) + // The `pi` CLI the cloud backend invokes. + .npmInstall(['@earendil-works/pi-coding-agent'], { g: true }) + +async function main() { + if (!process.env.E2B_API_KEY) { + console.error('E2B_API_KEY is required') + process.exit(1) + } + + const args = process.argv.slice(2) + const nameIdx = args.indexOf('--name') + const templateName = nameIdx !== -1 ? args[nameIdx + 1] : DEFAULT_TEMPLATE_NAME + const skipCache = args.includes('--no-cache') + + console.log(`Building Pi E2B template: ${templateName}`) + console.log(skipCache ? 'Cache: disabled\n' : 'Cache: enabled\n') + + const result = await Template.build(piTemplate, templateName, { + onBuildLogs: defaultBuildLogger(), + ...(skipCache ? { skipCache: true } : {}), + }) + + console.log(`\nDone. Template ID: ${result.templateId}`) + console.log(`Set in .env: E2B_PI_TEMPLATE_ID=${templateName}`) +} + +main().catch((error) => { + console.error('Build failed:', error) + process.exit(1) +}) diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 28eb17aaeb..1f7e554c32 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -1578,6 +1578,9 @@ async function executeToolRequest( } const headers = new Headers(requestParams.headers) + if (!headers.has('User-Agent')) { + headers.set('User-Agent', 'Sim') + } await addInternalAuthIfNeeded( headers, isInternalRoute, diff --git a/apps/sim/trigger.config.ts b/apps/sim/trigger.config.ts index 3917f341d1..a490e75a41 100644 --- a/apps/sim/trigger.config.ts +++ b/apps/sim/trigger.config.ts @@ -56,7 +56,7 @@ export default defineConfig({ dirs: ['./background'], ...(grafanaTelemetry ? { telemetry: grafanaTelemetry } : {}), build: { - external: ['isolated-vm'], + external: ['isolated-vm', '@earendil-works/pi-coding-agent'], extensions: [ additionalFiles({ files: [ @@ -67,7 +67,13 @@ export default defineConfig({ ], }), additionalPackages({ - packages: ['unpdf', 'isolated-vm', 'react-dom', '@react-email/render'], + packages: [ + 'unpdf', + 'isolated-vm', + 'react-dom', + '@react-email/render', + '@earendil-works/pi-coding-agent', + ], }), ], }, diff --git a/bun.lock b/bun.lock index ec76f4c62d..c7b0e0a917 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", @@ -118,6 +117,7 @@ "@browserbasehq/stagehand": "^3.2.1", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@e2b/code-interpreter": "^2.0.0", + "@earendil-works/pi-coding-agent": "0.79.10", "@floating-ui/dom": "1.7.6", "@google/genai": "1.34.0", "@hookform/resolvers": "5.2.2", @@ -835,6 +835,14 @@ "@e2b/code-interpreter": ["@e2b/code-interpreter@2.6.0", "", { "dependencies": { "e2b": "^2.28.0" } }, "sha512-Xp3pajVf2LQ2rcXZynE/jYfZw4yyKTZM/LkVPB2vSqVft87GxqEUFDfWxssb811B4571uAMfJxKSHHIa8tMprA=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.79.10", "", { "dependencies": { "@earendil-works/pi-ai": "^0.79.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-XKxgdjhcPuyjrthCOFSgfzT3xZ1uBrJ1IMVDxci1to6hIN6BIg9J5iY8q0pGXK1DLgATLP23da+1UyZLwA360Q=="], + + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.79.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-9jR23tOl0BIUdQMn70Gr72xYBpM7Xgl9Lyv7gAnU1USfkNRuYG/f/edLl+n/Dp/RafDW3JI4DF7y/GhgkORuew=="], + + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.79.10", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.79.10", "@earendil-works/pi-ai": "^0.79.10", "@earendil-works/pi-tui": "^0.79.10", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-YxaRhmgyDTvLDdGVbe7YzTHV80oL5mX5odg6EhGHz3w5Wu1Ix8DCw7bhtiOBLGQNFRcknia0zPmVWIj30XP1EA=="], + + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.79.10", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-FUVOjDn1DVwM1uHD5MNYboXQrXjIDbSt+BQ3py7nQWCY62tKfxgiM1OBMxTcwRWLfSdZHUPpV0hm1loIdUJnPw=="], + "@electric-sql/client": ["@electric-sql/client@1.0.14", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" } }, "sha512-LtPAfeMxXRiYS0hyDQ5hue2PjljUiK9stvzsVyVb4nwxWQxfOWTSF42bHTs/o5i3x1T4kAQ7mwHpxa4A+f8X7Q=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -1007,6 +1015,28 @@ "@linear/sdk": ["@linear/sdk@40.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.0", "graphql": "^15.4.0", "isomorphic-unfetch": "^3.1.0" } }, "sha512-R4lyDIivdi00fO+DYPs7gWNX221dkPJhgDowFrsfos/rNG6o5HixsCPgwXWtKN0GA0nlqLvFTmzvzLXpud1xKw=="], + "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="], + + "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="], + + "@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.9", "", { "os": "darwin" }, "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ=="], + + "@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg=="], + + "@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw=="], + + "@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ=="], + + "@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.9", "", { "os": "linux", "cpu": "none" }, "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw=="], + + "@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw=="], + + "@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ=="], + + "@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ=="], + + "@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA=="], + "@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.4.2", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-xs1qOuyeMOz6t9BXXCXWiukC0/0+48vR08B7uwNdG05wCMnbcNgxiFmdFKDOFbM76qFYFRYlGeRfhfq1U/iZmA=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], @@ -1017,6 +1047,8 @@ "@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="], + "@mistralai/mistralai": ["@mistralai/mistralai@2.2.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], @@ -1415,6 +1447,8 @@ "@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="], + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + "@sim/audit": ["@sim/audit@workspace:packages/audit"], "@sim/auth": ["@sim/auth@workspace:packages/auth"], @@ -1797,6 +1831,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], "@types/stats.js": ["@types/stats.js@0.17.4", "", {}, "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA=="], @@ -2253,6 +2289,8 @@ "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], @@ -2573,8 +2611,12 @@ "hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="], + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], @@ -2611,6 +2653,8 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], "imapflow": ["imapflow@1.2.4", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "iconv-lite": "0.7.1", "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1", "nodemailer": "7.0.12", "pino": "10.1.0", "socks": "2.8.7" } }, "sha512-X/eRQeje33uZycfopjwoQKKbya+bBIaqpviOFxhPOD24DXU2hMfXwYe9e8j1+ADwFVgTvKq4G2/ljjZK3Y8mvg=="], @@ -3139,6 +3183,8 @@ "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], @@ -3161,6 +3207,8 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], @@ -3259,6 +3307,8 @@ "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], "prosemirror-changeset": ["prosemirror-changeset@2.4.1", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw=="], @@ -3417,6 +3467,8 @@ "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], @@ -3477,7 +3529,7 @@ "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], - "semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -3509,7 +3561,7 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "sim": ["sim@workspace:apps/sim"], @@ -3715,6 +3767,8 @@ "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="], + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -3923,6 +3977,26 @@ "@cerebras/cerebras_cloud_sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "@earendil-works/pi-ai/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], + + "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="], + + "@earendil-works/pi-ai/@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], + + "@earendil-works/pi-ai/@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@earendil-works/pi-ai/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + + "@earendil-works/pi-ai/openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], + + "@earendil-works/pi-coding-agent/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "@earendil-works/pi-coding-agent/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "@earendil-works/pi-coding-agent/undici": ["undici@8.5.0", "", {}, "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg=="], + + "@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@grpc/proto-loader/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], @@ -4311,6 +4385,8 @@ "engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], + "execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -4325,6 +4401,8 @@ "figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "fumadocs-core/shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], @@ -4373,6 +4451,8 @@ "isomorphic-unfetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "jsonwebtoken/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "libmime/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -4395,6 +4475,8 @@ "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "make-dir/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -4415,6 +4497,8 @@ "next/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "node-abi/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], @@ -4435,6 +4519,8 @@ "ora/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -4475,14 +4561,14 @@ "restore-cursor/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], "samlify/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "sharp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "sim/@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="], "sim/lucide-react": ["lucide-react@0.479.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aBhNnveRhorBOK7uA4gDjgaf+YlHMdMhQ/3cupk6exM10hWlEU+2QtWYOfhXhjAsmdb6LeKR+NZnow4UxRRiTQ=="], @@ -4561,6 +4647,12 @@ "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], + + "@earendil-works/pi-ai/@aws-sdk/client-bedrock-runtime/@smithy/node-http-handler": ["@smithy/node-http-handler@4.8.0", "", { "dependencies": { "@smithy/core": "^3.25.0", "@smithy/types": "^4.15.0", "tslib": "^2.6.2" } }, "sha512-Mq7TNt/VhlEWiYRLQGpzUWeUxh899UGpjKh7Ru0WVIDIjnE+cTRAn0NYlFQ6bWfsQnKnpCbWJj86HzmcG0qEdg=="], + + "@earendil-works/pi-ai/@google/genai/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -4909,6 +5001,8 @@ "next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "next/sharp/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "nypm/pkg-types/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "openai/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], @@ -5021,6 +5115,8 @@ "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "@earendil-works/pi-ai/@google/genai/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@grpc/proto-loader/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="], @@ -5079,8 +5175,6 @@ "log-update/cli-cursor/restore-cursor/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "openai/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], @@ -5089,6 +5183,8 @@ "ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="], @@ -5109,6 +5205,8 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "@earendil-works/pi-ai/@google/genai/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@trigger.dev/core/@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -5151,6 +5249,8 @@ "lint-staged/listr2/log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "lint-staged/listr2/log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "posthog-js/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "rimraf/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], From 12a7f9cf963987b43361ac21a8285dbbf100db5f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 22 Jun 2026 20:23:25 -0700 Subject: [PATCH 2/9] formatting --- apps/sim/blocks/types.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index a511f72923..ed5ed73f23 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -416,6 +416,13 @@ export interface SubBlockConfig { blockId: string, optionId: string ) => Promise<{ label: string; id: string } | null> + /** + * tool-input only: tool categories the consuming block cannot execute. They + * stay visible in the picker but are greyed out with a tooltip rather than + * hidden. Block/integration tools always run via `executeTool`, so only the + * non-registry categories (`mcp`, `custom-tool`) can be marked unsupported. + */ + unsupportedToolTypes?: ('mcp' | 'custom-tool')[] } export interface BlockConfig { @@ -457,13 +464,6 @@ export interface BlockConfig { } } hideFromToolbar?: boolean - /** - * tool-input only: tool categories the consuming block cannot execute. They - * stay visible in the picker but are greyed out with a tooltip rather than - * hidden. Block/integration tools always run via `executeTool`, so only the - * non-registry categories (`mcp`, `custom-tool`) can be marked unsupported. - */ - unsupportedToolTypes?: ('mcp' | 'custom-tool')[] triggers?: { enabled: boolean available: string[] // List of trigger IDs this block supports From 8796db1a9c06eb90b18934b40153f879a25fae05 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 22 Jun 2026 20:32:12 -0700 Subject: [PATCH 3/9] update docs --- apps/docs/content/docs/en/workflows/blocks/pi.mdx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/docs/content/docs/en/workflows/blocks/pi.mdx b/apps/docs/content/docs/en/workflows/blocks/pi.mdx index adba4b49ac..f98095bdb1 100644 --- a/apps/docs/content/docs/en/workflows/blocks/pi.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/pi.mdx @@ -93,6 +93,15 @@ Multi-turn memory keyed by a conversation ID, shared with the [Agent block](/wor Reuse the same **Conversation ID** across runs to continue a thread. Each turn stores your task and the agent's final summary, which are folded into the next run's prompt. +### Context limits + +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. + +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. + ## Outputs | Output | What it is | @@ -139,4 +148,5 @@ Cloud runs in a sandbox image with the Pi CLI and git baked in. { question: "What GitHub permissions does Cloud mode need?", answer: "A token that can clone, push, and open a PR. With a fine-grained token: select the repo and grant Contents: Read and write plus Pull requests: Read and write. With a classic token: the repo scope. For organization repos, the token must be SSO-authorized." }, { question: "Can I give it Gmail, Slack, or other integrations?", answer: "Yes, in Local mode via the Tools field. Selected Sim tools run through Sim with your connected credentials, the same as the Agent block, so the agent can act beyond the repo while it codes. MCP and custom tools aren't supported yet." }, { question: "Where do the changes go?", answer: "In Cloud mode, to a new branch and a pull request (read prUrl and branch). In Local mode, the files are edited in place on the target machine — review them with git there. Both modes also return changedFiles and a diff." }, + { question: "What happens when memory or context gets large?", answer: "Two things keep it in bounds. Sim trims memory before the run based on the memory type (Conversation auto-caps to a fraction of the model's window for catalog models; the sliding windows bound by message count or token budget), and Pi auto-compacts older turns during the run to stay under the window in both modes. The only gap is a first prompt that already exceeds the window, reachable with Conversation memory plus a manually typed model — use Sliding window (tokens) for long histories or non-catalog models so the budget always applies." }, ]} /> From bac9331b17309f0841d51c50faa4752fdc2cfcc4 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 22 Jun 2026 20:35:29 -0700 Subject: [PATCH 4/9] change version num --- apps/sim/package.json | 2 +- bun.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/package.json b/apps/sim/package.json index aec686aadf..88e9575836 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -62,7 +62,7 @@ "@browserbasehq/stagehand": "^3.2.1", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@e2b/code-interpreter": "^2.0.0", - "@earendil-works/pi-coding-agent": "0.79.10", + "@earendil-works/pi-coding-agent": "0.79.4", "@floating-ui/dom": "1.7.6", "@google/genai": "1.34.0", "@hookform/resolvers": "5.2.2", diff --git a/bun.lock b/bun.lock index c7b0e0a917..b1af5d6b8f 100644 --- a/bun.lock +++ b/bun.lock @@ -117,7 +117,7 @@ "@browserbasehq/stagehand": "^3.2.1", "@cerebras/cerebras_cloud_sdk": "^1.23.0", "@e2b/code-interpreter": "^2.0.0", - "@earendil-works/pi-coding-agent": "0.79.10", + "@earendil-works/pi-coding-agent": "0.79.4", "@floating-ui/dom": "1.7.6", "@google/genai": "1.34.0", "@hookform/resolvers": "5.2.2", @@ -839,7 +839,7 @@ "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.79.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-9jR23tOl0BIUdQMn70Gr72xYBpM7Xgl9Lyv7gAnU1USfkNRuYG/f/edLl+n/Dp/RafDW3JI4DF7y/GhgkORuew=="], - "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.79.10", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.79.10", "@earendil-works/pi-ai": "^0.79.10", "@earendil-works/pi-tui": "^0.79.10", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-YxaRhmgyDTvLDdGVbe7YzTHV80oL5mX5odg6EhGHz3w5Wu1Ix8DCw7bhtiOBLGQNFRcknia0zPmVWIj30XP1EA=="], + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.79.4", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.79.4", "@earendil-works/pi-ai": "^0.79.4", "@earendil-works/pi-tui": "^0.79.4", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.3.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-PthzVzM5m4XH/hrU+2fVjuwuH5M4eMFWbd0NCRScH14XKpwlPc8/Fh6JDz0jQb5kTBT9oQT183YLTHVVulFL9A=="], "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.79.10", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-FUVOjDn1DVwM1uHD5MNYboXQrXjIDbSt+BQ3py7nQWCY62tKfxgiM1OBMxTcwRWLfSdZHUPpV0hm1loIdUJnPw=="], @@ -3993,7 +3993,7 @@ "@earendil-works/pi-coding-agent/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "@earendil-works/pi-coding-agent/undici": ["undici@8.5.0", "", {}, "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg=="], + "@earendil-works/pi-coding-agent/undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="], "@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], From 8ff71e402c0899fe1bccb3cfeb2b7444e5928c32 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 22 Jun 2026 20:38:19 -0700 Subject: [PATCH 5/9] guard to prevent prs on error --- .../handlers/pi/cloud-backend.test.ts | 23 +++++++++++++++++++ .../sim/executor/handlers/pi/cloud-backend.ts | 4 ++++ 2 files changed, 27 insertions(+) diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-backend.test.ts index 43eeb3afad..d39113efea 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -206,6 +206,29 @@ describe('runCloudPi', () => { expect(mockExecuteTool).not.toHaveBeenCalled() }) + it('does not commit, push, or open a PR when the run reports an error on a zero exit', async () => { + mockRun.mockImplementation( + (command: string, options: { onStdout?: (chunk: string) => void }) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + options.onStdout?.('{"type":"error","error":"model exploded"}\n') + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + return Promise.resolve({ + stdout: '__CHANGED__=src/x.ts\n__NEEDS_PUSH__=1', + stderr: '', + exitCode: 0, + }) + } + ) + + await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/model exploded/) + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false) + }) + it('surfaces the real git push error when the push fails, with the token scrubbed', async () => { mockRun.mockImplementation((command: string) => { if (command.includes('git clone')) { diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud-backend.ts index 6b844b4c30..fede0f67bd 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.ts @@ -258,6 +258,10 @@ export const runCloudPi: PiBackendRun = async (params, context ) } + if (totals.errorMessage) { + throw new Error(`Pi agent failed: ${totals.errorMessage}`) + } + // Same rationale as the prompt: keep the commit message off the command line. await runner.writeFile(COMMIT_MSG_PATH, commitMessage) From 991f047f46ab5a3758ab2faddedee0fd90e61080 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 22 Jun 2026 20:43:14 -0700 Subject: [PATCH 6/9] update param visibility --- apps/sim/blocks/blocks/pi.ts | 4 ++++ apps/sim/executor/handlers/pi/local-backend.ts | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/sim/blocks/blocks/pi.ts b/apps/sim/blocks/blocks/pi.ts index 5e7b45738a..25f4bb61e9 100644 --- a/apps/sim/blocks/blocks/pi.ts +++ b/apps/sim/blocks/blocks/pi.ts @@ -121,6 +121,7 @@ export const PiBlock: BlockConfig = { title: 'GitHub Token', 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.', required: true, @@ -201,6 +202,7 @@ export const PiBlock: BlockConfig = { title: 'Password', type: 'short-input', password: true, + paramVisibility: 'user-only', placeholder: 'Your SSH password', required: { field: 'mode', value: 'local', and: { field: 'authMethod', value: 'password' } }, condition: { field: 'mode', value: 'local', and: { field: 'authMethod', value: 'password' } }, @@ -210,6 +212,7 @@ export const PiBlock: BlockConfig = { id: 'privateKey', title: 'Private Key', type: 'code', + paramVisibility: 'user-only', placeholder: '-----BEGIN OPENSSH PRIVATE KEY-----\n...', required: { field: 'mode', @@ -246,6 +249,7 @@ export const PiBlock: BlockConfig = { title: 'Passphrase', type: 'short-input', password: true, + paramVisibility: 'user-only', placeholder: 'Passphrase for encrypted key (optional)', mode: 'advanced', condition: { diff --git a/apps/sim/executor/handlers/pi/local-backend.ts b/apps/sim/executor/handlers/pi/local-backend.ts index c7582e341f..b5834802f2 100644 --- a/apps/sim/executor/handlers/pi/local-backend.ts +++ b/apps/sim/executor/handlers/pi/local-backend.ts @@ -88,7 +88,13 @@ export const runLocalPi: PiBackendRun = async (params, context // Isolate Pi resource discovery: an empty cwd/agentDir keeps DefaultResourceLoader // from loading the Sim server's own .agents/skills, AGENTS.md, extensions, or settings. const isolatedDir = await mkdtemp(join(tmpdir(), 'sim-pi-')) - const session = await openSshSession(params.ssh) + // Clean up the scratch dir if the SSH connection fails — the try/finally below + // is only entered once the session is open, so an early handshake failure would + // otherwise orphan the directory. + const session = await openSshSession(params.ssh).catch(async (error) => { + await rm(isolatedDir, { recursive: true, force: true }).catch(() => {}) + throw error + }) try { const sdk = await loadPiSdk() From 4fb788be53f21c843798680c96cf52337eb5593a Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 22 Jun 2026 20:55:40 -0700 Subject: [PATCH 7/9] address security concerns --- .../handlers/pi/cloud-backend.test.ts | 21 +++++++++++++ .../sim/executor/handlers/pi/cloud-backend.ts | 15 ++++++--- apps/sim/executor/handlers/pi/ssh-tools.ts | 31 +++++++++++-------- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/apps/sim/executor/handlers/pi/cloud-backend.test.ts b/apps/sim/executor/handlers/pi/cloud-backend.test.ts index d39113efea..d81ed981c2 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.test.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -229,6 +229,27 @@ describe('runCloudPi', () => { expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false) }) + it('fails (no PR) when finalize reports neither no-changes nor a push', async () => { + mockRun.mockImplementation((command: string) => { + if (command.includes('git clone')) { + return Promise.resolve({ stdout: '__BASE_SHA__=abc', stderr: '', exitCode: 0 }) + } + if (command.includes('pi -p')) { + return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }) + } + // PREPARE aborted before emitting a marker (e.g. the repo dir vanished). + return Promise.resolve({ + stdout: '', + stderr: 'cd: /workspace/repo: No such file or directory', + exitCode: 1, + }) + }) + + await expect(runCloudPi(baseParams(), { onEvent: vi.fn() })).rejects.toThrow(/finalize failed/) + expect(mockExecuteTool).not.toHaveBeenCalled() + expect(mockRun.mock.calls.some(([cmd]: [string]) => cmd.includes('push'))).toBe(false) + }) + it('surfaces the real git push error when the push fails, with the token scrubbed', async () => { mockRun.mockImplementation((command: string) => { if (command.includes('git clone')) { diff --git a/apps/sim/executor/handlers/pi/cloud-backend.ts b/apps/sim/executor/handlers/pi/cloud-backend.ts index fede0f67bd..433f9eb99a 100644 --- a/apps/sim/executor/handlers/pi/cloud-backend.ts +++ b/apps/sim/executor/handlers/pi/cloud-backend.ts @@ -69,7 +69,8 @@ pi -p --mode json --provider "$PI_PROVIDER" --model "$PI_MODEL" --thinking "$PI_ // hooks are disabled too as defense-in-depth. Commit runs unconditionally // (`|| true` tolerates an empty commit); the push decision is gated on HEAD // advancing past base, so commits the agent made itself are still pushed. -const PREPARE_SCRIPT = `cd ${REPO_DIR} +const PREPARE_SCRIPT = `set -e +cd ${REPO_DIR} git -c core.hooksPath=/dev/null add -A git -c core.hooksPath=/dev/null -c user.email="pi@sim.ai" -c user.name="Sim Pi Agent" commit -F ${COMMIT_MSG_PATH} >/dev/null 2>&1 || true git diff --name-only "$BASE_SHA" HEAD | sed "s/^/__CHANGED__=/" @@ -275,9 +276,15 @@ export const runCloudPi: PiBackendRun = async (params, context context.signal ) const changedFiles = extractMarkerValues(prepare.stdout, '__CHANGED__=') - // Push only when PREPARE explicitly reports HEAD advanced. If it emitted - // neither marker (a git error), treat it as no-op rather than pushing. + const noChanges = prepare.stdout.includes('__NO_CHANGES__=1') const needsPush = prepare.stdout.includes('__NEEDS_PUSH__=1') + // PREPARE (`set -e`) emits exactly one of the two markers on success. Neither + // means the finalize step itself failed (e.g. the repo dir vanished mid-run) — + // surface that rather than silently reporting success with no push. + if (!noChanges && !needsPush) { + const reason = (prepare.stderr || prepare.stdout || 'no status reported').trim() + throw new Error(`Pi finalize failed: ${truncate(reason, PUSH_ERROR_MAX)}`) + } let diff: string | undefined try { @@ -288,7 +295,7 @@ export const runCloudPi: PiBackendRun = async (params, context diff = undefined } - if (!needsPush) { + if (noChanges) { logger.info('Pi cloud run produced no changes to push', { owner: params.owner, repo: params.repo, diff --git a/apps/sim/executor/handlers/pi/ssh-tools.ts b/apps/sim/executor/handlers/pi/ssh-tools.ts index 454f78b598..c625ba7bcb 100644 --- a/apps/sim/executor/handlers/pi/ssh-tools.ts +++ b/apps/sim/executor/handlers/pi/ssh-tools.ts @@ -37,20 +37,25 @@ export async function openSshSession(connection: PiSshConnection): Promise((resolve, reject) => { - client.sftp((err, channel) => (err ? reject(err) : resolve(channel))) - }) + const close = () => { + try { + client.end() + } catch (error) { + logger.warn('Failed to close SSH session', { error: getErrorMessage(error) }) + } + } - return { - client, - sftp, - close: () => { - try { - client.end() - } catch (error) { - logger.warn('Failed to close SSH session', { error: getErrorMessage(error) }) - } - }, + // The TCP/SSH connection is already open here, so close it if opening the SFTP + // channel fails (e.g. the server has the SFTP subsystem disabled) — otherwise + // the connection is orphaned when this function throws. + try { + const sftp = await new Promise((resolve, reject) => { + client.sftp((err, channel) => (err ? reject(err) : resolve(channel))) + }) + return { client, sftp, close } + } catch (error) { + close() + throw error } } From 49cf8952f01dd381255a4db1ce6772a3b56aab38 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 22 Jun 2026 21:02:22 -0700 Subject: [PATCH 8/9] fix tests --- apps/sim/blocks/utils.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/sim/blocks/utils.test.ts b/apps/sim/blocks/utils.test.ts index 41cc478ad2..3dc571b9dd 100644 --- a/apps/sim/blocks/utils.test.ts +++ b/apps/sim/blocks/utils.test.ts @@ -57,6 +57,10 @@ vi.mock('@/providers/models', () => ({ getBaseModelProviders: mockGetBaseModelProviders, })) +vi.mock('@/providers/utils', () => ({ + getProviderFromModel: vi.fn(() => 'openai'), +})) + vi.mock('@/stores/providers/store', () => ({ useProvidersStore: { getState: () => ({ From c7b1d4e6c703a27baed5a5111574299539017c58 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Mon, 22 Jun 2026 21:41:58 -0700 Subject: [PATCH 9/9] reorder: --- apps/sim/executor/handlers/pi/local-backend.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/sim/executor/handlers/pi/local-backend.ts b/apps/sim/executor/handlers/pi/local-backend.ts index b5834802f2..6f6e32e90e 100644 --- a/apps/sim/executor/handlers/pi/local-backend.ts +++ b/apps/sim/executor/handlers/pi/local-backend.ts @@ -147,6 +147,7 @@ export const runLocalPi: PiBackendRun = async (params, context context.signal?.addEventListener('abort', onAbort, { once: true }) } + let runErrorMessage: string | undefined try { await agentSession.prompt( buildPiPrompt({ @@ -155,6 +156,9 @@ export const runLocalPi: PiBackendRun = async (params, context task: params.task, }) ) + // Pi has no error event; a failed run surfaces on the agent state. Capture + // it before `dispose()` so the failure can't be missed by a later read. + runErrorMessage = agentSession.agent.state.errorMessage } finally { unsubscribe() context.signal?.removeEventListener('abort', onAbort) @@ -172,9 +176,8 @@ export const runLocalPi: PiBackendRun = async (params, context throw new Error('Pi run aborted') } - // Pi has no error event; a failed run surfaces on the agent state. - if (agentSession.agent.state.errorMessage) { - totals.errorMessage = agentSession.agent.state.errorMessage + if (runErrorMessage) { + totals.errorMessage = runErrorMessage return { totals } }