+}
+
+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=="],