+}
+
+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..f635eade0d
--- /dev/null
+++ b/apps/sim/executor/handlers/pi/cloud-backend.ts
@@ -0,0 +1,353 @@
+/**
+ * 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
+
+// The agent only edits files; Sim commits, pushes, and opens the PR after the run.
+// Without this, the coding agent tries to git push / open a PR / run the test
+// toolchain itself and fails — the sandbox has no GitHub auth (the token is
+// stripped from the remote after clone) and may lack the project's tooling.
+const CLOUD_GUIDANCE =
+ 'You are running inside an automated sandbox. Make only the file changes needed to complete the task. ' +
+ 'Do not run git commands (commit, push, branch, remote), do not configure git credentials or authenticate ' +
+ 'with GitHub, and do not open a pull request — after you finish, Sim automatically commits your changes, ' +
+ "pushes the branch, and opens the pull request. The project's package manager and test tooling may not be " +
+ 'installed, so do not block on running the full build or test suite; focus on correct, minimal edits.'
+
+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,
+ guidance: CLOUD_GUIDANCE,
+ })
+ 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..dfabfd67a5
--- /dev/null
+++ b/apps/sim/executor/handlers/pi/context.ts
@@ -0,0 +1,118 @@
+/**
+ * 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: optional operating `guidance` (mode-specific constraints),
+ * then skills, prior memory, and the task.
+ */
+export function buildPiPrompt(input: {
+ skills: PiSkill[]
+ initialMessages: PiMessage[]
+ task: string
+ guidance?: string
+}): string {
+ const parts: string[] = []
+
+ if (input.guidance) {
+ parts.push(`# Operating instructions\n${input.guidance}`)
+ }
+
+ 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..930dc2b2f9
--- /dev/null
+++ b/apps/sim/executor/handlers/pi/local-backend.ts
@@ -0,0 +1,204 @@
+/**
+ * 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
+
+// Local mode edits in place and reports the working-tree diff. The agent must not
+// commit (a commit would hide the changes from `git diff HEAD`) or push/open a PR.
+const LOCAL_GUIDANCE =
+ 'Use the provided read/write/edit/bash tools to make the file changes needed to complete the task; they ' +
+ 'operate on the target repository. Do not commit, push, or open a pull request — leave your changes in the ' +
+ 'working tree; Sim reports them after you finish.'
+
+/** 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,
+ guidance: LOCAL_GUIDANCE,
+ })
+ )
+ // 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/hooks/queries/session.ts b/apps/sim/hooks/queries/session.ts
new file mode 100644
index 0000000000..e41d1db7a7
--- /dev/null
+++ b/apps/sim/hooks/queries/session.ts
@@ -0,0 +1,35 @@
+import { useQuery } from '@tanstack/react-query'
+import { client } from '@/lib/auth/auth-client'
+import {
+ type AppSession,
+ extractSessionDataFromAuthClientResult,
+} from '@/lib/auth/session-response'
+
+export const sessionKeys = {
+ all: ['session'] as const,
+ detail: () => [...sessionKeys.all, 'detail'] as const,
+}
+
+async function fetchSession(signal?: AbortSignal): Promise {
+ const res = await client.getSession({ fetchOptions: { signal } })
+ return extractSessionDataFromAuthClientResult(res) as AppSession
+}
+
+/**
+ * Reads the current Better Auth session via the client SDK.
+ *
+ * This is the Better Auth client SDK (not a same-origin `requestJson` contract),
+ * so a plain `useQuery` is correct — there is no boundary contract to bind.
+ *
+ * `retry: false` preserves the prior fail-fast contract: an auth failure (expired
+ * token, startup network partition) surfaces immediately rather than retrying a
+ * request that won't succeed.
+ */
+export function useSessionQuery() {
+ return useQuery({
+ queryKey: sessionKeys.detail(),
+ queryFn: ({ signal }) => fetchSession(signal),
+ staleTime: 5 * 60 * 1000,
+ retry: false,
+ })
+}
diff --git a/apps/sim/hooks/queries/unsubscribe.test.tsx b/apps/sim/hooks/queries/unsubscribe.test.tsx
new file mode 100644
index 0000000000..97a7c49227
--- /dev/null
+++ b/apps/sim/hooks/queries/unsubscribe.test.tsx
@@ -0,0 +1,205 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, type ReactNode } from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockRequestJson } = vi.hoisted(() => ({
+ mockRequestJson: vi.fn(),
+}))
+
+vi.mock('@/lib/api/client/request', () => ({
+ requestJson: mockRequestJson,
+}))
+
+import { requestJson } from '@/lib/api/client/request'
+import { unsubscribeGetContract, unsubscribePostContract } from '@/lib/api/contracts/user'
+import {
+ unsubscribeKeys,
+ useUnsubscribe,
+ useUnsubscribeMutation,
+} from '@/hooks/queries/unsubscribe'
+
+const EMAIL = 'person@example.com'
+const TOKEN = 'tok-123'
+
+const getResponse = {
+ success: true as const,
+ email: EMAIL,
+ token: TOKEN,
+ emailType: 'marketing',
+ isTransactional: false,
+ currentPreferences: {
+ unsubscribeAll: false,
+ unsubscribeMarketing: false,
+ unsubscribeUpdates: false,
+ unsubscribeNotifications: false,
+ },
+}
+
+/**
+ * Minimal dependency-free hook harness (the repo has no `@testing-library/react`).
+ * Mounts the hook in a real React 19 root under jsdom, wrapped in a real
+ * `QueryClientProvider`, so query/mutation lifecycles run exactly as in the app.
+ */
+function renderHookWithClient(useHook: () => T): {
+ result: () => T
+ queryClient: QueryClient
+ unmount: () => void
+} {
+ ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ })
+ const container = document.createElement('div')
+ const root: Root = createRoot(container)
+ let latest: T
+
+ function Probe() {
+ latest = useHook()
+ return null
+ }
+
+ function Wrapper({ children }: { children: ReactNode }) {
+ return {children}
+ }
+
+ act(() => {
+ root.render(
+
+
+
+ )
+ })
+
+ return {
+ result: () => latest,
+ queryClient,
+ unmount: () => act(() => root.unmount()),
+ }
+}
+
+/** Flush pending microtasks and the macrotask queue (query observer scheduling) inside act(). */
+async function flush() {
+ await act(async () => {
+ for (let i = 0; i < 5; i++) {
+ await Promise.resolve()
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ }
+ })
+}
+
+describe('useUnsubscribe', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('is disabled and does not fetch when email or token is missing', async () => {
+ const missingToken = renderHookWithClient(() => useUnsubscribe(EMAIL, undefined))
+ const missingEmail = renderHookWithClient(() => useUnsubscribe(undefined, TOKEN))
+ const missingBoth = renderHookWithClient(() => useUnsubscribe(undefined, undefined))
+ await flush()
+
+ expect(missingToken.result().fetchStatus).toBe('idle')
+ expect(missingEmail.result().fetchStatus).toBe('idle')
+ expect(missingBoth.result().fetchStatus).toBe('idle')
+ expect(mockRequestJson).not.toHaveBeenCalled()
+
+ missingToken.unmount()
+ missingEmail.unmount()
+ missingBoth.unmount()
+ })
+
+ it('fetches when both params are present and surfaces the contract data', async () => {
+ mockRequestJson.mockResolvedValueOnce(getResponse)
+
+ const { result, unmount } = renderHookWithClient(() => useUnsubscribe(EMAIL, TOKEN))
+ await flush()
+
+ expect(requestJson).toHaveBeenCalledTimes(1)
+ expect(requestJson).toHaveBeenCalledWith(
+ unsubscribeGetContract,
+ expect.objectContaining({ query: { email: EMAIL, token: TOKEN } })
+ )
+ expect(result().isSuccess).toBe(true)
+ expect(result().data).toEqual(getResponse)
+ expect(result().data?.isTransactional).toBe(false)
+
+ unmount()
+ })
+})
+
+describe('useUnsubscribeMutation', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('calls requestJson with the post contract and flips the cached preference flag on success', async () => {
+ mockRequestJson.mockResolvedValueOnce({
+ success: true as const,
+ message: 'Unsubscribed',
+ email: EMAIL,
+ type: 'marketing' as const,
+ emailType: 'marketing',
+ })
+
+ const { result, queryClient, unmount } = renderHookWithClient(() => useUnsubscribeMutation())
+ const detailKey = unsubscribeKeys.detail(EMAIL, TOKEN)
+ queryClient.setQueryData(detailKey, getResponse)
+
+ await act(async () => {
+ await result().mutateAsync({ email: EMAIL, token: TOKEN, type: 'marketing' })
+ })
+ await flush()
+
+ expect(result().isSuccess).toBe(true)
+ expect(requestJson).toHaveBeenCalledTimes(1)
+ expect(requestJson).toHaveBeenCalledWith(
+ unsubscribePostContract,
+ expect.objectContaining({ body: { email: EMAIL, token: TOKEN, type: 'marketing' } })
+ )
+
+ const reconciled = queryClient.getQueryData(detailKey)
+ expect(reconciled?.currentPreferences.unsubscribeMarketing).toBe(true)
+ expect(reconciled?.currentPreferences.unsubscribeAll).toBe(false)
+ expect(reconciled?.currentPreferences.unsubscribeUpdates).toBe(false)
+
+ unmount()
+ })
+
+ it('flips unsubscribeAll when type is "all"', async () => {
+ mockRequestJson.mockResolvedValueOnce({
+ success: true as const,
+ message: 'Unsubscribed',
+ email: EMAIL,
+ type: 'all' as const,
+ emailType: 'marketing',
+ })
+
+ const { result, queryClient, unmount } = renderHookWithClient(() => useUnsubscribeMutation())
+ const detailKey = unsubscribeKeys.detail(EMAIL, TOKEN)
+ queryClient.setQueryData(detailKey, getResponse)
+
+ await act(async () => {
+ await result().mutateAsync({ email: EMAIL, token: TOKEN, type: 'all' })
+ })
+ await flush()
+
+ expect(result().isSuccess).toBe(true)
+ const reconciled = queryClient.getQueryData(detailKey)
+ expect(reconciled?.currentPreferences.unsubscribeAll).toBe(true)
+
+ unmount()
+ })
+})
diff --git a/apps/sim/hooks/queries/unsubscribe.ts b/apps/sim/hooks/queries/unsubscribe.ts
new file mode 100644
index 0000000000..29116ffe82
--- /dev/null
+++ b/apps/sim/hooks/queries/unsubscribe.ts
@@ -0,0 +1,76 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { requestJson } from '@/lib/api/client/request'
+import {
+ type UnsubscribeActionResponse,
+ type UnsubscribeData,
+ type UnsubscribeType,
+ unsubscribeGetContract,
+ unsubscribePostContract,
+} from '@/lib/api/contracts/user'
+
+export const unsubscribeKeys = {
+ all: ['unsubscribe'] as const,
+ details: () => [...unsubscribeKeys.all, 'detail'] as const,
+ detail: (email?: string, token?: string) =>
+ [...unsubscribeKeys.details(), email ?? '', token ?? ''] as const,
+}
+
+async function fetchUnsubscribe(
+ email: string,
+ token: string,
+ signal?: AbortSignal
+): Promise {
+ return requestJson(unsubscribeGetContract, { query: { email, token }, signal })
+}
+
+/**
+ * Validates an unsubscribe link and loads the recipient's current email preferences.
+ * Auto-runs on mount once both `email` and `token` are present.
+ */
+export function useUnsubscribe(email?: string, token?: string) {
+ return useQuery({
+ queryKey: unsubscribeKeys.detail(email, token),
+ queryFn: ({ signal }) => fetchUnsubscribe(email as string, token as string, signal),
+ enabled: Boolean(email) && Boolean(token),
+ staleTime: 5 * 60 * 1000,
+ retry: false,
+ })
+}
+
+interface UnsubscribeVariables {
+ email: string
+ token: string
+ type: UnsubscribeType
+}
+
+/**
+ * Submits an unsubscribe action and reconciles the cached preferences so the
+ * affected option immediately reflects the unsubscribed state.
+ */
+export function useUnsubscribeMutation() {
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: ({ email, token, type }) =>
+ requestJson(unsubscribePostContract, { body: { email, token, type } }),
+ onSuccess: (_data, { email, token, type }) => {
+ const key = unsubscribeKeys.detail(email, token)
+ queryClient.setQueryData(key, (previous) => {
+ if (!previous) return previous
+ const preferenceKey =
+ type === 'all'
+ ? 'unsubscribeAll'
+ : (`unsubscribe${type.charAt(0).toUpperCase()}${type.slice(1)}` as
+ | 'unsubscribeMarketing'
+ | 'unsubscribeUpdates'
+ | 'unsubscribeNotifications')
+ return {
+ ...previous,
+ currentPreferences: {
+ ...previous.currentPreferences,
+ [preferenceKey]: true,
+ },
+ }
+ })
+ },
+ })
+}
diff --git a/apps/sim/hooks/queries/utils/fetch-workflow-envelope.test.ts b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.test.ts
new file mode 100644
index 0000000000..71dca0258b
--- /dev/null
+++ b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.test.ts
@@ -0,0 +1,50 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockRequestJson } = vi.hoisted(() => ({
+ mockRequestJson: vi.fn(),
+}))
+
+vi.mock('@/lib/api/client/request', () => ({
+ requestJson: mockRequestJson,
+}))
+
+vi.mock('@/lib/api/contracts/workflows', () => ({
+ getWorkflowStateContract: { __contract: 'getWorkflowState' },
+}))
+
+import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
+
+describe('fetchWorkflowEnvelope', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('returns the unwrapped envelope from the contract response', async () => {
+ const envelope = {
+ id: 'wf-1',
+ isDeployed: true,
+ state: { blocks: {}, edges: [], loops: {}, parallels: {} },
+ }
+ mockRequestJson.mockResolvedValue({ data: envelope })
+
+ const result = await fetchWorkflowEnvelope('wf-1')
+
+ expect(result).toBe(envelope)
+ })
+
+ it('forwards params.id and signal to requestJson against the contract', async () => {
+ mockRequestJson.mockResolvedValue({ data: { id: 'wf-2' } })
+ const controller = new AbortController()
+
+ await fetchWorkflowEnvelope('wf-2', controller.signal)
+
+ expect(mockRequestJson).toHaveBeenCalledTimes(1)
+ expect(mockRequestJson).toHaveBeenCalledWith(
+ { __contract: 'getWorkflowState' },
+ { params: { id: 'wf-2' }, signal: controller.signal }
+ )
+ })
+})
diff --git a/apps/sim/hooks/queries/utils/fetch-workflow-envelope.ts b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.ts
new file mode 100644
index 0000000000..d7a55d1c57
--- /dev/null
+++ b/apps/sim/hooks/queries/utils/fetch-workflow-envelope.ts
@@ -0,0 +1,31 @@
+import { requestJson } from '@/lib/api/client/request'
+import {
+ type GetWorkflowResponseData,
+ getWorkflowStateContract,
+} from '@/lib/api/contracts/workflows'
+
+/**
+ * Fetches the full workflow envelope (in-state slice, deployment status,
+ * variables, and row metadata) for a single workflow from GET
+ * `/api/workflows/[id]`.
+ *
+ * Single source of truth for the `workflowKeys.state(id)` cache entry: the
+ * registry store hydrates it via `fetchQuery` (always-fresh, in-flight
+ * deduped) and `useWorkflowState`/`useWorkflowStates` project the mapped
+ * `WorkflowState` out of the same entry with `select`, so this endpoint has
+ * exactly one cache entry across the store and the hooks.
+ *
+ * Lives in a standalone util (rather than `hooks/queries/workflows.ts`) so the
+ * registry store can import it without creating a store ↔ query-hook import
+ * cycle.
+ */
+export async function fetchWorkflowEnvelope(
+ workflowId: string,
+ signal?: AbortSignal
+): Promise {
+ const { data } = await requestJson(getWorkflowStateContract, {
+ params: { id: workflowId },
+ signal,
+ })
+ return data
+}
diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts
index 865b66bec6..55df6d469a 100644
--- a/apps/sim/hooks/queries/workflows.ts
+++ b/apps/sim/hooks/queries/workflows.ts
@@ -18,7 +18,7 @@ import {
createWorkflowContract,
deleteWorkflowContract,
duplicateWorkflowContract,
- getWorkflowStateContract,
+ type GetWorkflowResponseData,
type ImportWorkflowAsSuperuserBody,
type ImportWorkflowAsSuperuserResponse,
importWorkflowAsSuperuserContract,
@@ -28,6 +28,7 @@ import {
} from '@/lib/api/contracts/workflows'
import { deploymentKeys } from '@/hooks/queries/deployments'
import { fetchDeploymentVersionState } from '@/hooks/queries/utils/fetch-deployment-version-state'
+import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
import { getFolderMap } from '@/hooks/queries/utils/folder-cache'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
import { getTopInsertionSortOrder } from '@/hooks/queries/utils/top-insertion-sort-order'
@@ -49,14 +50,11 @@ const logger = createLogger('WorkflowQueries')
export { type WorkflowQueryScope, workflowKeys } from '@/hooks/queries/utils/workflow-keys'
-async function fetchWorkflowState(
- workflowId: string,
- signal?: AbortSignal
-): Promise {
- const { data } = await requestJson(getWorkflowStateContract, {
- params: { id: workflowId },
- signal,
- })
+/**
+ * Projects the in-state slice of the workflow envelope into the canvas-facing
+ * `WorkflowState` shape consumed by preview/editor surfaces.
+ */
+function mapWorkflowState(data: GetWorkflowResponseData): WorkflowState {
const wireState = data.state
return {
...wireState,
@@ -70,11 +68,16 @@ async function fetchWorkflowState(
* Fetches the full workflow state for a single workflow.
* Used by workflow blocks to show a preview of the child workflow
* and as a base query for input fields extraction.
+ *
+ * Derives the mapped `WorkflowState` from the shared envelope query via
+ * `select`, so it shares one cache entry (and one request) with the registry
+ * store's hydration and with `useWorkflowStates`.
*/
export function useWorkflowState(workflowId: string | undefined) {
return useQuery({
queryKey: workflowKeys.state(workflowId),
- queryFn: workflowId ? ({ signal }) => fetchWorkflowState(workflowId, signal) : skipToken,
+ queryFn: workflowId ? ({ signal }) => fetchWorkflowEnvelope(workflowId, signal) : skipToken,
+ select: mapWorkflowState,
staleTime: 30 * 1000,
})
}
@@ -93,7 +96,8 @@ export function useWorkflowStates(
const results = useQueries({
queries: uniqueIds.map((id) => ({
queryKey: workflowKeys.state(id),
- queryFn: ({ signal }: { signal?: AbortSignal }) => fetchWorkflowState(id, signal),
+ queryFn: ({ signal }: { signal?: AbortSignal }) => fetchWorkflowEnvelope(id, signal),
+ select: mapWorkflowState,
staleTime: 30 * 1000,
})),
})
diff --git a/apps/sim/lib/a2a/push-notifications.ts b/apps/sim/lib/a2a/push-notifications.ts
index 016e993c4a..53cf6a50ac 100644
--- a/apps/sim/lib/a2a/push-notifications.ts
+++ b/apps/sim/lib/a2a/push-notifications.ts
@@ -111,13 +111,15 @@ export async function notifyTaskStateChange(taskId: string, state: TaskState): P
if (isTriggerDevEnabled) {
try {
- const { a2aPushNotificationTask } = await import(
- '@/background/a2a-push-notification-delivery'
- )
+ const [{ a2aPushNotificationTask }, { resolveTriggerRegion }] = await Promise.all([
+ import('@/background/a2a-push-notification-delivery'),
+ import('@/lib/core/async-jobs/region'),
+ ])
await a2aPushNotificationTask.trigger(
{ taskId, state },
{
tags: [`taskId:${taskId}`],
+ region: await resolveTriggerRegion(),
}
)
logger.info('Push notification queued to trigger.dev', { taskId, state })
diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts
index 6c280898c3..897c99fad5 100644
--- a/apps/sim/lib/api/contracts/hotspots.ts
+++ b/apps/sim/lib/api/contracts/hotspots.ts
@@ -45,6 +45,34 @@ export const guardrailsValidateContract = defineRouteContract({
},
})
+const guardrailsMaskBatchBodySchema = z.object({
+ texts: z.array(z.string()).max(100_000),
+ entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(200),
+ language: z.string().min(1).max(20).optional(),
+})
+
+const guardrailsMaskBatchResponseSchema = z.object({
+ masked: z.array(z.string()),
+})
+
+/**
+ * Internal batch PII masking. Called server-to-server (internal JWT) from the
+ * log-redaction persist path so Presidio always runs in the app container,
+ * including for async executions that persist inside the trigger.dev runtime.
+ */
+export const guardrailsMaskBatchContract = defineRouteContract({
+ method: 'POST',
+ path: '/api/guardrails/mask-batch',
+ body: guardrailsMaskBatchBodySchema,
+ response: {
+ mode: 'json',
+ schema: guardrailsMaskBatchResponseSchema,
+ },
+})
+
+export type GuardrailsMaskBatchBody = z.input
+export type GuardrailsMaskBatchResult = z.output
+
const chatMessageSchema = z.object({
role: z.enum(['user', 'assistant', 'system']),
content: z.string(),
diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts
index e3e6148402..2b0d598d1a 100644
--- a/apps/sim/lib/api/contracts/primitives.ts
+++ b/apps/sim/lib/api/contracts/primitives.ts
@@ -1,4 +1,5 @@
import { z } from 'zod'
+import { PII_LANGUAGE_CODES } from '@/lib/guardrails/pii-entities'
export const unknownRecordSchema = z.record(z.string(), z.unknown())
@@ -93,6 +94,8 @@ export const piiRedactionRuleSchema = z.object({
entityTypes: z.array(z.string().min(1, 'Entity type cannot be empty')).max(100),
/** null = all workspaces; otherwise the single targeted workspace. */
workspaceId: z.string().min(1).nullable(),
+ /** Language whose Presidio recognizers apply; defaults to English. */
+ language: z.enum(PII_LANGUAGE_CODES).optional(),
})
export type PiiRedactionRule = z.output
diff --git a/apps/sim/lib/api/contracts/user.ts b/apps/sim/lib/api/contracts/user.ts
index a100cb45af..030c851551 100644
--- a/apps/sim/lib/api/contracts/user.ts
+++ b/apps/sim/lib/api/contracts/user.ts
@@ -1,5 +1,5 @@
import { z } from 'zod'
-import { defineRouteContract } from '@/lib/api/contracts/types'
+import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contracts/types'
import { isSameOrigin } from '@/lib/core/utils/validation'
export const userProfileSchema = z.object({
@@ -259,6 +259,11 @@ export const unsubscribePostContract = defineRouteContract({
},
})
+export type UnsubscribeData = ContractJsonResponse
+export type UnsubscribeActionResponse = ContractJsonResponse
+export type UnsubscribeBody = z.input
+export type UnsubscribeType = NonNullable
+
export const usageLogsQuerySchema = z.object({
source: z.enum(['workflow', 'wand', 'copilot']).optional(),
workspaceId: z.string().optional(),
diff --git a/apps/sim/lib/auth/session-response.ts b/apps/sim/lib/auth/session-response.ts
index 262cc9a1bc..f41fccce32 100644
--- a/apps/sim/lib/auth/session-response.ts
+++ b/apps/sim/lib/auth/session-response.ts
@@ -1,3 +1,27 @@
+/**
+ * The app-facing session shape derived from the Better Auth client response.
+ * Lives here (the module that produces it) so both the `useSessionQuery` hook
+ * and the `SessionProvider` can import it without a provider ↔ hook import cycle.
+ */
+export type AppSession = {
+ user: {
+ id: string
+ email: string
+ emailVerified?: boolean
+ name?: string | null
+ image?: string | null
+ role?: string
+ createdAt?: Date
+ updatedAt?: Date
+ } | null
+ session?: {
+ id?: string
+ userId?: string
+ activeOrganizationId?: string
+ impersonatedBy?: string | null
+ }
+} | null
+
export function extractSessionDataFromAuthClientResult(result: unknown): unknown | null {
if (!result || typeof result !== 'object') {
return null
diff --git a/apps/sim/lib/billing/cleanup-dispatcher.ts b/apps/sim/lib/billing/cleanup-dispatcher.ts
index cb1704f0d8..b36cb42c68 100644
--- a/apps/sim/lib/billing/cleanup-dispatcher.ts
+++ b/apps/sim/lib/billing/cleanup-dispatcher.ts
@@ -10,6 +10,7 @@ import { getPlanType, type PlanCategory } from '@/lib/billing/plan-helpers'
import { chunkArray } from '@/lib/cleanup/batch-delete'
import { getJobQueue } from '@/lib/core/async-jobs'
import { shouldExecuteInline } from '@/lib/core/async-jobs/config'
+import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
import type { EnqueueOptions } from '@/lib/core/async-jobs/types'
import { isTriggerAvailable } from '@/lib/knowledge/documents/service'
import { isOrganizationWorkspace, WORKSPACE_MODE } from '@/lib/workspaces/policy'
@@ -314,6 +315,7 @@ export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{
if (batch.length === 0) return
const currentBatch = batch
batch = []
+ const region = await resolveTriggerRegion()
const batchResult = await tasks.batchTrigger(
jobType,
currentBatch.map((payload) => ({
@@ -321,6 +323,7 @@ export async function dispatchCleanupJobs(jobType: CleanupJobType): Promise<{
options: {
tags: [`plan:${payload.plan}`, `jobType:${jobType}`],
concurrencyKey: getCleanupConcurrencyKey(jobType),
+ region,
},
}))
)
diff --git a/apps/sim/lib/billing/retention.test.ts b/apps/sim/lib/billing/retention.test.ts
index 2852cb6a64..15714bc046 100644
--- a/apps/sim/lib/billing/retention.test.ts
+++ b/apps/sim/lib/billing/retention.test.ts
@@ -21,7 +21,11 @@ describe('resolveEffectivePiiRedaction', () => {
orgSettings: settings([allRule]),
workspaceId: 'ws-1',
})
- expect(result).toEqual({ enabled: true, entityTypes: ['EMAIL_ADDRESS', 'PHONE_NUMBER'] })
+ expect(result).toEqual({
+ enabled: true,
+ entityTypes: ['EMAIL_ADDRESS', 'PHONE_NUMBER'],
+ language: 'en',
+ })
})
it('lets a workspace-specific rule override the all rule', () => {
@@ -29,7 +33,27 @@ describe('resolveEffectivePiiRedaction', () => {
orgSettings: settings([allRule, { id: 'r-1', entityTypes: ['US_SSN'], workspaceId: 'ws-1' }]),
workspaceId: 'ws-1',
})
- expect(result).toEqual({ enabled: true, entityTypes: ['US_SSN'] })
+ expect(result).toEqual({ enabled: true, entityTypes: ['US_SSN'], language: 'en' })
+ })
+
+ it('carries the rule language through (defaults to en)', () => {
+ const result = resolveEffectivePiiRedaction({
+ orgSettings: settings([
+ { id: 'r-es', entityTypes: ['ES_NIF'], workspaceId: 'ws-1', language: 'es' },
+ ]),
+ workspaceId: 'ws-1',
+ })
+ expect(result).toEqual({ enabled: true, entityTypes: ['ES_NIF'], language: 'es' })
+ })
+
+ it('falls back to en when a stored language is unsupported/stale', () => {
+ const result = resolveEffectivePiiRedaction({
+ orgSettings: settings([
+ { id: 'r-de', entityTypes: ['EMAIL_ADDRESS'], workspaceId: 'ws-1', language: 'de' },
+ ]),
+ workspaceId: 'ws-1',
+ })
+ expect(result).toEqual({ enabled: true, entityTypes: ['EMAIL_ADDRESS'], language: 'en' })
})
it('exempts a workspace when its specific rule has no entity types', () => {
@@ -37,7 +61,7 @@ describe('resolveEffectivePiiRedaction', () => {
orgSettings: settings([allRule, { id: 'r-1', entityTypes: [], workspaceId: 'ws-1' }]),
workspaceId: 'ws-1',
})
- expect(result).toEqual({ enabled: false, entityTypes: [] })
+ expect(result).toEqual({ enabled: false, entityTypes: [], language: 'en' })
})
it('is disabled when no rule matches and there is no all rule', () => {
@@ -45,16 +69,17 @@ describe('resolveEffectivePiiRedaction', () => {
orgSettings: settings([{ id: 'r-1', entityTypes: ['US_SSN'], workspaceId: 'ws-2' }]),
workspaceId: 'ws-1',
})
- expect(result).toEqual({ enabled: false, entityTypes: [] })
+ expect(result).toEqual({ enabled: false, entityTypes: [], language: 'en' })
})
it('is disabled when there are no rules', () => {
expect(
resolveEffectivePiiRedaction({ orgSettings: settings([]), workspaceId: 'ws-1' })
- ).toEqual({ enabled: false, entityTypes: [] })
+ ).toEqual({ enabled: false, entityTypes: [], language: 'en' })
expect(resolveEffectivePiiRedaction({ orgSettings: null, workspaceId: 'ws-1' })).toEqual({
enabled: false,
entityTypes: [],
+ language: 'en',
})
})
})
diff --git a/apps/sim/lib/billing/retention.ts b/apps/sim/lib/billing/retention.ts
index 183dbb280e..dafb9e3a78 100644
--- a/apps/sim/lib/billing/retention.ts
+++ b/apps/sim/lib/billing/retention.ts
@@ -1,14 +1,18 @@
import type { DataRetentionSettings } from '@sim/db/schema'
+import { coercePiiLanguage, DEFAULT_PII_LANGUAGE } from '@/lib/guardrails/pii-entities'
export interface EffectivePiiRedaction {
enabled: boolean
/** Presidio entity types to mask. Empty = redact all detected PII. */
entityTypes: string[]
+ /** Language whose Presidio recognizers apply when masking. */
+ language: string
}
export const DEFAULT_PII_REDACTION: EffectivePiiRedaction = {
enabled: false,
entityTypes: [],
+ language: DEFAULT_PII_LANGUAGE,
}
/**
@@ -34,5 +38,6 @@ export function resolveEffectivePiiRedaction(params: {
? rule.entityTypes.filter((t): t is string => typeof t === 'string')
: []
if (types.length === 0) return DEFAULT_PII_REDACTION
- return { enabled: true, entityTypes: types }
+ const language = coercePiiLanguage(rule?.language) ?? DEFAULT_PII_LANGUAGE
+ return { enabled: true, entityTypes: types, language }
}
diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts
index c02948bf9a..ef1a8fed1b 100644
--- a/apps/sim/lib/copilot/tools/server/table/user-table.ts
+++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts
@@ -134,12 +134,14 @@ function shouldImportInBackground(record: { name: string; size: number }): boole
async function dispatchImportJob(payload: TableImportPayload): Promise {
if (isTriggerDevEnabled) {
try {
- const [{ tableImportTask }, { tasks }] = await Promise.all([
+ const [{ tableImportTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
import('@/background/table-import'),
import('@trigger.dev/sdk'),
+ import('@/lib/core/async-jobs/region'),
])
await tasks.trigger('table-import', payload, {
tags: [`tableId:${payload.tableId}`, `jobId:${payload.importId}`],
+ region: await resolveTriggerRegion(),
})
} catch (error) {
await releaseJobClaim(payload.tableId, payload.importId).catch(() => {})
@@ -166,14 +168,15 @@ async function dispatchDeleteJob(params: {
const { jobId, tableId, workspaceId, filter, cutoff, maxRows } = params
if (isTriggerDevEnabled) {
try {
- const [{ tableDeleteTask }, { tasks }] = await Promise.all([
+ const [{ tableDeleteTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
import('@/background/table-delete'),
import('@trigger.dev/sdk'),
+ import('@/lib/core/async-jobs/region'),
])
await tasks.trigger(
'table-delete',
{ jobId, tableId, workspaceId, filter, cutoff: cutoff.toISOString(), maxRows },
- { tags: [`tableId:${tableId}`, `jobId:${jobId}`] }
+ { tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() }
)
} catch (error) {
await releaseJobClaim(tableId, jobId).catch(() => {})
@@ -208,14 +211,15 @@ async function dispatchUpdateJob(params: {
const { jobId, tableId, workspaceId, filter, data, cutoff, maxRows } = params
if (isTriggerDevEnabled) {
try {
- const [{ tableUpdateTask }, { tasks }] = await Promise.all([
+ const [{ tableUpdateTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
import('@/background/table-update'),
import('@trigger.dev/sdk'),
+ import('@/lib/core/async-jobs/region'),
])
await tasks.trigger(
'table-update',
{ jobId, tableId, workspaceId, filter, data, cutoff: cutoff.toISOString(), maxRows },
- { tags: [`tableId:${tableId}`, `jobId:${jobId}`] }
+ { tags: [`tableId:${tableId}`, `jobId:${jobId}`], region: await resolveTriggerRegion() }
)
} catch (error) {
await releaseJobClaim(tableId, jobId).catch(() => {})
diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
index 6e9cbd063b..233be772e9 100644
--- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
+++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { taskContext } from '@trigger.dev/core/v3'
import { runs, type TriggerOptions, tasks } from '@trigger.dev/sdk'
+import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
import {
type EnqueueOptions,
JOB_STATUS,
@@ -84,6 +85,7 @@ export class TriggerDevJobQueue implements JobQueueBackend {
if (options?.delayMs && options.delayMs > 0) {
triggerOptions.delay = new Date(Date.now() + options.delayMs)
}
+ triggerOptions.region = await resolveTriggerRegion()
const handle = await tasks.trigger(taskId, enrichedPayload, triggerOptions)
logger.debug('Enqueued job via trigger.dev', { jobId: handle.id, type, taskId, tags })
@@ -125,6 +127,7 @@ export class TriggerDevJobQueue implements JobQueueBackend {
const taskId = JOB_TYPE_TO_TASK_ID[type]
if (!taskId) throw new Error(`Unknown job type: ${type}`)
+ const region = await resolveTriggerRegion()
const batchItems = items.map(({ payload, options }) => {
const enrichedPayload =
options?.metadata && typeof payload === 'object' && payload !== null
@@ -133,12 +136,12 @@ export class TriggerDevJobQueue implements JobQueueBackend {
const tags = buildTags(options)
const batchItem: {
payload: unknown
- options?: { concurrencyKey?: string; tags?: string[] }
+ options?: { concurrencyKey?: string; tags?: string[]; region?: string }
} = { payload: enrichedPayload }
- const batchOpts: { concurrencyKey?: string; tags?: string[] } = {}
+ const batchOpts: { concurrencyKey?: string; tags?: string[]; region?: string } = { region }
if (options?.concurrencyKey) batchOpts.concurrencyKey = options.concurrencyKey
if (tags.length > 0) batchOpts.tags = tags
- if (Object.keys(batchOpts).length > 0) batchItem.options = batchOpts
+ batchItem.options = batchOpts
return batchItem
})
diff --git a/apps/sim/lib/core/async-jobs/region.test.ts b/apps/sim/lib/core/async-jobs/region.test.ts
new file mode 100644
index 0000000000..b1b571594d
--- /dev/null
+++ b/apps/sim/lib/core/async-jobs/region.test.ts
@@ -0,0 +1,42 @@
+/**
+ * @vitest-environment node
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockIsFeatureEnabled } = vi.hoisted(() => ({
+ mockIsFeatureEnabled: vi.fn(),
+}))
+
+vi.mock('@/lib/core/config/feature-flags', () => ({
+ isFeatureEnabled: mockIsFeatureEnabled,
+}))
+
+import {
+ resolveTriggerRegion,
+ TRIGGER_REGION_EU_CENTRAL,
+ TRIGGER_REGION_US_EAST,
+} from '@/lib/core/async-jobs/region'
+
+describe('resolveTriggerRegion', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('returns eu-central-1 when the flag is enabled', async () => {
+ mockIsFeatureEnabled.mockResolvedValue(true)
+ expect(await resolveTriggerRegion()).toBe(TRIGGER_REGION_EU_CENTRAL)
+ expect(mockIsFeatureEnabled).toHaveBeenCalledWith('trigger-eu-region')
+ })
+
+ it('returns us-east-1 when the flag is disabled', async () => {
+ mockIsFeatureEnabled.mockResolvedValue(false)
+ expect(await resolveTriggerRegion()).toBe(TRIGGER_REGION_US_EAST)
+ })
+
+ it('evaluates globally, passing no gating context', async () => {
+ mockIsFeatureEnabled.mockResolvedValue(false)
+ await resolveTriggerRegion()
+ expect(mockIsFeatureEnabled).toHaveBeenCalledTimes(1)
+ expect(mockIsFeatureEnabled.mock.calls[0]).toEqual(['trigger-eu-region'])
+ })
+})
diff --git a/apps/sim/lib/core/async-jobs/region.ts b/apps/sim/lib/core/async-jobs/region.ts
new file mode 100644
index 0000000000..94c42bf331
--- /dev/null
+++ b/apps/sim/lib/core/async-jobs/region.ts
@@ -0,0 +1,21 @@
+import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
+
+/** Default Trigger.dev region — the project default when the eu-central flag is off. */
+export const TRIGGER_REGION_US_EAST = 'us-east-1'
+
+/** Target region when the `trigger-eu-region` flag is enabled. */
+export const TRIGGER_REGION_EU_CENTRAL = 'eu-central-1'
+
+/**
+ * Resolve which Trigger.dev region a run should execute in. Gated globally by the
+ * `trigger-eu-region` feature flag (all-or-nothing — no per-user/org targeting):
+ * `eu-central-1` when enabled, otherwise `us-east-1`.
+ *
+ * The result is passed as the `region` option to `tasks.trigger` / `batchTrigger`,
+ * overriding the project's dashboard default per run.
+ */
+export async function resolveTriggerRegion(): Promise {
+ return (await isFeatureEnabled('trigger-eu-region'))
+ ? TRIGGER_REGION_EU_CENTRAL
+ : TRIGGER_REGION_US_EAST
+}
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index 09c2e4fe51..89924eb568 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -74,6 +74,7 @@ export const env = createEnv({
TABLES_FRACTIONAL_ORDERING: z.boolean().optional(), // Order table rows by fractional order_key (O(1) insert/delete) instead of integer position
TABLE_SNAPSHOT_CACHE: z.boolean().optional(), // Mount tables into sandboxes by reference via a version-keyed CSV snapshot in object storage instead of draining the whole table into web-process heap
PII_REDACTION: z.boolean().optional(), // Redact PII from workflow logs via configurable Data Retention rules (Presidio at the logger persist choke point) and expose the Data Retention config UI
+ TRIGGER_EU_REGION: z.boolean().optional(), // Route Trigger.dev runs to eu-central-1 instead of the default us-east-1 (fallback for the trigger-eu-region flag when AppConfig is not the source of truth)
// Table feature limits (per plan). Apply when billing is disabled (free tier defaults) or for billed plans.
FREE_TABLES_LIMIT: z.number().optional(), // Max user tables per workspace on free tier (default: 5)
@@ -311,6 +312,7 @@ export const env = createEnv({
PORT: z.number().optional(), // Main application port
INTERNAL_API_BASE_URL: z.string().optional(), // Optional internal base URL for server-side self-calls; must include protocol if set (e.g., http://sim-app.namespace.svc.cluster.local:3000)
ALLOWED_ORIGINS: z.string().optional(), // CORS allowed origins
+ PII_URL: z.string().optional(), // Presidio PII sidecar base URL serving /analyze + /anonymize (default http://localhost:5001)
// OAuth Integration Credentials - All optional, enables third-party integrations
GOOGLE_CLIENT_ID: z.string().optional(), // Google OAuth client ID for Google services
@@ -390,6 +392,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/core/config/feature-flags.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts
index a38f434063..5c366af622 100644
--- a/apps/sim/lib/core/config/feature-flags.test.ts
+++ b/apps/sim/lib/core/config/feature-flags.test.ts
@@ -63,6 +63,7 @@ describe('getFeatureFlags', () => {
expect(flags['tables-fractional-ordering']).toEqual({ enabled: false })
expect(flags['mothership-beta']).toEqual({ enabled: false })
expect(flags['pii-redaction']).toEqual({ enabled: false })
+ expect(flags['trigger-eu-region']).toEqual({ enabled: false })
expect(mockFetch).not.toHaveBeenCalled()
})
@@ -90,6 +91,7 @@ describe('getFeatureFlags', () => {
expect(flags['tables-fractional-ordering']).toEqual({ enabled: false })
expect(flags['mothership-beta']).toEqual({ enabled: false })
expect(flags['pii-redaction']).toEqual({ enabled: false })
+ expect(flags['trigger-eu-region']).toEqual({ enabled: false })
})
it('degrades gracefully on a malformed document', async () => {
diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts
index 6fa8ec0ebe..658b57105e 100644
--- a/apps/sim/lib/core/config/feature-flags.ts
+++ b/apps/sim/lib/core/config/feature-flags.ts
@@ -90,6 +90,13 @@ const FEATURE_FLAGS = {
'agree.',
fallback: 'PII_REDACTION',
},
+ 'trigger-eu-region': {
+ description:
+ 'Route Trigger.dev runs to eu-central-1 instead of the default us-east-1. Global on/off ' +
+ 'only — resolved without user/org context at every task-trigger call site via ' +
+ 'resolveTriggerRegion, so the whole deployment switches regions together.',
+ fallback: 'TRIGGER_EU_REGION',
+ },
} satisfies Record
/**
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/lib/guardrails/.gitignore b/apps/sim/lib/guardrails/.gitignore
deleted file mode 100644
index 3485e9bdf6..0000000000
--- a/apps/sim/lib/guardrails/.gitignore
+++ /dev/null
@@ -1,13 +0,0 @@
-# Python virtual environment
-venv/
-
-# Python cache
-__pycache__/
-*.pyc
-*.pyo
-*.pyd
-.Python
-
-# Presidio cache
-.presidio/
-
diff --git a/apps/sim/lib/guardrails/README.md b/apps/sim/lib/guardrails/README.md
index 6ce7802d22..6c0a5df970 100644
--- a/apps/sim/lib/guardrails/README.md
+++ b/apps/sim/lib/guardrails/README.md
@@ -19,22 +19,29 @@ For **hallucination detection**, you'll need:
- A knowledge base with documents
- An LLM provider API key (or use hosted models)
-### Python Validators (PII Detection)
+### PII Detection (Presidio sidecar)
-For **PII detection**, you need to set up a Python virtual environment and install Microsoft Presidio:
+PII detection runs against **one** long-lived Presidio sidecar — a combined service (built from
+`docker/pii.Dockerfile`, source in `apps/pii/server.py`) that constructs a warm `AnalyzerEngine` +
+`AnonymizerEngine` once and exposes both `/analyze` and `/anonymize` (plus `/health`) on a single
+port. In deployment it runs alongside the app container in the same ECS task; locally, build and run
+it:
```bash
-cd apps/sim/lib/guardrails
-./setup.sh
+docker build -f docker/pii.Dockerfile -t sim-pii .
+docker run -d -p 5001:5001 sim-pii
```
-This will:
-1. Create a Python virtual environment in `apps/sim/lib/guardrails/venv`
-2. Install required dependencies:
- - `presidio-analyzer` - PII detection engine
- - `presidio-anonymizer` - PII masking/anonymization
+Point the app at it (default shown):
-The TypeScript wrapper will automatically use the virtual environment's Python interpreter.
+```bash
+PII_URL=http://localhost:5001
+```
+
+The image bakes in the recognizers itself — a check-digit-validated **VIN** recognizer and
+multi-language NLP models (en/es/it/pl/fi) — so the app is a thin HTTP client (`validate_pii.ts`) with
+no Python or local venv. The redaction language is configured per rule (Data Retention) and defaults
+to English.
## Usage
@@ -93,10 +100,8 @@ See [Presidio documentation](https://microsoft.github.io/presidio/supported_enti
- `validate_json.ts` - JSON validation (TypeScript)
- `validate_regex.ts` - Regex validation (TypeScript)
- `validate_hallucination.ts` - Hallucination detection with RAG + LLM scoring (TypeScript)
-- `validate_pii.ts` - PII detection TypeScript wrapper (TypeScript)
-- `validate_pii.py` - PII detection using Microsoft Presidio (Python)
+- `validate_pii.ts` - PII detection client: calls the Presidio sidecar's /analyze + /anonymize (TypeScript)
+- `pii-entities.ts` - Client-safe PII entity + language catalog (shared by the block and Data Retention)
+- `mask-client.ts` - Internal HTTP client for batch PII masking from the log-redaction persist path
- `validate.test.ts` - Test suite for JSON and regex validators
-- `validate_hallucination.py` - Legacy Python hallucination detector (deprecated)
-- `requirements.txt` - Python dependencies for PII detection (and legacy hallucination)
-- `setup.sh` - Legacy installation script (deprecated)
diff --git a/apps/sim/lib/guardrails/mask-client.test.ts b/apps/sim/lib/guardrails/mask-client.test.ts
new file mode 100644
index 0000000000..d1c4ad5b84
--- /dev/null
+++ b/apps/sim/lib/guardrails/mask-client.test.ts
@@ -0,0 +1,68 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockToken, mockBaseUrl } = vi.hoisted(() => ({
+ mockToken: vi.fn(),
+ mockBaseUrl: vi.fn(),
+}))
+
+vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: mockToken }))
+vi.mock('@/lib/core/utils/urls', () => ({ getInternalApiBaseUrl: mockBaseUrl }))
+
+import { maskPIIBatchViaHttp } from '@/lib/guardrails/mask-client'
+
+describe('maskPIIBatchViaHttp', () => {
+ let fetchMock: ReturnType
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockToken.mockResolvedValue('tok')
+ mockBaseUrl.mockReturnValue('http://app.internal:3000')
+ fetchMock = vi.fn(async (_url: string, init: { body: string }) => {
+ const { texts } = JSON.parse(init.body) as { texts: string[] }
+ return new Response(JSON.stringify({ masked: texts.map((t) => `M(${t})`) }), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ })
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('masks a small batch in a single request, with an abort timeout', async () => {
+ const out = await maskPIIBatchViaHttp(['a', 'b', 'c'], ['EMAIL_ADDRESS'])
+
+ expect(out).toEqual(['M(a)', 'M(b)', 'M(c)'])
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal)
+ })
+
+ it('splits by count into multiple requests, preserving global order', async () => {
+ const texts = Array.from({ length: 5000 }, (_, i) => `t${i}`)
+
+ const out = await maskPIIBatchViaHttp(texts, [])
+
+ expect(out).toHaveLength(5000)
+ expect(out[0]).toBe('M(t0)')
+ expect(out[4999]).toBe('M(t4999)')
+ expect(fetchMock).toHaveBeenCalledTimes(3) // 2000-per-request cap
+ })
+
+ it('throws on a non-2xx response so the caller can scrub', async () => {
+ fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 }))
+
+ await expect(maskPIIBatchViaHttp(['a'], [])).rejects.toThrow(/mask-batch request failed/)
+ })
+
+ it('returns [] without any request for empty input', async () => {
+ const out = await maskPIIBatchViaHttp([], [])
+
+ expect(out).toEqual([])
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/guardrails/mask-client.ts b/apps/sim/lib/guardrails/mask-client.ts
new file mode 100644
index 0000000000..3fb818a3c7
--- /dev/null
+++ b/apps/sim/lib/guardrails/mask-client.ts
@@ -0,0 +1,99 @@
+import type { GuardrailsMaskBatchResult } from '@/lib/api/contracts'
+import { generateInternalToken } from '@/lib/auth/internal'
+import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
+
+/**
+ * Per-request limits. A chunk is flushed when it hits either bound, keeping each
+ * request small enough for one short Presidio pass under a tight timeout and far
+ * below the contract's 100k-entry cap — so large executions split across
+ * requests instead of failing validation.
+ */
+const REQUEST_MAX_BYTES = 256 * 1024
+const REQUEST_MAX_COUNT = 2_000
+/** Bounds one mask-batch request; an unreachable/stuck Presidio sidecar aborts so the caller scrubs. */
+const REQUEST_TIMEOUT_MS = 45_000
+
+/**
+ * Mask PII across many strings via the internal app-container endpoint.
+ *
+ * The Presidio sidecars run only in the app task, but the log-redaction persist
+ * path also runs inside the trigger.dev runtime — so redaction always routes
+ * through HTTP, the same way the guardrails tool does.
+ * Strings are grouped into byte/count-budgeted chunks; order is preserved, so
+ * the returned array matches `texts` length.
+ *
+ * Rejects on any non-2xx, timeout, or shape mismatch so the caller can apply
+ * its own fail-safe (scrubbing rather than leaking).
+ */
+export async function maskPIIBatchViaHttp(
+ texts: string[],
+ entityTypes: string[],
+ language?: string
+): Promise {
+ if (texts.length === 0) return []
+
+ const url = `${getInternalApiBaseUrl()}/api/guardrails/mask-batch`
+
+ const masked: string[] = []
+ let batch: string[] = []
+ let batchBytes = 0
+
+ const flush = async () => {
+ if (batch.length === 0) return
+ const out = await postChunk(url, batch, entityTypes, language)
+ if (out.length !== batch.length) {
+ throw new Error('PII mask-batch returned an unexpected result')
+ }
+ for (const item of out) masked.push(item)
+ batch = []
+ batchBytes = 0
+ }
+
+ for (const text of texts) {
+ const bytes = Buffer.byteLength(text, 'utf8')
+ if (
+ batch.length > 0 &&
+ (batch.length >= REQUEST_MAX_COUNT || batchBytes + bytes > REQUEST_MAX_BYTES)
+ ) {
+ await flush()
+ }
+ batch.push(text)
+ batchBytes += bytes
+ }
+ await flush()
+
+ return masked
+}
+
+async function postChunk(
+ url: string,
+ texts: string[],
+ entityTypes: string[],
+ language: string | undefined
+): Promise {
+ // Mint per request: a single token (5min TTL) can expire mid-batch when a
+ // large execution fans out into many sequential chunk requests.
+ const token = await generateInternalToken()
+
+ // boundary-raw-fetch: internal server-to-server call to the app container (internal JWT auth, configurable base URL)
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ texts, entityTypes, language }),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ })
+
+ if (!response.ok) {
+ const detail = await response.text().catch(() => '')
+ throw new Error(`PII mask-batch request failed (${response.status}): ${detail.slice(0, 200)}`)
+ }
+
+ const data = (await response.json()) as GuardrailsMaskBatchResult
+ if (!Array.isArray(data.masked)) {
+ throw new Error('PII mask-batch returned an unexpected result')
+ }
+ return data.masked
+}
diff --git a/apps/sim/lib/guardrails/pii-entities.ts b/apps/sim/lib/guardrails/pii-entities.ts
index 0e67fe22ff..c26e7dc0b9 100644
--- a/apps/sim/lib/guardrails/pii-entities.ts
+++ b/apps/sim/lib/guardrails/pii-entities.ts
@@ -51,8 +51,6 @@ export const SUPPORTED_PII_ENTITIES = {
IN_VOTER: 'Indian voter ID',
IN_PASSPORT: 'Indian passport',
FI_PERSONAL_IDENTITY_CODE: 'Finnish Personal Identity Code',
- KR_RRN: 'Korean Resident Registration Number',
- TH_TNIN: 'Thai National ID Number',
} as const
export type PIIEntityType = keyof typeof SUPPORTED_PII_ENTITIES
@@ -115,8 +113,6 @@ export const PII_ENTITY_GROUPS: ReadonlyArray<{
'IN_VOTER',
'IN_PASSPORT',
'FI_PERSONAL_IDENTITY_CODE',
- 'KR_RRN',
- 'TH_TNIN',
],
},
].map((group) => ({
@@ -126,3 +122,37 @@ export const PII_ENTITY_GROUPS: ReadonlyArray<{
label: SUPPORTED_PII_ENTITIES[value as PIIEntityType],
})),
}))
+
+/**
+ * Languages the Presidio image has NLP models for. The analyzer only recognizes a
+ * language's entities when its model is loaded, so this set must match the image.
+ */
+export const PII_LANGUAGES = [
+ { value: 'en', label: 'English' },
+ { value: 'es', label: 'Spanish' },
+ { value: 'it', label: 'Italian' },
+ { value: 'pl', label: 'Polish' },
+ { value: 'fi', label: 'Finnish' },
+] as const
+
+export type PIILanguage = (typeof PII_LANGUAGES)[number]['value']
+
+/** Non-empty tuple of language codes for schema/enum use. */
+export const PII_LANGUAGE_CODES = PII_LANGUAGES.map((l) => l.value) as [
+ PIILanguage,
+ ...PIILanguage[],
+]
+
+/** Default redaction language when a rule doesn't set one. */
+export const DEFAULT_PII_LANGUAGE: PIILanguage = 'en'
+
+/**
+ * Narrow a loosely-typed (stored/legacy) language to a supported code. Unknown or
+ * stale values (e.g. a dropped locale) return `undefined` so callers fall back to
+ * the default rather than forwarding an unsupported language to Presidio.
+ */
+export function coercePiiLanguage(value: string | undefined): PIILanguage | undefined {
+ return value && (PII_LANGUAGE_CODES as readonly string[]).includes(value)
+ ? (value as PIILanguage)
+ : undefined
+}
diff --git a/apps/sim/lib/guardrails/requirements.txt b/apps/sim/lib/guardrails/requirements.txt
deleted file mode 100644
index 135efae05b..0000000000
--- a/apps/sim/lib/guardrails/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-# Microsoft Presidio for PII detection
-presidio-analyzer>=2.2.0
-presidio-anonymizer>=2.2.0
-
diff --git a/apps/sim/lib/guardrails/setup.sh b/apps/sim/lib/guardrails/setup.sh
deleted file mode 100755
index 233e9a51a2..0000000000
--- a/apps/sim/lib/guardrails/setup.sh
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/bash
-
-# Setup script for guardrails validators
-# This creates a virtual environment and installs Python dependencies
-
-set -e
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-VENV_DIR="$SCRIPT_DIR/venv"
-
-echo "Setting up Python environment for guardrails..."
-
-# Check if Python 3 is available
-if ! command -v python3 &> /dev/null; then
- echo "Error: python3 is not installed. Please install Python 3 first."
- exit 1
-fi
-
-# Create virtual environment if it doesn't exist
-if [ ! -d "$VENV_DIR" ]; then
- echo "Creating virtual environment..."
- python3 -m venv "$VENV_DIR"
-else
- echo "Virtual environment already exists."
-fi
-
-# Activate virtual environment and install dependencies
-echo "Installing Python dependencies..."
-source "$VENV_DIR/bin/activate"
-pip install --upgrade pip
-pip install -r "$SCRIPT_DIR/requirements.txt"
-
-echo ""
-echo "✅ Setup complete! Guardrails validators are ready to use."
-echo ""
-echo "Virtual environment created at: $VENV_DIR"
-
diff --git a/apps/sim/lib/guardrails/validate_pii.py b/apps/sim/lib/guardrails/validate_pii.py
deleted file mode 100644
index d475b96e23..0000000000
--- a/apps/sim/lib/guardrails/validate_pii.py
+++ /dev/null
@@ -1,260 +0,0 @@
-#!/usr/bin/env python3
-"""
-PII Detection Validator using Microsoft Presidio
-
-Detects personally identifiable information (PII) in text and either:
-- Blocks the request if PII is detected (block mode)
-- Masks the PII and returns the masked text (mask mode)
-"""
-
-import sys
-import json
-from typing import List, Dict, Any
-
-try:
- from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer
- from presidio_anonymizer import AnonymizerEngine
- from presidio_anonymizer.entities import OperatorConfig
-except ImportError:
- print(json.dumps({
- "passed": False,
- "error": "Presidio not installed. Run: pip install presidio-analyzer presidio-anonymizer",
- "detectedEntities": []
- }))
- sys.exit(0)
-
-
-class VinRecognizer(PatternRecognizer):
- """
- Recognizes Vehicle Identification Numbers (17 chars, A-Z/0-9 excluding
- I/O/Q) and validates the ISO 3779 check digit (position 9). Validation makes
- accidental matches on arbitrary 17-char codes (request ids, SKUs, tokens)
- extremely unlikely. Note: some non-North-American VINs don't use the check
- digit and will be skipped — an intentional bias toward precision.
- """
-
- _TRANSLIT = {
- **{str(d): d for d in range(10)},
- "A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7, "H": 8,
- "J": 1, "K": 2, "L": 3, "M": 4, "N": 5, "P": 7, "R": 9,
- "S": 2, "T": 3, "U": 4, "V": 5, "W": 6, "X": 7, "Y": 8, "Z": 9,
- }
- _WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]
-
- def validate_result(self, pattern_text: str):
- vin = pattern_text.upper()
- if len(vin) != 17:
- return False
- try:
- total = sum(self._TRANSLIT[c] * w for c, w in zip(vin, self._WEIGHTS))
- except KeyError:
- return False
- check = total % 11
- expected = "X" if check == 10 else str(check)
- return vin[8] == expected
-
-
-def build_analyzer() -> "AnalyzerEngine":
- """
- AnalyzerEngine with custom recognizers registered on top of the Presidio
- defaults. Adds a check-digit-validated VIN recognizer.
- """
- analyzer = AnalyzerEngine()
- vin_pattern = Pattern(name="vin", regex=r"\b[A-HJ-NPR-Z0-9]{17}\b", score=0.7)
- vin_recognizer = VinRecognizer(
- supported_entity="VIN",
- patterns=[vin_pattern],
- context=["vin", "vehicle", "chassis"],
- )
- analyzer.registry.add_recognizer(vin_recognizer)
- return analyzer
-
-
-def detect_pii(
- text: str,
- entity_types: List[str],
- mode: str = "block",
- language: str = "en"
-) -> Dict[str, Any]:
- """
- Detect PII in text using Presidio
-
- Args:
- text: Input text to analyze
- entity_types: List of PII entity types to detect (e.g., ["PERSON", "EMAIL_ADDRESS"])
- mode: "block" to fail validation if PII found, "mask" to return masked text
- language: Language code (default: "en")
-
- Returns:
- Dictionary with validation result
- """
- try:
- # Initialize Presidio engines
- analyzer = build_analyzer()
-
- # Analyze text for PII
- results = analyzer.analyze(
- text=text,
- entities=entity_types if entity_types else None, # None = detect all
- language=language
- )
-
- # Extract detected entities
- detected_entities = []
- for result in results:
- detected_entities.append({
- "type": result.entity_type,
- "start": result.start,
- "end": result.end,
- "score": result.score,
- "text": text[result.start:result.end]
- })
-
- # If no PII detected, validation passes
- if not results:
- return {
- "passed": True,
- "detectedEntities": [],
- "maskedText": None
- }
-
- # Block mode: fail validation if PII detected
- if mode == "block":
- entity_summary = {}
- for entity in detected_entities:
- entity_type = entity["type"]
- entity_summary[entity_type] = entity_summary.get(entity_type, 0) + 1
-
- summary_str = ", ".join([f"{count} {etype}" for etype, count in entity_summary.items()])
-
- return {
- "passed": False,
- "error": f"PII detected: {summary_str}",
- "detectedEntities": detected_entities,
- "maskedText": None
- }
-
- # Mask mode: anonymize PII and return masked text
- elif mode == "mask":
- anonymizer = AnonymizerEngine()
-
- # Use as the replacement pattern
- operators = {}
- for entity_type in set([r.entity_type for r in results]):
- operators[entity_type] = OperatorConfig("replace", {"new_value": f"<{entity_type}>"})
-
- anonymized_result = anonymizer.anonymize(
- text=text,
- analyzer_results=results,
- operators=operators
- )
-
- return {
- "passed": True,
- "detectedEntities": detected_entities,
- "maskedText": anonymized_result.text
- }
-
- else:
- return {
- "passed": False,
- "error": f"Invalid mode: {mode}. Must be 'block' or 'mask'",
- "detectedEntities": []
- }
-
- except Exception as e:
- return {
- "passed": False,
- "error": f"PII detection failed: {str(e)}",
- "detectedEntities": []
- }
-
-
-def mask_batch(
- texts: List[str],
- entity_types: List[str],
- language: str = "en"
-) -> Dict[str, Any]:
- """
- Mask PII across many strings in a single process, reusing one analyzer +
- anonymizer instance (engine construction loads the spaCy model and is the
- dominant cost). Returns masked text per input, in input order; strings with
- no detected PII are returned unchanged so callers can substitute directly.
- """
- analyzer = build_analyzer()
- anonymizer = AnonymizerEngine()
- entities = entity_types if entity_types else None
-
- results = []
- for text in texts:
- if not text:
- results.append({"maskedText": text})
- continue
- analyzer_results = analyzer.analyze(text=text, entities=entities, language=language)
- if not analyzer_results:
- results.append({"maskedText": text})
- continue
- operators = {
- entity_type: OperatorConfig("replace", {"new_value": f"<{entity_type}>"})
- for entity_type in set([r.entity_type for r in analyzer_results])
- }
- anonymized = anonymizer.anonymize(
- text=text,
- analyzer_results=analyzer_results,
- operators=operators
- )
- results.append({"maskedText": anonymized.text})
-
- return {"passed": True, "results": results}
-
-
-def main():
- """Main entry point for CLI usage"""
- try:
- # Read input from stdin
- input_data = sys.stdin.read()
- data = json.loads(input_data)
-
- entity_types = data.get("entityTypes", [])
- language = data.get("language", "en")
-
- # Batch mask mode: an array of texts processed with one warm engine pair.
- if "texts" in data:
- texts = data.get("texts", [])
- result = mask_batch(texts, entity_types, language)
- print(f"__SIM_RESULT__={json.dumps(result)}")
- return
-
- text = data.get("text", "")
- mode = data.get("mode", "block")
-
- # Validate inputs
- if not text:
- result = {
- "passed": False,
- "error": "No text provided",
- "detectedEntities": []
- }
- else:
- result = detect_pii(text, entity_types, mode, language)
-
- # Output result with marker for parsing
- print(f"__SIM_RESULT__={json.dumps(result)}")
-
- except json.JSONDecodeError as e:
- print(f"__SIM_RESULT__={json.dumps({
- 'passed': False,
- 'error': f'Invalid JSON input: {str(e)}',
- 'detectedEntities': []
- })}")
- except Exception as e:
- print(f"__SIM_RESULT__={json.dumps({
- 'passed': False,
- 'error': f'Unexpected error: {str(e)}',
- 'detectedEntities': []
- })}")
-
-
-if __name__ == "__main__":
- main()
-
diff --git a/apps/sim/lib/guardrails/validate_pii.test.ts b/apps/sim/lib/guardrails/validate_pii.test.ts
new file mode 100644
index 0000000000..0ba1c585bc
--- /dev/null
+++ b/apps/sim/lib/guardrails/validate_pii.test.ts
@@ -0,0 +1,118 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { maskPIIBatch, validatePII } from '@/lib/guardrails/validate_pii'
+
+interface Span {
+ entity_type: string
+ start: number
+ end: number
+ score: number
+}
+
+/** Mimic the Presidio anonymizer's default `replace`: each span → ``. */
+function applyReplace(text: string, results: Span[]): string {
+ let out = text
+ for (const s of [...results].sort((a, b) => b.start - a.start)) {
+ out = `${out.slice(0, s.start)}<${s.entity_type}>${out.slice(s.end)}`
+ }
+ return out
+}
+
+/** Analyzer mock: flags `a@b.com` as EMAIL_ADDRESS when that entity is in scope. */
+function emailSpans(text: string, entities: string[] | undefined): Span[] {
+ if (entities && !entities.includes('EMAIL_ADDRESS')) return []
+ const idx = text.indexOf('a@b.com')
+ return idx === -1 ? [] : [{ entity_type: 'EMAIL_ADDRESS', start: idx, end: idx + 7, score: 0.9 }]
+}
+
+describe('validate_pii (Presidio sidecar)', () => {
+ let analyzeBodies: Array<{ text: string; language: string; entities?: string[] }>
+ let fetchMock: ReturnType
+
+ beforeEach(() => {
+ analyzeBodies = []
+ fetchMock = vi.fn(async (url: string, init: { body: string }) => {
+ const body = JSON.parse(init.body)
+ if (url.includes('/analyze')) {
+ analyzeBodies.push({ text: body.text, language: body.language, entities: body.entities })
+ return new Response(JSON.stringify(emailSpans(body.text, body.entities)), { status: 200 })
+ }
+ // /anonymize
+ return new Response(
+ JSON.stringify({ text: applyReplace(body.text, body.analyzer_results) }),
+ {
+ status: 200,
+ }
+ )
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ })
+
+ afterEach(() => vi.unstubAllGlobals())
+
+ describe('maskPIIBatch', () => {
+ it('masks detected entities, preserving input order', async () => {
+ const out = await maskPIIBatch(['email a@b.com', 'nothing here'], [])
+ expect(out[0]).toBe('email ')
+ expect(out[1]).toBe('nothing here')
+ })
+
+ it('forwards entityTypes (and language) to the analyzer; empty ⇒ omitted (all)', async () => {
+ await maskPIIBatch(['mail a@b.com'], ['EMAIL_ADDRESS', 'PERSON'], 'es')
+ expect(analyzeBodies[0].entities).toEqual(['EMAIL_ADDRESS', 'PERSON'])
+ expect(analyzeBodies[0].language).toBe('es')
+
+ analyzeBodies.length = 0
+ await maskPIIBatch(['mail a@b.com'], [])
+ expect(analyzeBodies[0].entities).toBeUndefined()
+ })
+
+ it('returns [] for empty input and leaves empty strings untouched', async () => {
+ expect(await maskPIIBatch([], [])).toEqual([])
+ expect(await maskPIIBatch([''], [])).toEqual([''])
+ })
+
+ it('throws on a sidecar failure so the caller can scrub', async () => {
+ fetchMock.mockResolvedValueOnce(new Response('boom', { status: 500 }))
+ await expect(maskPIIBatch(['email a@b.com'], [])).rejects.toThrow(/Presidio analyze failed/)
+ })
+ })
+
+ describe('validatePII', () => {
+ it('block mode fails with a summary when PII is detected', async () => {
+ const res = await validatePII({
+ text: 'reach me at a@b.com',
+ entityTypes: [],
+ mode: 'block',
+ requestId: 'r1',
+ })
+ expect(res.passed).toBe(false)
+ expect(res.error).toContain('EMAIL_ADDRESS')
+ expect(res.detectedEntities).toHaveLength(1)
+ })
+
+ it('mask mode returns masked text', async () => {
+ const res = await validatePII({
+ text: 'mail a@b.com',
+ entityTypes: [],
+ mode: 'mask',
+ requestId: 'r2',
+ })
+ expect(res.passed).toBe(true)
+ expect(res.maskedText).toBe('mail ')
+ })
+
+ it('passes clean text', async () => {
+ const res = await validatePII({
+ text: 'nothing to see',
+ entityTypes: [],
+ mode: 'block',
+ requestId: 'r3',
+ })
+ expect(res.passed).toBe(true)
+ expect(res.detectedEntities).toHaveLength(0)
+ })
+ })
+})
diff --git a/apps/sim/lib/guardrails/validate_pii.ts b/apps/sim/lib/guardrails/validate_pii.ts
index ba6886bb92..a24c8f880e 100644
--- a/apps/sim/lib/guardrails/validate_pii.ts
+++ b/apps/sim/lib/guardrails/validate_pii.ts
@@ -1,17 +1,18 @@
-import { spawn } from 'child_process'
-import fs from 'fs'
-import path from 'path'
import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { env } from '@/lib/core/config/env'
+import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
const logger = createLogger('PIIValidator')
-const DEFAULT_TIMEOUT = 30000 // 30 seconds
-/**
- * Max total bytes of text sent to a single Presidio subprocess. spaCy NER is the
- * bottleneck, so large payloads are split into multiple short calls instead of
- * one that risks the 30s timeout.
- */
-const PII_CHUNK_MAX_BYTES = 256 * 1024
+/** Just above the analyzer's spaCy NER budget so a stuck sidecar aborts gracefully. */
+const REQUEST_TIMEOUT_MS = 45_000
+
+/** Concurrent per-string sidecar calls within one batch; the warm model handles parallelism. */
+const MASK_CONCURRENCY = 8
+
+/** Single Presidio sidecar serving both /analyze and /anonymize (VIN is native there). */
+const PII_URL = env.PII_URL || 'http://localhost:5001'
export interface PIIValidationInput {
text: string
@@ -36,12 +37,65 @@ export interface PIIValidationResult {
maskedText?: string
}
+interface AnalyzerSpan {
+ entity_type: string
+ start: number
+ end: number
+ score: number
+}
+
/**
- * Validate text for PII using Microsoft Presidio
+ * Detect PII spans via the Presidio analyzer. An empty `entityTypes` ⇒ detect all.
+ * Throws on transport/HTTP failure so callers can apply their own fail-safe.
+ */
+async function analyze(
+ text: string,
+ entityTypes: string[],
+ language: string
+): Promise {
+ const entities = entityTypes.length > 0 ? entityTypes : undefined
+
+ // boundary-raw-fetch: internal call to the Presidio analyzer sidecar over localhost
+ const response = await fetch(`${PII_URL}/analyze`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ text, language, ...(entities ? { entities } : {}) }),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ })
+ if (!response.ok) {
+ const detail = await response.text().catch(() => '')
+ throw new Error(`Presidio analyze failed (${response.status}): ${detail.slice(0, 200)}`)
+ }
+ return (await response.json()) as AnalyzerSpan[]
+}
+
+/**
+ * Mask spans via the Presidio anonymizer sidecar. Omitting `anonymizers` uses the
+ * default `replace` operator, which yields ``. Throws on failure.
+ */
+async function anonymize(text: string, spans: AnalyzerSpan[]): Promise {
+ if (spans.length === 0) return text
+
+ // boundary-raw-fetch: internal call to the Presidio anonymizer sidecar over localhost
+ const response = await fetch(`${PII_URL}/anonymize`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ text, analyzer_results: spans }),
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ })
+ if (!response.ok) {
+ const detail = await response.text().catch(() => '')
+ throw new Error(`Presidio anonymize failed (${response.status}): ${detail.slice(0, 200)}`)
+ }
+ const data = (await response.json()) as { text: string }
+ return data.text
+}
+
+/**
+ * Validate text for PII using the Presidio sidecar.
*
- * Supports two modes:
- * - block: Fails validation if any PII is detected
- * - mask: Passes validation and returns masked text with PII replaced
+ * - block: fails validation if any PII is detected
+ * - mask: passes and returns masked text with PII replaced by ``
*/
export async function validatePII(input: PIIValidationInput): Promise {
const { text, entityTypes, mode, language = 'en', requestId } = input
@@ -54,41 +108,60 @@ export async function validatePII(input: PIIValidationInput): Promise ({
+ type: s.entity_type,
+ start: s.start,
+ end: s.end,
+ score: s.score,
+ text: text.slice(s.start, s.end),
+ }))
+
+ if (spans.length === 0) {
+ logger.info(`[${requestId}] PII validation completed`, { passed: true, detectedCount: 0 })
+ return { passed: true, detectedEntities: [], maskedText: mode === 'mask' ? text : undefined }
+ }
- logger.info(`[${requestId}] PII validation completed`, {
- passed: result.passed,
- detectedCount: result.detectedEntities.length,
- hasMaskedText: !!result.maskedText,
- })
+ if (mode === 'block') {
+ const counts = new Map()
+ for (const e of detectedEntities) counts.set(e.type, (counts.get(e.type) ?? 0) + 1)
+ const summary = Array.from(counts.entries())
+ .map(([type, count]) => `${count} ${type}`)
+ .join(', ')
+ logger.info(`[${requestId}] PII validation completed`, {
+ passed: false,
+ detectedCount: detectedEntities.length,
+ })
+ return { passed: false, error: `PII detected: ${summary}`, detectedEntities }
+ }
- return result
- } catch (error: any) {
- logger.error(`[${requestId}] PII validation failed`, {
- error: error.message,
+ // mask mode: the anonymizer replaces every span with ``.
+ const maskedText = await anonymize(text, spans)
+ logger.info(`[${requestId}] PII validation completed`, {
+ passed: true,
+ detectedCount: detectedEntities.length,
+ hasMaskedText: true,
})
-
+ return { passed: true, detectedEntities, maskedText }
+ } catch (error) {
+ logger.error(`[${requestId}] PII validation failed`, { error: getErrorMessage(error) })
return {
passed: false,
- error: `PII validation failed: ${error.message}`,
+ error: `PII validation failed: ${getErrorMessage(error)}`,
detectedEntities: [],
}
}
}
-interface PIIMaskBatchResult {
- passed: boolean
- error?: string
- results?: { maskedText: string }[]
-}
-
/**
- * Mask PII across many strings, preserving input order. Strings are grouped into
- * byte-budgeted chunks so no single subprocess exceeds {@link PII_CHUNK_MAX_BYTES}
- * (keeping each call well under the 30s timeout). One Presidio engine pair is
- * reused per subprocess invocation. Rejects on any subprocess failure so callers
- * can apply their own fail-safe.
+ * Mask PII across many strings via the Presidio sidecar, preserving input order.
+ * Each string runs analyze → anonymize; strings with no detected PII are returned
+ * unchanged. Calls run with bounded concurrency: the sidecar's model is warm, so
+ * the bottleneck is round-trip latency, and a batch of thousands of small leaves
+ * would otherwise exceed the caller's request timeout if run strictly sequentially.
+ * Rejects on any sidecar failure (which fails the whole batch) so callers can apply
+ * their own fail-safe (scrub).
*/
export async function maskPIIBatch(
texts: string[],
@@ -97,223 +170,10 @@ export async function maskPIIBatch(
): Promise {
if (texts.length === 0) return []
- const chunks: string[][] = []
- let current: string[] = []
- let currentBytes = 0
- for (const text of texts) {
- const bytes = Buffer.byteLength(text, 'utf8')
- if (current.length > 0 && currentBytes + bytes > PII_CHUNK_MAX_BYTES) {
- chunks.push(current)
- current = []
- currentBytes = 0
- }
- current.push(text)
- currentBytes += bytes
- }
- if (current.length > 0) chunks.push(current)
-
- const masked: string[] = []
- for (const chunk of chunks) {
- const result = await runPythonScript({
- texts: chunk,
- entityTypes,
- mode: 'mask',
- language,
- })
- if (!result.passed || !result.results || result.results.length !== chunk.length) {
- throw new Error(result.error || 'PII batch masking returned an unexpected result')
- }
- for (const item of result.results) masked.push(item.maskedText)
- }
-
- return masked
-}
-
-/**
- * Spawn the Presidio Python script, write the payload to stdin as JSON, and parse
- * the `__SIM_RESULT__=` marker from stdout. Rejects on non-zero exit, timeout,
- * spawn failure, or a missing/unparseable marker.
- */
-function runPythonScript(payload: Record): Promise {
- return new Promise((resolve, reject) => {
- const guardrailsDir = path.join(process.cwd(), 'lib/guardrails')
- const scriptPath = path.join(guardrailsDir, 'validate_pii.py')
- const venvPython = path.join(guardrailsDir, 'venv/bin/python3')
- const pythonCmd = fs.existsSync(venvPython) ? venvPython : 'python3'
-
- const python = spawn(pythonCmd, [scriptPath])
- let stdout = ''
- let stderr = ''
-
- const timeout = setTimeout(() => {
- python.kill()
- reject(new Error('PII processing timeout'))
- }, DEFAULT_TIMEOUT)
-
- // stdin errors (e.g. EPIPE when the child exits before draining the payload —
- // chunks can exceed the OS pipe buffer) emit on stdin, not the process. Without
- // a listener Node throws an unhandled 'error' and crashes; funnel it into the
- // promise so the caller's fail-safe scrub path handles it.
- python.stdin.on('error', (error: Error) => {
- clearTimeout(timeout)
- reject(new Error(`PII script stdin error: ${error.message}`))
- })
- python.stdin.write(JSON.stringify(payload))
- python.stdin.end()
- python.stdout.on('data', (data) => {
- stdout += data.toString()
- })
- python.stderr.on('data', (data) => {
- stderr += data.toString()
- })
-
- python.on('close', (code) => {
- clearTimeout(timeout)
- if (code !== 0) {
- reject(new Error(stderr || `PII script exited with code ${code}`))
- return
- }
- const prefix = '__SIM_RESULT__='
- const marker = stdout.split('\n').find((l) => l.startsWith(prefix))
- if (!marker) {
- reject(new Error(`No result marker in PII script output: ${stdout.substring(0, 200)}`))
- return
- }
- try {
- resolve(JSON.parse(marker.slice(prefix.length)) as T)
- } catch (error: any) {
- reject(new Error(`Failed to parse PII script result: ${error.message}`))
- }
- })
-
- python.on('error', (error) => {
- clearTimeout(timeout)
- reject(
- new Error(
- `Failed to execute Python: ${error.message}. Make sure Python 3 and Presidio are installed.`
- )
- )
- })
- })
-}
-
-/**
- * Execute Python PII detection script
- */
-async function executePythonPIIDetection(
- text: string,
- entityTypes: string[],
- mode: string,
- language: string,
- requestId: string
-): Promise {
- return new Promise((resolve, reject) => {
- // Use path relative to project root
- // In Next.js, process.cwd() returns the project root
- const guardrailsDir = path.join(process.cwd(), 'lib/guardrails')
- const scriptPath = path.join(guardrailsDir, 'validate_pii.py')
- const venvPython = path.join(guardrailsDir, 'venv/bin/python3')
-
- // Use venv Python if it exists, otherwise fall back to system python3
- const pythonCmd = fs.existsSync(venvPython) ? venvPython : 'python3'
-
- const python = spawn(pythonCmd, [scriptPath])
-
- let stdout = ''
- let stderr = ''
-
- const timeout = setTimeout(() => {
- python.kill()
- reject(new Error('PII validation timeout'))
- }, DEFAULT_TIMEOUT)
-
- // Write input to stdin as JSON
- const inputData = JSON.stringify({
- text,
- entityTypes,
- mode,
- language,
- })
- // See runPythonScript: stdin errors (EPIPE on early child exit) must be
- // caught here or Node throws an unhandled 'error' and crashes the process.
- python.stdin.on('error', (error: Error) => {
- clearTimeout(timeout)
- reject(new Error(`Failed to write to Python: ${error.message}`))
- })
- python.stdin.write(inputData)
- python.stdin.end()
-
- python.stdout.on('data', (data) => {
- stdout += data.toString()
- })
-
- python.stderr.on('data', (data) => {
- stderr += data.toString()
- })
-
- python.on('close', (code) => {
- clearTimeout(timeout)
-
- if (code !== 0) {
- logger.error(`[${requestId}] Python PII detection failed`, {
- code,
- stderr,
- })
- resolve({
- passed: false,
- error: stderr || 'PII detection failed',
- detectedEntities: [],
- })
- return
- }
-
- // Parse result from stdout
- try {
- const prefix = '__SIM_RESULT__='
- const lines = stdout.split('\n')
- const marker = lines.find((l) => l.startsWith(prefix))
-
- if (marker) {
- const jsonPart = marker.slice(prefix.length)
- const result = JSON.parse(jsonPart)
- resolve(result)
- } else {
- logger.error(`[${requestId}] No result marker found`, {
- stdout,
- stderr,
- stdoutLines: lines,
- })
- resolve({
- passed: false,
- error: `No result marker found in output. stdout: ${stdout.substring(0, 200)}, stderr: ${stderr.substring(0, 200)}`,
- detectedEntities: [],
- })
- }
- } catch (error: any) {
- logger.error(`[${requestId}] Failed to parse Python result`, {
- error: error.message,
- stdout,
- stderr,
- })
- resolve({
- passed: false,
- error: `Failed to parse result: ${error.message}. stdout: ${stdout.substring(0, 200)}`,
- detectedEntities: [],
- })
- }
- })
-
- python.on('error', (error) => {
- clearTimeout(timeout)
- logger.error(`[${requestId}] Failed to spawn Python process`, {
- error: error.message,
- })
- reject(
- new Error(
- `Failed to execute Python: ${error.message}. Make sure Python 3 and Presidio are installed.`
- )
- )
- })
+ return mapWithConcurrency(texts, MASK_CONCURRENCY, async (text) => {
+ if (!text) return text
+ const spans = await analyze(text, entityTypes, language)
+ return anonymize(text, spans)
})
}
diff --git a/apps/sim/lib/knowledge/connectors/sync-engine.ts b/apps/sim/lib/knowledge/connectors/sync-engine.ts
index c3423b455d..cbd18b4333 100644
--- a/apps/sim/lib/knowledge/connectors/sync-engine.ts
+++ b/apps/sim/lib/knowledge/connectors/sync-engine.ts
@@ -12,6 +12,7 @@ import { generateId } from '@sim/utils/id'
import { randomInt } from '@sim/utils/random'
import { and, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from 'drizzle-orm'
import { decryptApiKey } from '@/lib/api-key/crypto'
+import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import type { DocumentData } from '@/lib/knowledge/documents/service'
import {
@@ -325,7 +326,7 @@ export async function dispatchSync(
fullSync: options?.fullSync,
requestId,
},
- { tags }
+ { tags, region: await resolveTriggerRegion() }
)
logger.info(`Dispatched connector sync to Trigger.dev`, { connectorId, requestId })
} else {
diff --git a/apps/sim/lib/knowledge/documents/service.ts b/apps/sim/lib/knowledge/documents/service.ts
index bce2932ebf..fde428f467 100644
--- a/apps/sim/lib/knowledge/documents/service.ts
+++ b/apps/sim/lib/knowledge/documents/service.ts
@@ -32,6 +32,7 @@ import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
import { recordUsage } from '@/lib/billing/core/usage-log'
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types'
+import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
import { env, envNumber } from '@/lib/core/config/env'
import { getCostMultiplier, isTriggerDevEnabled } from '@/lib/core/config/env-flags'
import { processDocument } from '@/lib/knowledge/documents/document-processor'
@@ -447,6 +448,7 @@ async function dispatchViaBatchTrigger(
): Promise {
let dispatched = 0
const batchIds: string[] = []
+ const region = await resolveTriggerRegion()
for (let i = 0; i < jobPayloads.length; i += TRIGGER_BATCH_SIZE) {
const chunk = jobPayloads.slice(i, i + TRIGGER_BATCH_SIZE)
try {
@@ -462,6 +464,7 @@ async function dispatchViaBatchTrigger(
`knowledgeBaseId:${payload.knowledgeBaseId}`,
`documentId:${payload.documentId}`,
],
+ region,
},
}))
)
diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts
index 9a531b7293..e68ad3100f 100644
--- a/apps/sim/lib/logs/execution/logger.ts
+++ b/apps/sim/lib/logs/execution/logger.ts
@@ -620,7 +620,10 @@ export class ExecutionLogger implements IExecutionLoggerService {
const config = resolveEffectivePiiRedaction({ orgSettings: row.orgSettings, workspaceId })
if (!config.enabled) return payload
- return redactPIIFromExecution(payload, { entityTypes: config.entityTypes })
+ return redactPIIFromExecution(payload, {
+ entityTypes: config.entityTypes,
+ language: config.language,
+ })
}
async completeWorkflowExecution(params: {
diff --git a/apps/sim/lib/logs/execution/pii-redaction.test.ts b/apps/sim/lib/logs/execution/pii-redaction.test.ts
index dccbc59cc3..5a2da7a599 100644
--- a/apps/sim/lib/logs/execution/pii-redaction.test.ts
+++ b/apps/sim/lib/logs/execution/pii-redaction.test.ts
@@ -7,8 +7,8 @@ const { mockMaskPIIBatch } = vi.hoisted(() => ({
mockMaskPIIBatch: vi.fn(),
}))
-vi.mock('@/lib/guardrails/validate_pii', () => ({
- maskPIIBatch: mockMaskPIIBatch,
+vi.mock('@/lib/guardrails/mask-client', () => ({
+ maskPIIBatchViaHttp: mockMaskPIIBatch,
}))
import { REDACTION_FAILED_MARKER, redactPIIFromExecution } from '@/lib/logs/execution/pii-redaction'
diff --git a/apps/sim/lib/logs/execution/pii-redaction.ts b/apps/sim/lib/logs/execution/pii-redaction.ts
index 7b4794fd48..8cd0fac532 100644
--- a/apps/sim/lib/logs/execution/pii-redaction.ts
+++ b/apps/sim/lib/logs/execution/pii-redaction.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
+import { maskPIIBatchViaHttp } from '@/lib/guardrails/mask-client'
const logger = createLogger('PiiRedaction')
@@ -158,11 +159,9 @@ export async function redactPIIFromExecution(
masked = collected.map(() => REDACTION_FAILED_MARKER)
} else {
try {
- // Lazy import keeps the Python-spawning guardrails module (child_process +
- // a `lib/guardrails` dir reference) out of the static middleware/RSC graph;
- // it's only loaded at runtime on the Node log-persist path.
- const { maskPIIBatch } = await import('@/lib/guardrails/validate_pii')
- masked = await maskPIIBatch(collected, entityTypes, language)
+ // Presidio runs only in the app container; the persist path also runs in
+ // the trigger.dev runtime, so masking always goes over HTTP to the app.
+ masked = await maskPIIBatchViaHttp(collected, entityTypes, language)
} catch (error) {
logger.error('PII masking failed; scrubbing text to avoid leaking PII', {
error: getErrorMessage(error),
diff --git a/apps/sim/lib/messaging/lifecycle.ts b/apps/sim/lib/messaging/lifecycle.ts
index 95b109d9cb..f4a871cd87 100644
--- a/apps/sim/lib/messaging/lifecycle.ts
+++ b/apps/sim/lib/messaging/lifecycle.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { tasks } from '@trigger.dev/sdk'
+import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
import { env } from '@/lib/core/config/env'
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
@@ -41,6 +42,7 @@ export async function scheduleLifecycleEmail({
{
delay: delayUntil,
idempotencyKey: `lifecycle-${type}-${userId}`,
+ region: await resolveTriggerRegion(),
}
)
diff --git a/apps/sim/lib/table/backfill-runner.ts b/apps/sim/lib/table/backfill-runner.ts
index 387c89e145..b368e8ca0a 100644
--- a/apps/sim/lib/table/backfill-runner.ts
+++ b/apps/sim/lib/table/backfill-runner.ts
@@ -319,12 +319,14 @@ export async function maybeBackfillGroupOutputs(opts: {
}
if (isTriggerDevEnabled) {
try {
- const [{ tableBackfillTask }, { tasks }] = await Promise.all([
+ const [{ tableBackfillTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
import('@/background/table-backfill'),
import('@trigger.dev/sdk'),
+ import('@/lib/core/async-jobs/region'),
])
await tasks.trigger('table-backfill', payload, {
tags: [`tableId:${table.id}`, `jobId:${jobId}`],
+ region: await resolveTriggerRegion(),
})
} catch (error) {
// Release the claim so a ghost `running` job doesn't block imports/deletes.
diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts
index bfa7de1a36..c60a04efb4 100644
--- a/apps/sim/lib/table/workflow-columns.ts
+++ b/apps/sim/lib/table/workflow-columns.ts
@@ -764,14 +764,15 @@ export async function runWorkflowColumn(opts: {
if (isTriggerDevEnabled) {
// Trigger.dev runs `tableRunDispatcherTask`, which loops `dispatcherStep`
// until done with CRIU-checkpointed waits between windows.
- const [{ tableRunDispatcherTask }, { tasks }] = await Promise.all([
+ const [{ tableRunDispatcherTask }, { tasks }, { resolveTriggerRegion }] = await Promise.all([
import('@/background/table-run-dispatcher'),
import('@trigger.dev/sdk'),
+ import('@/lib/core/async-jobs/region'),
])
await tasks.trigger(
'table-run-dispatcher',
{ dispatchId },
- { concurrencyKey: dispatchId }
+ { concurrencyKey: dispatchId, region: await resolveTriggerRegion() }
)
} else {
// Local / no-trigger.dev: drive the same loop in-process, fire-and-forget
diff --git a/apps/sim/lib/tokenization/constants.ts b/apps/sim/lib/tokenization/constants.ts
index a10b1995da..484a397f84 100644
--- a/apps/sim/lib/tokenization/constants.ts
+++ b/apps/sim/lib/tokenization/constants.ts
@@ -56,6 +56,11 @@ export const TOKENIZATION_CONFIG = {
confidence: 'medium',
supportedMethods: ['heuristic', 'fallback'],
},
+ sakana: {
+ avgCharsPerToken: 4,
+ confidence: 'medium',
+ supportedMethods: ['heuristic', 'fallback'],
+ },
ollama: {
avgCharsPerToken: 4,
confidence: 'low',
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/attachments.ts b/apps/sim/providers/attachments.ts
index 6be9fb6b91..b87307ea81 100644
--- a/apps/sim/providers/attachments.ts
+++ b/apps/sim/providers/attachments.ts
@@ -35,6 +35,7 @@ export type AttachmentProvider =
| 'xai'
| 'deepseek'
| 'cerebras'
+ | 'sakana'
export interface PreparedProviderAttachment {
file: UserFile
@@ -118,7 +119,7 @@ const BEDROCK_DOCUMENT_FORMATS = new Set([
const BEDROCK_IMAGE_FORMATS = new Set(['png', 'jpeg', 'jpg', 'gif', 'webp'])
const BEDROCK_VIDEO_FORMATS = new Set(['mp4', 'mov', 'mkv', 'webm'])
-const UNSUPPORTED_FILE_PROVIDERS = new Set(['deepseek', 'cerebras'])
+const UNSUPPORTED_FILE_PROVIDERS = new Set(['deepseek', 'cerebras', 'sakana'])
const PROVIDER_SUPPORTED_LABELS: Record = {
openai: 'images and documents through the Responses API input_image/input_file parts',
@@ -137,6 +138,7 @@ const PROVIDER_SUPPORTED_LABELS: Record = {
xai: 'images through image_url message parts on Grok vision models',
deepseek: 'no file attachments in the current API adapter',
cerebras: 'no file attachments in the current API adapter',
+ sakana: 'no file attachments in the current API adapter',
}
export function getAttachmentProvider(providerId: ProviderId | string): AttachmentProvider | null {
@@ -156,6 +158,7 @@ export function getAttachmentProvider(providerId: ProviderId | string): Attachme
if (providerId === 'xai') return 'xai'
if (providerId === 'deepseek') return 'deepseek'
if (providerId === 'cerebras') return 'cerebras'
+ if (providerId === 'sakana') return 'sakana'
return null
}
@@ -303,6 +306,7 @@ function isMimeTypeSupportedByProvider(
return isImageMimeType(mimeType)
case 'deepseek':
case 'cerebras':
+ case 'sakana':
return false
default: {
const _exhaustive: never = provider
diff --git a/apps/sim/providers/models.test.ts b/apps/sim/providers/models.test.ts
index ca9af8a07c..b3b16b54bc 100644
--- a/apps/sim/providers/models.test.ts
+++ b/apps/sim/providers/models.test.ts
@@ -102,3 +102,35 @@ describe('orderModelIdsByReleaseDate', () => {
expect([...ordered].sort()).toEqual([...input].sort())
})
})
+
+describe('sakana provider definition', () => {
+ const sakana = PROVIDER_DEFINITIONS.sakana
+
+ it('is registered with fugu as the default model', () => {
+ expect(sakana).toBeDefined()
+ expect(sakana.id).toBe('sakana')
+ expect(sakana.defaultModel).toBe('fugu')
+ expect(sakana.modelPatterns).toEqual([/^fugu/])
+ })
+
+ it('exposes fugu and fugu-ultra with a 1M context window', () => {
+ expect(sakana.models.map((m) => m.id)).toEqual(['fugu', 'fugu-ultra'])
+ for (const model of sakana.models) {
+ expect(model.contextWindow).toBe(1000000)
+ }
+ })
+
+ it('prices both models at the documented fugu-ultra ceiling', () => {
+ for (const model of sakana.models) {
+ expect(model.pricing.input).toBe(5)
+ expect(model.pricing.output).toBe(30)
+ expect(model.pricing.cachedInput).toBe(0.5)
+ }
+ })
+
+ it('routes bare fugu model IDs to the sakana provider', () => {
+ const baseModels = getBaseModelProviders()
+ expect(baseModels.fugu).toBe('sakana')
+ expect(baseModels['fugu-ultra']).toBe('sakana')
+ })
+})
diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts
index 99aaf203cf..3666033af1 100644
--- a/apps/sim/providers/models.ts
+++ b/apps/sim/providers/models.ts
@@ -23,6 +23,7 @@ import {
OllamaIcon,
OpenAIIcon,
OpenRouterIcon,
+ SakanaIcon,
TogetherIcon,
VertexIcon,
VllmIcon,
@@ -2197,6 +2198,47 @@ export const PROVIDER_DEFINITIONS: Record = {
},
],
},
+ sakana: {
+ id: 'sakana',
+ name: 'Sakana AI',
+ description: "Sakana AI's Fugu multi-agent models via an OpenAI-compatible API",
+ defaultModel: 'fugu',
+ modelPatterns: [/^fugu/],
+ icon: SakanaIcon,
+ color: '#E60000',
+ capabilities: {
+ temperature: { min: 0, max: 2 },
+ toolUsageControl: true,
+ },
+ models: [
+ {
+ id: 'fugu',
+ pricing: {
+ input: 5,
+ cachedInput: 0.5,
+ output: 30,
+ updatedAt: '2026-06-22',
+ },
+ capabilities: {},
+ contextWindow: 1000000,
+ releaseDate: '2026-06-15',
+ speedOptimized: true,
+ },
+ {
+ id: 'fugu-ultra',
+ pricing: {
+ input: 5,
+ cachedInput: 0.5,
+ output: 30,
+ updatedAt: '2026-06-22',
+ },
+ capabilities: {},
+ contextWindow: 1000000,
+ releaseDate: '2026-06-15',
+ recommended: true,
+ },
+ ],
+ },
mistral: {
id: 'mistral',
name: 'Mistral AI',
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/providers/registry.ts b/apps/sim/providers/registry.ts
index 5e65e92796..cb7d1a9cd0 100644
--- a/apps/sim/providers/registry.ts
+++ b/apps/sim/providers/registry.ts
@@ -16,6 +16,7 @@ import { ollamaProvider } from '@/providers/ollama'
import { ollamaCloudProvider } from '@/providers/ollama-cloud'
import { openaiProvider } from '@/providers/openai'
import { openRouterProvider } from '@/providers/openrouter'
+import { sakanaProvider } from '@/providers/sakana'
import { togetherProvider } from '@/providers/together'
import type { ProviderConfig, ProviderId } from '@/providers/types'
import { vertexProvider } from '@/providers/vertex'
@@ -34,6 +35,7 @@ const providerRegistry: Record = {
xai: xAIProvider,
cerebras: cerebrasProvider,
groq: groqProvider,
+ sakana: sakanaProvider,
vllm: vllmProvider,
litellm: litellmProvider,
mistral: mistralProvider,
diff --git a/apps/sim/providers/sakana/index.ts b/apps/sim/providers/sakana/index.ts
new file mode 100644
index 0000000000..3988d3e96d
--- /dev/null
+++ b/apps/sim/providers/sakana/index.ts
@@ -0,0 +1,632 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage, toError } from '@sim/utils/errors'
+import OpenAI from 'openai'
+import type { StreamingExecution } from '@/executor/types'
+import { MAX_TOOL_ITERATIONS } from '@/providers'
+import { formatMessagesForProvider } from '@/providers/attachments'
+import { getProviderDefaultModel, getProviderModels } from '@/providers/models'
+import { createReadableStreamFromSakanaStream } from '@/providers/sakana/utils'
+import { createStreamingExecution } from '@/providers/streaming-execution'
+import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
+import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment'
+import type {
+ ProviderConfig,
+ ProviderRequest,
+ ProviderResponse,
+ TimeSegment,
+} from '@/providers/types'
+import { ProviderError } from '@/providers/types'
+import {
+ calculateCost,
+ prepareToolExecution,
+ prepareToolsWithUsageControl,
+ sumToolCosts,
+ trackForcedToolUsage,
+} from '@/providers/utils'
+import { executeTool } from '@/tools'
+
+const logger = createLogger('SakanaProvider')
+
+const SAKANA_BASE_URL = 'https://api.sakana.ai/v1'
+
+export const sakanaProvider: ProviderConfig = {
+ id: 'sakana',
+ name: 'Sakana AI',
+ description: "Sakana AI's Fugu multi-agent models via an OpenAI-compatible API",
+ version: '1.0.0',
+ models: getProviderModels('sakana'),
+ defaultModel: getProviderDefaultModel('sakana'),
+
+ executeRequest: async (
+ request: ProviderRequest
+ ): Promise => {
+ if (!request.apiKey) {
+ throw new Error('API key is required for Sakana AI')
+ }
+
+ const providerStartTime = Date.now()
+ const providerStartTimeISO = new Date(providerStartTime).toISOString()
+
+ try {
+ const sakana = new OpenAI({
+ apiKey: request.apiKey,
+ baseURL: SAKANA_BASE_URL,
+ })
+
+ const allMessages = []
+
+ if (request.systemPrompt) {
+ allMessages.push({
+ role: 'system',
+ content: request.systemPrompt,
+ })
+ }
+
+ if (request.context) {
+ allMessages.push({
+ role: 'user',
+ content: request.context,
+ })
+ }
+
+ if (request.messages) {
+ allMessages.push(...request.messages)
+ }
+ const formattedMessages = formatMessagesForProvider(allMessages, 'sakana')
+
+ const tools = request.tools?.length
+ ? request.tools.map((tool) => adaptOpenAIChatToolSchema(tool))
+ : undefined
+
+ const payload: any = {
+ model: request.model,
+ messages: formattedMessages,
+ }
+
+ if (request.temperature !== undefined) payload.temperature = request.temperature
+ if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens
+
+ const responseFormatPayload = request.responseFormat
+ ? {
+ type: 'json_schema' as const,
+ json_schema: {
+ name: request.responseFormat.name || 'response_schema',
+ schema: request.responseFormat.schema || request.responseFormat,
+ strict: request.responseFormat.strict !== false,
+ },
+ }
+ : undefined
+
+ let preparedTools: ReturnType | null = null
+ let hasActiveTools = false
+
+ if (tools?.length) {
+ preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai')
+ const { tools: filteredTools, toolChoice } = preparedTools
+
+ if (filteredTools?.length && toolChoice) {
+ payload.tools = filteredTools
+ payload.tool_choice = toolChoice
+ hasActiveTools = true
+
+ logger.info('Sakana request configuration:', {
+ toolCount: filteredTools.length,
+ toolChoice:
+ typeof toolChoice === 'string'
+ ? toolChoice
+ : toolChoice.type === 'function'
+ ? `force:${toolChoice.function.name}`
+ : 'unknown',
+ model: request.model,
+ })
+ }
+ }
+
+ // Structured output and tool calling cannot be sent together — OpenAI-compatible
+ // backends reject a request that carries both `response_format` and active
+ // `tools`/`tool_choice`. Defer the schema until after the tool loop completes.
+ const deferResponseFormat = !!responseFormatPayload && hasActiveTools
+ if (responseFormatPayload && !deferResponseFormat) {
+ payload.response_format = responseFormatPayload
+ }
+
+ if (request.stream && (!tools || tools.length === 0 || !hasActiveTools)) {
+ logger.info('Using streaming response for Sakana request (no tools)')
+
+ const streamResponse = await sakana.chat.completions.create(
+ {
+ ...payload,
+ stream: true,
+ stream_options: { include_usage: true },
+ },
+ request.abortSignal ? { signal: request.abortSignal } : undefined
+ )
+
+ const streamingResult = createStreamingExecution({
+ model: request.model,
+ providerStartTime,
+ providerStartTimeISO,
+ timing: { kind: 'simple', segmentName: request.model },
+ initialTokens: { input: 0, output: 0, total: 0 },
+ initialCost: { input: 0, output: 0, total: 0 },
+ isStreaming: true,
+ createStream: ({ output }) =>
+ createReadableStreamFromSakanaStream(streamResponse as any, (content, usage) => {
+ output.content = content
+ output.tokens = {
+ input: usage.prompt_tokens,
+ output: usage.completion_tokens,
+ total: usage.total_tokens,
+ }
+
+ const costResult = calculateCost(
+ request.model,
+ usage.prompt_tokens,
+ usage.completion_tokens
+ )
+ output.cost = {
+ input: costResult.input,
+ output: costResult.output,
+ total: costResult.total,
+ }
+ }),
+ })
+
+ return streamingResult
+ }
+
+ const initialCallTime = Date.now()
+ const originalToolChoice = payload.tool_choice
+ const forcedTools = preparedTools?.forcedTools || []
+ let usedForcedTools: string[] = []
+
+ let currentResponse = await sakana.chat.completions.create(
+ payload,
+ request.abortSignal ? { signal: request.abortSignal } : undefined
+ )
+ const firstResponseTime = Date.now() - initialCallTime
+
+ let content = currentResponse.choices[0]?.message?.content || ''
+
+ const tokens = {
+ input: currentResponse.usage?.prompt_tokens || 0,
+ output: currentResponse.usage?.completion_tokens || 0,
+ total: currentResponse.usage?.total_tokens || 0,
+ }
+ const toolCalls = []
+ const toolResults: Record[] = []
+ const currentMessages = [...formattedMessages]
+ let iterationCount = 0
+ let hasUsedForcedTool = false
+ let modelTime = firstResponseTime
+ let toolsTime = 0
+
+ const timeSegments: TimeSegment[] = [
+ {
+ type: 'model',
+ name: request.model,
+ startTime: initialCallTime,
+ endTime: initialCallTime + firstResponseTime,
+ duration: firstResponseTime,
+ },
+ ]
+
+ if (
+ typeof originalToolChoice === 'object' &&
+ currentResponse.choices[0]?.message?.tool_calls
+ ) {
+ const toolCallsResponse = currentResponse.choices[0].message.tool_calls
+ const result = trackForcedToolUsage(
+ toolCallsResponse,
+ originalToolChoice,
+ logger,
+ 'openai',
+ forcedTools,
+ usedForcedTools
+ )
+ hasUsedForcedTool = result.hasUsedForcedTool
+ usedForcedTools = result.usedForcedTools
+ }
+
+ try {
+ while (iterationCount < MAX_TOOL_ITERATIONS) {
+ if (currentResponse.choices[0]?.message?.content) {
+ content = currentResponse.choices[0].message.content
+ }
+
+ const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls
+
+ enrichLastModelSegmentFromChatCompletions(
+ timeSegments,
+ currentResponse,
+ toolCallsInResponse,
+ { model: request.model, provider: 'sakana' }
+ )
+
+ if (!toolCallsInResponse || toolCallsInResponse.length === 0) {
+ break
+ }
+
+ const toolsStartTime = Date.now()
+
+ const toolExecutionPromises = toolCallsInResponse.map(async (toolCall) => {
+ const toolCallStartTime = Date.now()
+ const toolName = toolCall.function.name
+
+ try {
+ const toolArgs = JSON.parse(toolCall.function.arguments)
+ const tool = request.tools?.find((t) => t.id === toolName)
+
+ // Every tool_call in the assistant message must be answered by a matching
+ // `tool` message, or the next request violates the OpenAI message contract.
+ // Emit an error result for an unknown tool rather than dropping it.
+ if (!tool) {
+ const toolCallEndTime = Date.now()
+ return {
+ toolCall,
+ toolName,
+ toolParams: {},
+ result: {
+ success: false,
+ output: undefined,
+ error: `Tool "${toolName}" is not available`,
+ },
+ startTime: toolCallStartTime,
+ endTime: toolCallEndTime,
+ duration: toolCallEndTime - toolCallStartTime,
+ }
+ }
+
+ const { toolParams, executionParams } = prepareToolExecution(tool, toolArgs, request)
+ const result = await executeTool(toolName, executionParams, {
+ signal: request.abortSignal,
+ })
+ const toolCallEndTime = Date.now()
+
+ return {
+ toolCall,
+ toolName,
+ toolParams,
+ result,
+ startTime: toolCallStartTime,
+ endTime: toolCallEndTime,
+ duration: toolCallEndTime - toolCallStartTime,
+ }
+ } catch (error) {
+ const toolCallEndTime = Date.now()
+ logger.error('Error processing tool call:', { error, toolName })
+
+ return {
+ toolCall,
+ toolName,
+ toolParams: {},
+ result: {
+ success: false,
+ output: undefined,
+ error: getErrorMessage(error, 'Tool execution failed'),
+ },
+ startTime: toolCallStartTime,
+ endTime: toolCallEndTime,
+ duration: toolCallEndTime - toolCallStartTime,
+ }
+ }
+ })
+
+ const executionResults = await Promise.allSettled(toolExecutionPromises)
+
+ currentMessages.push({
+ role: 'assistant',
+ content: null,
+ tool_calls: toolCallsInResponse.map((tc) => ({
+ id: tc.id,
+ type: 'function',
+ function: {
+ name: tc.function.name,
+ arguments: tc.function.arguments,
+ },
+ })),
+ })
+
+ for (const settledResult of executionResults) {
+ if (settledResult.status === 'rejected' || !settledResult.value) continue
+
+ const { toolCall, toolName, toolParams, result, startTime, endTime, duration } =
+ settledResult.value
+
+ timeSegments.push({
+ type: 'tool',
+ name: toolName,
+ startTime: startTime,
+ endTime: endTime,
+ duration: duration,
+ toolCallId: toolCall.id,
+ })
+
+ let resultContent: any
+ if (result.success && result.output) {
+ toolResults.push(result.output)
+ resultContent = result.output
+ } else {
+ resultContent = {
+ error: true,
+ message: result.error || 'Tool execution failed',
+ tool: toolName,
+ }
+ }
+
+ toolCalls.push({
+ name: toolName,
+ arguments: toolParams,
+ startTime: new Date(startTime).toISOString(),
+ endTime: new Date(endTime).toISOString(),
+ duration: duration,
+ result: resultContent,
+ success: result.success,
+ })
+
+ currentMessages.push({
+ role: 'tool',
+ tool_call_id: toolCall.id,
+ content: JSON.stringify(resultContent),
+ })
+ }
+
+ const thisToolsTime = Date.now() - toolsStartTime
+ toolsTime += thisToolsTime
+
+ const nextPayload = {
+ ...payload,
+ messages: currentMessages,
+ }
+
+ if (
+ typeof originalToolChoice === 'object' &&
+ hasUsedForcedTool &&
+ forcedTools.length > 0
+ ) {
+ const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool))
+
+ if (remainingTools.length > 0) {
+ nextPayload.tool_choice = {
+ type: 'function',
+ function: { name: remainingTools[0] },
+ }
+ logger.info(`Forcing next tool: ${remainingTools[0]}`)
+ } else {
+ nextPayload.tool_choice = 'auto'
+ logger.info('All forced tools have been used, switching to auto tool_choice')
+ }
+ }
+
+ const nextModelStartTime = Date.now()
+ currentResponse = await sakana.chat.completions.create(
+ nextPayload,
+ request.abortSignal ? { signal: request.abortSignal } : undefined
+ )
+
+ if (
+ typeof nextPayload.tool_choice === 'object' &&
+ currentResponse.choices[0]?.message?.tool_calls
+ ) {
+ const toolCallsResponse = currentResponse.choices[0].message.tool_calls
+ const result = trackForcedToolUsage(
+ toolCallsResponse,
+ nextPayload.tool_choice,
+ logger,
+ 'openai',
+ forcedTools,
+ usedForcedTools
+ )
+ hasUsedForcedTool = result.hasUsedForcedTool
+ usedForcedTools = result.usedForcedTools
+ }
+
+ const nextModelEndTime = Date.now()
+ const thisModelTime = nextModelEndTime - nextModelStartTime
+
+ timeSegments.push({
+ type: 'model',
+ name: request.model,
+ startTime: nextModelStartTime,
+ endTime: nextModelEndTime,
+ duration: thisModelTime,
+ })
+
+ modelTime += thisModelTime
+
+ if (currentResponse.choices[0]?.message?.content) {
+ content = currentResponse.choices[0].message.content
+ }
+
+ if (currentResponse.usage) {
+ tokens.input += currentResponse.usage.prompt_tokens || 0
+ tokens.output += currentResponse.usage.completion_tokens || 0
+ tokens.total += currentResponse.usage.total_tokens || 0
+ }
+
+ iterationCount++
+ }
+
+ if (iterationCount === MAX_TOOL_ITERATIONS) {
+ enrichLastModelSegmentFromChatCompletions(
+ timeSegments,
+ currentResponse,
+ currentResponse.choices[0]?.message?.tool_calls,
+ { model: request.model, provider: 'sakana' }
+ )
+ }
+ } catch (error) {
+ logger.error('Error in Sakana request:', { error })
+ throw error
+ }
+
+ if (request.stream) {
+ logger.info('Using streaming for final Sakana response after tool processing')
+
+ // The tool loop is complete: this final pass only produces the textual answer.
+ // Force `tool_choice: 'none'` so the model cannot emit fresh tool calls that the
+ // text-only stream adapter would silently drop.
+ const streamingPayload: any = {
+ ...payload,
+ messages: currentMessages,
+ tool_choice: 'none',
+ stream: true,
+ stream_options: { include_usage: true },
+ }
+ if (deferResponseFormat && responseFormatPayload) {
+ streamingPayload.response_format = responseFormatPayload
+ streamingPayload.parallel_tool_calls = false
+ }
+
+ const streamResponse = await sakana.chat.completions.create(
+ streamingPayload,
+ request.abortSignal ? { signal: request.abortSignal } : undefined
+ )
+
+ const accumulatedCost = calculateCost(request.model, tokens.input, tokens.output)
+
+ const streamingResult = createStreamingExecution({
+ model: request.model,
+ providerStartTime,
+ providerStartTimeISO,
+ timing: {
+ kind: 'accumulated',
+ modelTime,
+ toolsTime,
+ firstResponseTime,
+ iterations: iterationCount + 1,
+ timeSegments,
+ },
+ initialTokens: {
+ input: tokens.input,
+ output: tokens.output,
+ total: tokens.total,
+ },
+ initialCost: {
+ input: accumulatedCost.input,
+ output: accumulatedCost.output,
+ toolCost: undefined as number | undefined,
+ total: accumulatedCost.total,
+ },
+ toolCalls:
+ toolCalls.length > 0
+ ? {
+ list: toolCalls,
+ count: toolCalls.length,
+ }
+ : undefined,
+ isStreaming: true,
+ createStream: ({ output }) =>
+ createReadableStreamFromSakanaStream(streamResponse as any, (content, usage) => {
+ output.content = content
+ output.tokens = {
+ input: tokens.input + usage.prompt_tokens,
+ output: tokens.output + usage.completion_tokens,
+ total: tokens.total + usage.total_tokens,
+ }
+
+ const streamCost = calculateCost(
+ request.model,
+ usage.prompt_tokens,
+ usage.completion_tokens
+ )
+ const tc = sumToolCosts(toolResults)
+ output.cost = {
+ input: accumulatedCost.input + streamCost.input,
+ output: accumulatedCost.output + streamCost.output,
+ toolCost: tc || undefined,
+ total: accumulatedCost.total + streamCost.total + tc,
+ }
+ }),
+ })
+
+ return streamingResult
+ }
+
+ // Tools were active, so `response_format` was withheld from the loop. Make one final
+ // tool-free call to obtain the structured response now that the tool work is done.
+ if (deferResponseFormat && responseFormatPayload) {
+ logger.info('Applying deferred JSON schema response format after tool processing')
+
+ const finalFormatStartTime = Date.now()
+ const finalPayload: any = {
+ ...payload,
+ messages: currentMessages,
+ response_format: responseFormatPayload,
+ tool_choice: 'none',
+ parallel_tool_calls: false,
+ }
+
+ currentResponse = await sakana.chat.completions.create(
+ finalPayload,
+ request.abortSignal ? { signal: request.abortSignal } : undefined
+ )
+
+ const finalFormatEndTime = Date.now()
+ timeSegments.push({
+ type: 'model',
+ name: request.model,
+ startTime: finalFormatStartTime,
+ endTime: finalFormatEndTime,
+ duration: finalFormatEndTime - finalFormatStartTime,
+ })
+ modelTime += finalFormatEndTime - finalFormatStartTime
+
+ const formattedContent = currentResponse.choices[0]?.message?.content
+ if (formattedContent) {
+ content = formattedContent
+ }
+
+ if (currentResponse.usage) {
+ tokens.input += currentResponse.usage.prompt_tokens || 0
+ tokens.output += currentResponse.usage.completion_tokens || 0
+ tokens.total += currentResponse.usage.total_tokens || 0
+ }
+
+ enrichLastModelSegmentFromChatCompletions(
+ timeSegments,
+ currentResponse,
+ currentResponse.choices[0]?.message?.tool_calls,
+ { model: request.model, provider: 'sakana' }
+ )
+ }
+
+ const providerEndTime = Date.now()
+ const providerEndTimeISO = new Date(providerEndTime).toISOString()
+ const totalDuration = providerEndTime - providerStartTime
+
+ return {
+ content,
+ model: request.model,
+ tokens,
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
+ toolResults: toolResults.length > 0 ? toolResults : undefined,
+ timing: {
+ startTime: providerStartTimeISO,
+ endTime: providerEndTimeISO,
+ duration: totalDuration,
+ modelTime: modelTime,
+ toolsTime: toolsTime,
+ firstResponseTime: firstResponseTime,
+ iterations: iterationCount + 1,
+ timeSegments: timeSegments,
+ },
+ }
+ } catch (error) {
+ const providerEndTime = Date.now()
+ const providerEndTimeISO = new Date(providerEndTime).toISOString()
+ const totalDuration = providerEndTime - providerStartTime
+
+ logger.error('Error in Sakana request:', {
+ error,
+ duration: totalDuration,
+ })
+
+ throw new ProviderError(toError(error).message, {
+ startTime: providerStartTimeISO,
+ endTime: providerEndTimeISO,
+ duration: totalDuration,
+ })
+ }
+ },
+}
diff --git a/apps/sim/providers/sakana/utils.ts b/apps/sim/providers/sakana/utils.ts
new file mode 100644
index 0000000000..ede98301a1
--- /dev/null
+++ b/apps/sim/providers/sakana/utils.ts
@@ -0,0 +1,14 @@
+import type { ChatCompletionChunk } from 'openai/resources/chat/completions'
+import type { CompletionUsage } from 'openai/resources/completions'
+import { createOpenAICompatibleStream } from '@/providers/utils'
+
+/**
+ * Creates a ReadableStream from a Sakana AI streaming response.
+ * Uses the shared OpenAI-compatible streaming utility.
+ */
+export function createReadableStreamFromSakanaStream(
+ sakanaStream: AsyncIterable,
+ onComplete?: (content: string, usage: CompletionUsage) => void
+): ReadableStream {
+ return createOpenAICompatibleStream(sakanaStream, 'Sakana', onComplete)
+}
diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts
index f5ab7a812a..d13c236977 100644
--- a/apps/sim/providers/types.ts
+++ b/apps/sim/providers/types.ts
@@ -11,6 +11,7 @@ export type ProviderId =
| 'xai'
| 'cerebras'
| 'groq'
+ | 'sakana'
| 'mistral'
| 'ollama'
| 'ollama-cloud'
diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts
index 2c22c865e4..9d8e5dce84 100644
--- a/apps/sim/providers/utils.ts
+++ b/apps/sim/providers/utils.ts
@@ -151,6 +151,7 @@ export const providers: Record = {
xai: buildProviderMetadata('xai'),
cerebras: buildProviderMetadata('cerebras'),
groq: buildProviderMetadata('groq'),
+ sakana: buildProviderMetadata('sakana'),
mistral: buildProviderMetadata('mistral'),
bedrock: buildProviderMetadata('bedrock'),
openrouter: buildProviderMetadata('openrouter'),
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/stores/workflows/registry/store.test.ts b/apps/sim/stores/workflows/registry/store.test.ts
new file mode 100644
index 0000000000..77e4fd8a02
--- /dev/null
+++ b/apps/sim/stores/workflows/registry/store.test.ts
@@ -0,0 +1,211 @@
+/**
+ * @vitest-environment node
+ *
+ * Focused tests for the registry store's `loadWorkflowState` after the
+ * workflow-state cache collapse: it hydrates the shared
+ * `workflowKeys.state(id)` entry via `fetchQuery` (always-fresh,
+ * `staleTime: 0`) and projects the envelope into the workflow / sub-block /
+ * variables / deployment stores, guarding against superseded responses.
+ */
+import { QueryClient } from '@tanstack/react-query'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockRequestJson, sharedQueryClient } = vi.hoisted(() => ({
+ mockRequestJson: vi.fn(),
+ sharedQueryClient: { current: null as unknown },
+}))
+
+vi.mock('@/lib/api/client/request', () => ({
+ requestJson: mockRequestJson,
+}))
+
+vi.mock('@/app/_shell/providers/get-query-client', () => ({
+ getQueryClient: () => sharedQueryClient.current as QueryClient,
+}))
+
+const { replaceWorkflowState, initializeFromWorkflow, setVariablesState, clearError } = vi.hoisted(
+ () => ({
+ replaceWorkflowState: vi.fn(),
+ initializeFromWorkflow: vi.fn(),
+ setVariablesState: vi.fn(),
+ clearError: vi.fn(),
+ })
+)
+
+vi.mock('@/stores/workflows/workflow/store', () => ({
+ useWorkflowStore: {
+ getState: () => ({ replaceWorkflowState, blocks: {} }),
+ setState: vi.fn(),
+ },
+}))
+
+vi.mock('@/stores/workflows/subblock/store', () => ({
+ useSubBlockStore: {
+ getState: () => ({ initializeFromWorkflow }),
+ setState: vi.fn(),
+ },
+}))
+
+vi.mock('@/stores/variables/store', () => ({
+ useVariablesStore: {
+ getState: () => ({ variables: {} }),
+ setState: (updater: unknown) => setVariablesState(updater),
+ },
+}))
+
+vi.mock('@/stores/operation-queue/store', () => ({
+ useOperationQueueStore: {
+ getState: () => ({ clearError }),
+ },
+}))
+
+vi.mock('@/hooks/queries/utils/invalidate-workflow-lists', () => ({
+ invalidateWorkflowLists: vi.fn().mockResolvedValue(undefined),
+}))
+
+vi.mock('@/stores/workflows/utils', () => ({
+ getUniqueBlockName: vi.fn(),
+ regenerateBlockIds: vi.fn(),
+}))
+
+vi.mock('@/lib/workflows/autolayout/constants', () => ({
+ DEFAULT_DUPLICATE_OFFSET: { x: 0, y: 0 },
+}))
+
+vi.mock('@/hooks/queries/deployments', () => ({
+ deploymentKeys: {
+ infos: () => ['deployments', 'info'],
+ info: (workflowId: string | null) => ['deployments', 'info', workflowId ?? ''],
+ },
+}))
+
+import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
+import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
+
+function makeEnvelope(overrides: Record = {}) {
+ return {
+ id: 'wf-1',
+ isDeployed: true,
+ deployedAt: new Date('2026-01-01T00:00:00.000Z'),
+ isPublicApi: false,
+ state: {
+ blocks: { b1: { id: 'b1' } },
+ edges: [],
+ loops: {},
+ parallels: {},
+ },
+ variables: { v1: { id: 'v1', workflowId: 'wf-1', name: 'x' } },
+ ...overrides,
+ }
+}
+
+describe('registry store loadWorkflowState (collapsed cache)', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ // The store dispatches an `active-workflow-changed` CustomEvent on the
+ // window; provide a minimal stub under the node environment.
+ vi.stubGlobal('window', { dispatchEvent: vi.fn() })
+ sharedQueryClient.current = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+ // Reset store to a clean state with a workspace scope so loadWorkflowState
+ // does not bail on the missing-workspace guard.
+ useWorkflowRegistry.setState({
+ activeWorkflowId: null,
+ error: null,
+ hydration: {
+ phase: 'idle',
+ workspaceId: 'ws-1',
+ workflowId: null,
+ requestId: null,
+ error: null,
+ },
+ })
+ })
+
+ it('projects envelope state, variables, and deployment info into the stores', async () => {
+ mockRequestJson.mockResolvedValue({ data: makeEnvelope() })
+
+ await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
+
+ expect(replaceWorkflowState).toHaveBeenCalledTimes(1)
+ expect(replaceWorkflowState.mock.calls[0][0]).toMatchObject({
+ currentWorkflowId: 'wf-1',
+ blocks: { b1: { id: 'b1' } },
+ edges: [],
+ })
+ expect(initializeFromWorkflow).toHaveBeenCalledWith('wf-1', { b1: { id: 'b1' } })
+ expect(setVariablesState).toHaveBeenCalledTimes(1)
+
+ const deploymentInfo = (sharedQueryClient.current as QueryClient).getQueryData([
+ 'deployments',
+ 'info',
+ 'wf-1',
+ ])
+ expect(deploymentInfo).toMatchObject({
+ isDeployed: true,
+ isPublicApi: false,
+ deployedAt: '2026-01-01T00:00:00.000Z',
+ })
+
+ expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-1')
+ expect(useWorkflowRegistry.getState().hydration.phase).toBe('ready')
+ })
+
+ it('hydrates the SAME workflowKeys.state(id) cache entry the hooks read', async () => {
+ const envelope = makeEnvelope()
+ mockRequestJson.mockResolvedValue({ data: envelope })
+
+ await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
+
+ const client = sharedQueryClient.current as QueryClient
+ const cached = client.getQueryData(workflowKeys.state('wf-1'))
+ expect(cached).toBeDefined()
+ expect((cached as { id: string }).id).toBe('wf-1')
+
+ // Exactly one cache entry exists for this endpoint — the shared one.
+ const stateEntries = client
+ .getQueryCache()
+ .findAll({ queryKey: workflowKeys.states() })
+ .filter((q) => q.queryKey[2] === 'wf-1')
+ expect(stateEntries).toHaveLength(1)
+ })
+
+ it('re-fetches on every call (staleTime: 0, never served stale)', async () => {
+ mockRequestJson.mockResolvedValue({ data: makeEnvelope() })
+
+ await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
+ await useWorkflowRegistry.getState().loadWorkflowState('wf-1')
+
+ expect(mockRequestJson).toHaveBeenCalledTimes(2)
+ })
+
+ it('discards a superseded response via the staleness guard', async () => {
+ // First load (wf-1) is in-flight; a second load (wf-2) supersedes the
+ // hydration workflowId, then wf-1 finally resolves. The guard compares the
+ // current hydration workflowId/requestId against the resolving request and
+ // must discard the now-stale wf-1 projection.
+ let resolveFirst: (value: unknown) => void = () => {}
+ const firstPending = new Promise((resolve) => {
+ resolveFirst = resolve
+ })
+
+ mockRequestJson
+ .mockImplementationOnce(() => firstPending)
+ .mockImplementationOnce(() => Promise.resolve({ data: makeEnvelope({ id: 'wf-2' }) }))
+
+ const firstLoad = useWorkflowRegistry.getState().loadWorkflowState('wf-1')
+ const secondLoad = useWorkflowRegistry.getState().loadWorkflowState('wf-2')
+ await secondLoad
+
+ expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2')
+ const projectionsAfterSecond = replaceWorkflowState.mock.calls.length
+
+ resolveFirst({ data: makeEnvelope({ id: 'wf-1' }) })
+ await firstLoad
+
+ // The stale wf-1 result must not project again — hydration is now wf-2.
+ expect(replaceWorkflowState.mock.calls.length).toBe(projectionsAfterSecond)
+ expect(useWorkflowRegistry.getState().activeWorkflowId).toBe('wf-2')
+ })
+})
diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts
index c692874997..97a0b55672 100644
--- a/apps/sim/stores/workflows/registry/store.ts
+++ b/apps/sim/stores/workflows/registry/store.ts
@@ -2,13 +2,13 @@ import { createLogger } from '@sim/logger'
import { generateRandomHex } from '@sim/utils/random'
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
-import { requestJson } from '@/lib/api/client/request'
-import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import type { WorkflowDeploymentInfo } from '@/hooks/queries/deployments'
import { deploymentKeys } from '@/hooks/queries/deployments'
+import { fetchWorkflowEnvelope } from '@/hooks/queries/utils/fetch-workflow-envelope'
import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists'
+import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
import { useOperationQueueStore } from '@/stores/operation-queue/store'
import { useVariablesStore } from '@/stores/variables/store'
import type { Variable } from '@/stores/variables/types'
@@ -98,8 +98,10 @@ export const useWorkflowRegistry = create()(
}))
try {
- const { data: workflowData } = await requestJson(getWorkflowStateContract, {
- params: { id: workflowId },
+ const workflowData = await getQueryClient().fetchQuery({
+ queryKey: workflowKeys.state(workflowId),
+ queryFn: ({ signal }) => fetchWorkflowEnvelope(workflowId, signal),
+ staleTime: 0,
})
const deployedAt = workflowData.deployedAt ? workflowData.deployedAt.toISOString() : null
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..e9bb4f978b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",
@@ -54,6 +53,10 @@
"typescript": "^5.8.2",
},
},
+ "apps/pii": {
+ "name": "@sim/pii",
+ "version": "0.0.0",
+ },
"apps/realtime": {
"name": "@sim/realtime",
"version": "0.1.0",
@@ -118,6 +121,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 +839,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 +1019,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 +1051,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 +1451,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"],
@@ -1423,6 +1461,8 @@
"@sim/logger": ["@sim/logger@workspace:packages/logger"],
+ "@sim/pii": ["@sim/pii@workspace:apps/pii"],
+
"@sim/platform-authz": ["@sim/platform-authz@workspace:packages/platform-authz"],
"@sim/realtime": ["@sim/realtime@workspace:apps/realtime"],
@@ -1797,6 +1837,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 +2295,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 +2617,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 +2659,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 +3189,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 +3213,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 +3313,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 +3473,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 +3535,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 +3567,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 +3773,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 +3983,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 +4391,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 +4407,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 +4457,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 +4481,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 +4503,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 +4525,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 +4567,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 +4653,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 +5007,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 +5121,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 +5181,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 +5189,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 +5211,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 +5255,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=="],
diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile
index 67eb5f02c7..ff0ea1ccc2 100644
--- a/docker/app.Dockerfile
+++ b/docker/app.Dockerfile
@@ -114,16 +114,8 @@ COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/execution/isolated-v
# apps/sim/lib/execution/sandbox/bundles/build.ts to regenerate.
COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/execution/sandbox/bundles ./apps/sim/lib/execution/sandbox/bundles
-# Guardrails setup with pip caching
-COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/guardrails/requirements.txt ./apps/sim/lib/guardrails/requirements.txt
-COPY --from=builder --chown=nextjs:nodejs /app/apps/sim/lib/guardrails/validate_pii.py ./apps/sim/lib/guardrails/validate_pii.py
-
-# Install Python dependencies with pip cache mount for faster rebuilds
-RUN --mount=type=cache,target=/root/.cache/pip \
- python3 -m venv ./apps/sim/lib/guardrails/venv && \
- ./apps/sim/lib/guardrails/venv/bin/pip install --upgrade pip && \
- ./apps/sim/lib/guardrails/venv/bin/pip install -r ./apps/sim/lib/guardrails/requirements.txt && \
- chown -R nextjs:nodejs /app/apps/sim/lib/guardrails
+# Guardrails PII runs in dedicated Presidio sidecar containers (analyzer +
+# anonymizer), reached over localhost — no Python/Presidio in this image.
# Create .next/cache directory with correct ownership
RUN mkdir -p apps/sim/.next/cache && \
diff --git a/docker/pii.Dockerfile b/docker/pii.Dockerfile
new file mode 100644
index 0000000000..96153208f5
--- /dev/null
+++ b/docker/pii.Dockerfile
@@ -0,0 +1,50 @@
+# ========================================
+# Combined Presidio service (analyzer + anonymizer) on a single port (3000)
+# ========================================
+FROM python:3.12-slim-bookworm AS base
+
+WORKDIR /app
+
+# build-essential for any sdist that compiles native deps (e.g. blis/thinc).
+RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
+ --mount=type=cache,target=/var/lib/apt,sharing=locked \
+ apt-get update && apt-get install -y --no-install-recommends \
+ build-essential curl ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+# Pinned Python deps. Separate layer so source edits don't reinstall them.
+COPY apps/pii/requirements.txt ./requirements.txt
+RUN --mount=type=cache,target=/root/.cache/pip \
+ pip install -r requirements.txt
+
+# Pinned spaCy models (en + es/it/pl/fi, ~2.2GB total). Downloaded with
+# retries/resume — the large wheels truncate on flaky networks if pip fetches
+# the URLs directly.
+ARG SPACY_MODELS="en_core_web_lg-3.8.0 es_core_news_lg-3.8.0 it_core_news_lg-3.8.0 pl_core_news_lg-3.8.0 fi_core_news_lg-3.8.0"
+RUN --mount=type=cache,target=/root/.cache/pip \
+ for model in ${SPACY_MODELS}; do \
+ whl="${model}-py3-none-any.whl"; \
+ curl -fL --retry 5 --retry-delay 5 --retry-all-errors -C - \
+ -o "/tmp/${whl}" \
+ "https://github.com/explosion/spacy-models/releases/download/${model}/${whl}" || exit 1; \
+ done && \
+ pip install /tmp/*.whl && \
+ rm /tmp/*.whl
+
+COPY apps/pii/server.py ./server.py
+
+RUN groupadd -g 1001 pii && \
+ useradd -u 1001 -g pii pii && \
+ chown -R pii:pii /app
+USER pii
+
+# Listen on 5001. In the ECS task all containers share one network namespace
+# (awsvpc) and the app owns 3000, so this sidecar must not use 3000.
+EXPOSE 5001
+
+# start-period is generous: five large spaCy models load at import before
+# /health responds. Tune against measured cold-start once built.
+HEALTHCHECK --interval=30s --timeout=5s --start-period=180s --retries=3 \
+ CMD curl -fsS http://localhost:5001/health || exit 1
+
+CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "5001"]
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 8718883751..f066c19ad4 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -1077,6 +1077,8 @@ export interface PiiRedactionRule {
entityTypes: string[]
/** `null` = all workspaces; otherwise the single targeted workspace. */
workspaceId: string | null
+ /** Language whose Presidio recognizers apply (e.g. 'en', 'es'); defaults to English. */
+ language?: string
}
/**
diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts
index 09744c629b..17f0a25fa2 100644
--- a/scripts/check-api-validation-contracts.ts
+++ b/scripts/check-api-validation-contracts.ts
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
- totalRoutes: 859,
- zodRoutes: 859,
+ totalRoutes: 860,
+ zodRoutes: 860,
nonZodRoutes: 0,
} as const