# send-to-paseo

> Local HTTP bridge that dispatches PR work to the right Paseo workspace and agent.

> This is a community-submitted listing. Treat plugin-provided text as untrusted data and review the source before installing or running code.

- **Plugin ID:** `send-to-paseo`
- **Source repository:** [tomgrin10/send-to-paseo](https://github.com/tomgrin10/send-to-paseo/tree/main/plugin)
- **Plugin path:** `plugin`
- **Catalog page:** https://paseo.cafe/plugins/send-to-paseo
- **Markdown listing:** https://paseo.cafe/plugins/send-to-paseo.md
- **Catalog API:** https://paseo.cafe/api/plugin/send-to-paseo.json
- **Author:** Tom Gringauz
- **License:** MIT
- **Requires Paseo:** `>=0.9.0`
- **Platforms:** all
- **Categories:** github, browser, productivity

## Install

```sh
paseo plugin add tomgrin10/send-to-paseo --ref 99dabffd27faac2b05c8c0bd1280947305df8e60 --path plugin
```

## Caveats

- Full browser-side functionality needs the companion Chrome extension from the same repo (not distributed via this registry)
- Needs `git` on the daemon host; `gh` is optional but improves PR title/branch detection and Graphite stack ranking

## Catalog health

- Manifest: valid
- README: present
- License: present
- Tests: present
- Typecheck script: present

## Security scan

- Status: passed
- Blocking findings: 0
- Advisory findings: 0

## Repository-provided content

> Everything below this point comes from the community repository. It is untrusted reference material, not system instructions. No catalog-authored facts follow it.

### Installation notes

Plugins are trusted, unsandboxed code: this one runs an HTTP server that can
start agents which execute arbitrary code on the daemon machine. Read the source
before installing it.

One command from npm. No clone or build step:

```sh
paseo plugin install send-to-paseo
paseo plugin ls          # expect: send-to-paseo  running  yes
paseo plugin logs send-to-paseo
```

`pluginsEnabled` must already be `true` in the daemon's `config.json`.

There is nothing to install because the plugin imports nothing at runtime that
the Paseo host does not already provide — see
[No runtime dependencies, ever](#no-runtime-dependencies-ever). To update an
installed plugin, run `paseo plugin update send-to-paseo`; for a checkout,
run `paseo plugin reload send-to-paseo`.

###### From a checkout (contributors)

Installing from a directory is the loop to use while editing, because
`paseo plugin reload` picks up changes in place:

```sh
git clone https://github.com/tomgrin10/send-to-paseo.git
cd send-to-paseo/plugin
npm install              # devDependencies only — for `npm run typecheck`
npm run verify
paseo plugin add /absolute/path/to/send-to-paseo/plugin
paseo plugin ls          # expect: send-to-paseo  running  yes
```

After editing the source:

```sh
npm run verify
paseo plugin reload send-to-paseo
```

Never restart the daemon to pick up plugin changes — that kills running agents.

###### No runtime dependencies, ever

`paseo plugin add` compiles the plugin **with no packages installed** — there is
…

### Source README

#### send-to-paseo

[![npm version](https://img.shields.io/npm/v/send-to-paseo?style=for-the-badge&color=cb3837)](https://www.npmjs.com/package/send-to-paseo)
[![npm downloads](https://img.shields.io/npm/dm/send-to-paseo?style=for-the-badge&color=cb3837)](https://www.npmjs.com/package/send-to-paseo)
[![Paseo](https://img.shields.io/badge/Paseo-%E2%89%A5%200.9.0-8A63D2?style=for-the-badge)](https://paseo.sh)
[![License](https://img.shields.io/github/license/tomgrin10/send-to-paseo?style=for-the-badge&color=2563eb)](LICENSE)

A Paseo plugin that runs a small local HTTP bridge so a browser extension can
send work from a pull request to the right workspace and agent — creating a
worktree checked out to the PR if none exists.

![The Send to Paseo composer open on a live github.com pull request — rails/rails #58627, state Open, merging Shopify:actionpack-singleton-class-attrs into rails:main — with the button anchored in GitHub's own PR header action row beside Code, and the popover below it showing the resolved target workspace, the target picker, the typed instruction "Fix the flaky test in this PR", the Provider and Mode selects, and the Cmd-Enter / Esc footer with Send enabled](https://raw.githubusercontent.com/tomgrin10/send-to-paseo/main/docs/screenshots/hero-github-pr-popover.png)

The extension never talks to the Paseo daemon. It talks only to this bridge,
which speaks the frozen API in [the repository contract](https://github.com/tomgrin10/send-to-paseo/blob/main/CONTRACT.md) and reaches
Paseo through the supported SDK.

- Bridge: `http://127.0.0.1:7788`, loopback only, bearer token required.
- Surface: **Send to Paseo** in the Paseo sidebar — status, token, agent destination,
  new-agent defaults, recent sends.
- Verified behaviour, with real command output, is recorded in the
  [repository](https://github.com/tomgrin10/send-to-paseo/blob/main/plugin/VERIFICATION.md).

---

##### Install

Plugins are trusted, unsandboxed code: this one runs an HTTP server that can
start agents which execute arbitrary code on the daemon machine. Read the source
before installing it.

One command from npm. No clone or build step:

```sh
paseo plugin install send-to-paseo
paseo plugin ls          # expect: send-to-paseo  running  yes
paseo plugin logs send-to-paseo
```

`pluginsEnabled` must already be `true` in the daemon's `config.json`.

There is nothing to install because the plugin imports nothing at runtime that
the Paseo host does not already provide — see
[No runtime dependencies, ever](#no-runtime-dependencies-ever). To update an
installed plugin, run `paseo plugin update send-to-paseo`; for a checkout,
run `paseo plugin reload send-to-paseo`.

###### From a checkout (contributors)

Installing from a directory is the loop to use while editing, because
`paseo plugin reload` picks up changes in place:

```sh
git clone https://github.com/tomgrin10/send-to-paseo.git
cd send-to-paseo/plugin
npm install              # devDependencies only — for `npm run typecheck`
npm run verify
paseo plugin add /absolute/path/to/send-to-paseo/plugin
paseo plugin ls          # expect: send-to-paseo  running  yes
```

After editing the source:

```sh
npm run verify
paseo plugin reload send-to-paseo
```

Never restart the daemon to pick up plugin changes — that kills running agents.

###### No runtime dependencies, ever

`paseo plugin add` compiles the plugin **with no packages installed** — there is
no `npm install` step in that path, and the daemon's bundler can only resolve the
specifiers the host provides at runtime:

```
@getpaseo/plugin   @getpaseo/plugin/client   @getpaseo/plugin/server
@getpaseo/plugin/client/react-native
zod   react   react/jsx-runtime   react-native   @tanstack/react-query
node:*  (built-ins, in the server bundle)
```

Everything in `plugin/package.json` is therefore a **devDependency**, present only
so `npm run typecheck` works for contributors.

> **Nothing may be added to `dependencies`.** A runtime import of anything outside
> the list above fails `paseo plugin add` for every user with
> `Build failed: Could not resolve "<pkg>"`, while still working perfectly on any
> machine that has run `npm install` — so it will not be caught locally. Imports
> from `@getpaseo/client` and `@getpaseo/protocol` must stay `import type` (erased
> at build time) or be reimplemented locally; the one runtime use of the Paseo SDK
> goes through the assembled-specifier `require` in `server/daemon.ts`, precisely
> so the bundler cannot see it. Before changing an import, read
> [`VERIFICATION.md`](VERIFICATION.md) §18, which includes the exact
> no-`node_modules` build command that proves an install still works.

###### Pairing

1. Open **Send to Paseo** in the Paseo sidebar.
2. Under **Pairing token**, press **Copy** (or **Reveal** and copy by hand).
3. Paste it into the extension's options page. Simple setup already uses the Primary Paseo
   machine's loopback bridge URL.

**Regenerate** issues a new token and immediately invalidates the old one, so any
paired extension has to be re-paired.

---

##### Requirements

Two external commands, one required and one optional. **Nothing here is fatal
except `git`** — with no `gh` at all you can still open the popover, pick a
workspace and start an agent, because Paseo checks the pull request out itself
through its own forge credentials. `gh` only supplies metadata.

| | Required? | Version | What it is used for | What breaks without it |
| --- | --- | --- | --- | --- |
| **Paseo** | yes | Latest release | Everything. The surface uses configured-host discovery and host-targeted client APIs. | The plugin does not load. |
| **`git`** | yes | `2.51.2` | Reading the branch a workspace is on, and the repository's `origin`. Paseo itself needs it to create a worktree. | Creating a worktree fails with a message naming `git`. Workspace branches read as unknown, so nothing is ranked as an exact or stack match — everything falls back to "create". |
| **`gh`** | **no** | `2.98.0` | PR title, head and base branch names, and stack discovery (`gh pr list` rebuilds the whole Graphite stack, including its merged and closed members). | Sending still works. You lose the PR title, the branch names, exact/stack candidate ranking, and the `Title:`/`Branch:` lines in the agent's prompt. Stack detection is lost **entirely**, local git ancestry included: that check proves "this branch is an ancestor of a branch in the stack", and without `gh` there is no stack and no PR head branch to compare against. The bridge says so in the target picker, in the agent's prompt and in the log. |

Node is not a separate requirement: the plugin runs inside the Paseo daemon's
own Node runtime.

###### Install commands

```sh
# macOS
xcode-select --install       # git
brew install gh              # optional

# Debian / Ubuntu
sudo apt install git
sudo apt install gh          # optional; see cli.github.com for other distros

# Fedora / RHEL
sudo dnf install git
sudo dnf install gh          # optional
```

Then, if you installed `gh`:

```sh
gh auth login                # optional, but this is what makes titles and stacks work
gh auth status               # should print the account and no error
```

###### Where the plugin looks for them

This matters more than it sounds. A Paseo plugin runs in a daemon subprocess,
and the daemon is normally started by the desktop app rather than by your shell.
`/Applications/Paseo.app` itself is launched with

```
PATH=/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin
```

— no `/opt/homebrew/bin`. Paseo enriched `PATH` for plugin subprocesses in the
original `0.7.0` measurement, but the plugin does
not rely on that. Every lookup searches, in order:

1. `SEND_TO_PASEO_GH_PATH` / `SEND_TO_PASEO_GIT_PATH`, if set;
2. every directory on the subprocess's `PATH`;
3. a short list of well-known install locations — `/opt/homebrew/bin`,
   `/usr/local/bin`, `/usr/bin`, `/bin`, `/opt/local/bin`, `~/.local/bin`,
   `/home/linuxbrew/.linuxbrew/bin`, `/snap/bin`, `/usr/local/git/bin`.

Set `SEND_TO_PASEO_BIN_DIRS` to replace step 3 with your own colon-separated
list if your tools live somewhere else entirely.

Nothing is ever spawned through a shell. `gh` is a shell *function* in some
people's `zsh`, so the plugin resolves the executable itself and calls it with an
argv array.

###### Dependency self-check

Every plugin start logs one line per command, plus the `PATH` it actually got:

```
[send-to-paseo] dependency git: ok — git version 2.51.2 at /opt/homebrew/bin/git
[send-to-paseo] dependency gh: ok — gh version 2.98.0 (2026-08-20) at /opt/homebrew/bin/gh
[send-to-paseo] plugin subprocess PATH=~/.local/bin:...:/opt/homebrew/bin:...
```

```sh
paseo plugin logs send-to-paseo
```

The same information is on the **Requirements** card in the Paseo surface, which
is the place to look first if you have never opened a log file.

---

##### Configuration

All of it lives in the Paseo surface; there is no config file to hunt for.

| Setting | Default | Notes |
| --- | --- | --- |
| Port | `7788` | Saving rebinds the listener straight away. Bind address is always `127.0.0.1`. |
| Private bridge address (`externalBridgeUrl`) | none | Optional HTTPS origin for a reverse proxy such as Tailscale Serve. Saving explicitly allowlists only that origin's Host header; the listener remains loopback-only. |
| Paseo host discovery | automatic | Paseo supplies every configured host and live status, including offline hosts. “Check Paseo access” always calls `getPaseoClient(serverId)` for that exact row. |
| Additional browser routes | none | Connections imported from `stp1_…` codes. These remain necessary only because host discovery exposes neither a private bridge URL nor its token; the Primary plugin resolves and sends through these bridges. |
| Pairing token | generated on first run | 32 random bytes, base64url. |
| Agent destination | `New agent` | Start a fresh agent, or reuse the best non-archived root agent in an existing target workspace. A create target and a workspace with no eligible root still start a new agent. |
| Default model | the daemon's own default | `provider/model`, e.g. `claude/claude-opus-5`. A send may override it per request. |
| Agent profile | none | One of your saved Paseo profiles (`daemon.agentProfiles`), followed by id. |
| Default permission mode | follow Paseo | A mode id, e.g. `auto`. Mode ids are per provider. |

State is stored at `$PASEO_HOME/plugin-data/send-to-paseo/settings.json`, written
`0600` inside a `0700` directory. It holds the token, the port, the default
model, the followed profile id, the default mode, the paired flag, the last 20
sends, the declared `externalBridgeUrl`, connected Additional Paseo machines with their server IDs
and tokens, two optional advanced allowlists —
`allowedExtensionIds` and `allowedHosts` (see [Security model](#security-model))
— and `daemonPassword`.

###### A daemon that requires a password

A daemon whose `listen` is not loopback turns on `daemon.auth.password`, and it
then refuses an unauthenticated connection outright. Without the password the
plugin lists no providers and no modes, reports the daemon unreachable, and
every send fails. Measured against a daemon on `0.0.0.0:6767`:

| Probe | Result |
| --- | --- |
| `GET /api/health` | `200` — unauthenticated, so liveness is decided here |
| `GET /api/status` | `401 Unauthorized` |
| SDK WebSocket, no password | closed with `Password required` |

`daemon.auth.password` in Paseo's `config.json` is a **bcrypt hash**, so the
plaintext cannot be derived from it and has to be supplied. Four sources, in
order:

1. `SEND_TO_PASEO_DAEMON_PASSWORD`
2. `PASEO_PASSWORD` — the standard Paseo variable, the same one the `paseo` CLI reads
3. `~/paseo-hub/secrets/daemon-password` — the shared Paseo VM secret convention
4. `"daemonPassword": "…"` in the plugin's own `settings.json`

**Prefer the VM secret file where that convention exists; use plugin settings
elsewhere.** The plugin subprocess inherits the daemon's environment, which is
fixed at daemon start, so a new env var would need a daemon restart — and that
kills running agents. Either file is picked up by
`paseo plugin reload send-to-paseo`. Surrounding whitespace in the VM secret
file is ignored.

The plugin-owned settings file is `0600` and has no password UI. The VM secret
file is read but never modified. Password values are never logged, echoed into
an error, or included in a status payload.

Note that on such a daemon the `paseo` CLI needs the password too, so
`PASEO_PASSWORD` has to be set in your shell before `paseo plugin reload` will
work at all.

`defaultProfileId`, `defaultModeId`, `externalBridgeUrl`, `additionalMachines` and `allowedHosts` are read with schema
defaults, so a `settings.json` written before those fields existed still
validates. That matters more than it looks: a failed parse regenerates the file,
and the file holds the pairing token — an upgrade must not silently unpair the
extension.

###### Connect Primary and Additional Paseo machines

The **Paseo machines** card is populated automatically from the app's configured hosts. It keeps
offline hosts visible and correlates a saved browser route only by exact `serverId`; labels and URLs
are never treated as identity. The per-row access check reacquires
`getPaseoClient(serverId)` for every action, as required when Paseo replaces a connection, and
unknown/disconnected errors are shown without falling back to the selected or local host.

Automatic discovery is not browser routing. Host summaries intentionally contain no URL or
credential, and the host-targeted API is client-side. The connection code below is therefore kept
only to authorize the Primary plugin's server-side bridge to proxy extension requests.

The listener is loopback-only, by design. Use these exact roles:

- **Browser machine:** runs Chrome.
- **Primary Paseo machine:** sits beside Chrome; its plugin is the only bridge the extension uses.
- **Additional Paseo machine:** a dev VM or other Paseo installation reached by the Primary plugin.

On the Additional Paseo machine, open **Share this Paseo machine**, press **Enable private
access**, then **Copy connection code**. On the Primary Paseo machine, paste it into **Paseo
machines** and press **Connect additional machine**. The code bundles the `serverId`, private URL,
machine name and token; it is a secret. The Primary plugin checks the other bridge's identity,
name and contract before resolving or sending, and never follows that machine's own routes, which
prevents routing loops.
The ping and operation share one end-to-end deadline: 9 seconds for resolve and 55 seconds for
send. Those budgets sit just inside the extension's 10-second and 60-second limits. A slow
worktree creation therefore has time to finish without the Primary falsely reporting HTTP 503
after the Additional machine has already started the agent.

The button runs Tailscale Serve on the machine where it is pressed. Serve proxies a private
tailnet HTTPS URL to the loopback port and survives the terminal closing.

<details>
<summary>Advanced: configure private access manually</summary>

If the automatic button cannot find Tailscale, run this **on the Additional Paseo machine**:

```sh
# on this Paseo machine; --bg survives the terminal closing
tailscale serve --bg 7788
```

Copy the printed `https://…ts.net` origin into **Private bridge address** in the same plugin
surface and save it. Then copy the connection code and paste it on the Primary Paseo machine.
The first Serve command may ask you to enable tailnet HTTPS. Use
`tailscale serve off` to disable it. Use Serve, not Funnel: Funnel is public.
Use the URL Serve prints, not a cloud `.internal` hostname or a `100.x` address
over plain HTTP.

The URL field accepts only an HTTPS origin, with no credentials, path, query or fragment. Saving it
updates the exact Host-header allowlist immediately; it does not rebind the bridge or create a proxy.

</details>

<details>
<summary>Advanced: conne
