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..f98095bdb1 --- /dev/null +++ b/apps/docs/content/docs/en/workflows/blocks/pi.mdx @@ -0,0 +1,152 @@ +--- +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. + +### 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 | +| --- | --- | +| `` | 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..25f4bb61e9 --- /dev/null +++ b/apps/sim/blocks/blocks/pi.ts @@ -0,0 +1,386 @@ +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, + 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, + 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, + 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' } }, + dependsOn: ['authMethod'], + }, + { + id: 'privateKey', + title: 'Private Key', + type: 'code', + paramVisibility: 'user-only', + 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, + paramVisibility: 'user-only', + 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..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 { 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: () => ({ 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..d81ed981c2 --- /dev/null +++ b/apps/sim/executor/handlers/pi/cloud-backend.test.ts @@ -0,0 +1,281 @@ +/** + * @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('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('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')) { + 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..433f9eb99a --- /dev/null +++ b/apps/sim/executor/handlers/pi/cloud-backend.ts @@ -0,0 +1,341 @@ +/** + * 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 = `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__=/" +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() + ) + } + + 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) + + // 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__=') + 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 { + 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 (noChanges) { + 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..6f6e32e90e --- /dev/null +++ b/apps/sim/executor/handlers/pi/local-backend.ts @@ -0,0 +1,196 @@ +/** + * 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-')) + // 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() + + 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 }) + } + + let runErrorMessage: string | undefined + try { + await agentSession.prompt( + buildPiPrompt({ + skills: params.skills, + initialMessages: params.initialMessages, + 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) + 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') + } + + if (runErrorMessage) { + totals.errorMessage = runErrorMessage + 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..c625ba7bcb --- /dev/null +++ b/apps/sim/executor/handlers/pi/ssh-tools.ts @@ -0,0 +1,229 @@ +/** + * 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 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 + } +} + +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..88e9575836 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.4", "@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..b1af5d6b8f 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.4", "@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.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=="], + "@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.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="], + + "@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=="],