From f51fd2d099884d5b65e3238e0ca6b6d65e79981f Mon Sep 17 00:00:00 2001 From: Sleeyax Date: Sun, 11 Oct 2026 13:40:11 +0200 Subject: [PATCH 01/31] chore: configure the engineering skills for this repository Output of setup-matt-pocock-skills: GitHub Issues as the tracker, the five default triage labels, and a multi-context domain-doc layout with one context per plugin or app. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 17 +++++++++++++ docs/agents/domain.md | 42 ++++++++++++++++++++++++++++++++ docs/agents/issue-tracker.md | 46 ++++++++++++++++++++++++++++++++++++ docs/agents/triage-labels.md | 15 ++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md diff --git a/CLAUDE.md b/CLAUDE.md index 0ca7449..64e2e81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,3 +41,20 @@ Every plugin's README follows the same template, so a new plugin starts from thi ``` Where a plugin's settings file lives is documented once in the root README, not per plugin. + +## Agent skills + +### Issue tracker + +Issues live in GitHub Issues on sleeyax/paseo-plugins, managed with the `gh` CLI. +See `docs/agents/issue-tracker.md`. + +### Triage labels + +The five default triage labels, each label string equal to its role name. +See `docs/agents/triage-labels.md`. + +### Domain docs + +Multi-context: a root `GLOSSARY-MAP.md` points at one `GLOSSARY.md` per plugin or app, and `docs/adr/` at the root holds workspace-wide decisions. +See `docs/agents/domain.md`. diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..ccc7779 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,42 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +This repo is multi-context: each package under `plugins/` and `apps/` is its own context. + +## Before exploring, read these + +- **`GLOSSARY-MAP.md`** at the repo root: it points at one `GLOSSARY.md` per context, at `plugins//GLOSSARY.md` or `apps//GLOSSARY.md`. Read each one relevant to the topic. +- **`docs/adr/`** at the repo root: workspace-wide decisions, such as the plugin layout or where dependencies belong. +- **`/docs/adr/`**: decisions scoped to one plugin or app. Read the ADRs that touch the area you're about to work in. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +``` +/ +├── GLOSSARY-MAP.md +├── docs/adr/ ← workspace-wide decisions +├── apps/ +│ └── claude-tty-acp/ +│ ├── GLOSSARY.md +│ └── docs/adr/ +└── plugins/ + ├── guided-review/ + │ ├── GLOSSARY.md + │ └── docs/adr/ ← plugin-specific decisions + └── … +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in the relevant `GLOSSARY.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal: either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders), but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..a63510f --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,46 @@ +# Issue tracker: GitHub + +Issues and specs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --json number,title,body,labels,comments`. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Make an issue a sub-issue of a parent**: `gh issue create --parent ...`, or `gh issue edit --add-sub-issue ` afterwards (`gh` 2.94+). Older `gh`: `gh api --method POST repos///issues//sub_issues -F sub_issue_id=` (database id, as in **Blocking** below). Without sub-issues, put `Part of #` at the top of the child body. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v`; `gh` does this automatically when run inside a clone. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_ + +When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents: + +- **Read a PR**: `gh pr view --comments` and `gh pr diff ` for the diff. +- **List external PRs for triage**: `gh api --paginate 'repos/{owner}/{repo}/pulls?state=open' --jq '.[] | select(.author_association | IN("OWNER","MEMBER","COLLABORATOR") | not) | {number, title, author: .user.login, author_association, labels: [.labels[].name]}'`. +- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`. + +GitHub shares one number space across issues and PRs, so a bare `#42` may be either: resolve with `gh pr view 42` and fall back to `gh issue view 42`. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Read it as in **Read an issue** above. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. + +- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`. +- **Child ticket**: an issue linked to the map as a GitHub sub-issue (see **Make an issue a sub-issue of a parent**). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #` at the top of the child body. Labels: `wayfinder:` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev. +- **Blocking**: GitHub's **native issue dependencies**, the canonical, UI-visible representation. Add an edge with `gh api --method POST repos///issues//dependencies/blocked_by -F issue_id=`, where `` is the blocker's numeric **database id** (`gh api repos///issues/ --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only, the live gate). Where dependencies aren't available, fall back to a `Blocked by: #, #` line at the top of the child body. A ticket is unblocked when every blocker is closed. +- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins. +- **Claim**: `gh issue edit --add-assignee @me`, the session's first write. +- **Resolve**: `gh issue comment --body ""`, then `gh issue close `, then append a context pointer (gist + link) to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..5276464 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the "Label in our tracker" column to match whatever vocabulary you actually use. From a412c0c493270c6f9421de8cffb530513e74fdf0 Mon Sep 17 00:00:00 2001 From: Sleeyax Date: Sun, 11 Oct 2026 13:42:32 +0200 Subject: [PATCH 02/31] docs(catppuccin-theme): rewrite the README to the new template Move picking a flavour into a Usage section, and state the Paseo requirement once. Drop the palette-mapping note, which is for whoever changes the themes, not for users. Co-Authored-By: Claude Opus 5.5 --- plugins/catppuccin-theme/README.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/plugins/catppuccin-theme/README.md b/plugins/catppuccin-theme/README.md index 18c7199..6aeade7 100644 --- a/plugins/catppuccin-theme/README.md +++ b/plugins/catppuccin-theme/README.md @@ -2,7 +2,7 @@ [![Paseo Cafe](https://img.shields.io/badge/Paseo_Cafe-catppuccin--theme-blue)](https://paseo.cafe/plugins/catppuccin-theme) -Adds all [Catppuccin](https://catppuccin.com/palette/) flavours — Latte, Frappé, Macchiato, and Mocha — as Paseo app themes. +Adds the four [Catppuccin](https://catppuccin.com/palette/) flavours to Paseo as app themes: Latte, Frappé, Macchiato and Mocha. ## Screenshots @@ -16,33 +16,35 @@ Adds all [Catppuccin](https://catppuccin.com/palette/) flavours — Latte, Frapp ## Installation +Requires Paseo 0.9 or newer. +Install from the [Paseo plugin registry](https://paseo.sh/plugins), which needs Paseo 0.11 or newer: + ```sh paseo plugin add sleeyax/catppuccin-theme ``` -That installs the release published to the [Paseo plugin registry](https://paseo.sh/plugins), which needs Paseo 0.11 or later. To install straight from this repository's default branch instead: +Or install from this repository's default branch: ```sh paseo plugin add github:sleeyax/paseo-plugins:plugins/catppuccin-theme ``` -Then pick your favorite theme in **Settings -> Appearance**. - -Either way, `paseo plugin update catppuccin-theme` picks up new releases without a clone, and `paseo plugin update catppuccin-theme --check` says what is installed against what is available. +Update with `paseo plugin update catppuccin-theme`. -## Settings +## Usage -The plugin has no settings. Change the theme in Settings -> Appearance. +Pick a flavour under **Settings → Appearance**. +The themes recolour the app only; syntax highlighting keeps its own theme. ## Development ```sh +pnpm --filter @paseo-plugins/catppuccin-theme typecheck +pnpm --filter @paseo-plugins/catppuccin-theme test paseo plugin reload catppuccin-theme paseo plugin logs catppuccin-theme ``` -Each flavour maps the same seven palette colours onto Paseo's theme tokens. - ## License and attributions -The palettes comes from [Catppuccin](https://github.com/catppuccin/catppuccin), licensed under the MIT License. +The palettes come from [Catppuccin](https://github.com/catppuccin/catppuccin), licensed under the MIT License. From 337444dd3f735ea9622241fb4baebbba830e4d34 Mon Sep 17 00:00:00 2001 From: Sleeyax Date: Sun, 11 Oct 2026 13:42:57 +0200 Subject: [PATCH 03/31] docs: trim the root README Put installation and the settings location first, since that is what a user of the plugins needs, and shorten local development and releasing. Drop the note on settings from before Paseo 0.9, which no installation is still on. Co-Authored-By: Claude Opus 5.5 --- README.md | 60 +++++++++++++++++++++++++++---------------------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 0fac6a5..e780750 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,23 @@ Extensions for [Paseo](https://github.com/getpaseo/paseo), organized in a pnpm w | [Claude TTY](plugins/claude-tty) | Offer the Claude TTY ACP adapter as a Paseo provider, and manage it on the daemon host. | | [Guided Review](plugins/guided-review) | Show a trunk-first guide to a pull request or merge request, and build your draft review alongside it. | -Each app and plugin has its own README with installation, settings, and development details. +Each app and plugin has its own README. + +## Installation + +Enable plugins in Paseo, then install a plugin by its directory in this repository: + +```sh +paseo plugin add github:sleeyax/paseo-plugins:plugins/discord-rich-presence +``` + +Plugins in the [Paseo plugin registry](https://paseo.sh/plugins) also install by their registry ID, which their README gives. +Update with `paseo plugin update `, or preview the update with `paseo plugin update --check`. + +`claude-tty` and `guided-review` run a `pnpm` build when installed or updated, so they need `pnpm` on the daemon's `PATH`. + +Plugins keep their settings in `$PASEO_HOME/plugin-settings//`. +Paseo manages that directory and removes it with the plugin. ## Local development @@ -25,55 +41,37 @@ pnpm typecheck pnpm test ``` -Enable Paseo plugins, then install each plugin by its directory in this repository: - -```sh -paseo plugin add github:sleeyax/paseo-plugins:plugins/discord-rich-presence -paseo plugin ls -``` - -Paseo clones the repository itself and tracks the default branch, so `paseo plugin update ` keeps an installation current and `paseo plugin update --check` previews what it would install. Plugins published to the [Paseo plugin registry](https://paseo.sh/plugins) also install by their registry ID, which their READMEs give. - -Two plugins ask something of the host: `claude-tty` runs an adapter that has to be built, and `guided-review` bundles Paseo's syntax highlighter, an npm dependency. Installing and updating either runs the `build` commands in its manifest, so both need `pnpm` on the daemon's `PATH`; `claude-tty` can instead take a path to an adapter built elsewhere, in its own **Adapter executable** setting. - -To work on a plugin, install it from the working copy instead. A directory installation runs no `build`, so build what it needs first: +To work on a plugin, install it from your working copy. +A directory installation runs no build, so build what the plugin needs first: ```sh paseo plugin add "/absolute/path/to/paseo-plugins/plugins/discord-rich-presence" ``` -After making changes, run `paseo plugin reload `. Paseo does not hot-reload plugins, and reloading is the compile check for the client and server bundles built from `index.client.tsx` and `index.server.ts`. +Paseo does not hot-reload plugins. +Run `paseo plugin reload ` after each change; the reload is also what compiles the plugin's bundles. ## Releasing a plugin -A plugin's `version` in its own `package.json` is its update identity: [Paseo Cafe](https://github.com/paseo-cafe/paseo-cafe) compares it against the installed copy's, and only a higher version offers an update. -Until it moves, installations stay on the old code however many commits land here. - -[release-please](https://github.com/googleapis/release-please) does the bumping, from the conventional commits on `main`: a `feat` takes a minor, a `fix` takes a patch, and a `chore` or `docs` releases nothing. -It keeps a single release pull request open and adds to it as more commits land, so a release can cover one pull request or a batch of them. -Merging it is the release: the new versions and each package's `CHANGELOG.md` are written, tagged, and published as GitHub releases. -Nothing reaches npm, since every package here is private. +[release-please](https://github.com/googleapis/release-please) releases from the conventional commits on `main`: a `feat` bumps the minor version, a `fix` the patch version, and `chore` or `docs` release nothing. +It keeps one release pull request open, and merging it is the release: versions, `CHANGELOG.md` files, tags and GitHub releases. -Which package a commit bumps comes from the files it touches rather than its scope, so keep a change inside the plugin it belongs to. -`claude-tty` and the `claude-tty-acp` adapter it runs are versioned together, because a change to the adapter changes what the plugin ships. +A plugin's `version` in its `package.json` is what [Paseo Cafe](https://github.com/paseo-cafe/paseo-cafe) compares to offer an update, so installations only move when the version does. +release-please picks the package to bump from the files a commit touches, not its scope, so keep each commit inside one package. +`claude-tty` and `claude-tty-acp` share one version. ## Skills -`skills/` holds agent skills for working on this repository, in the [Agent Skills](https://agentskills.io) format, so any harness that reads them can use them. -`.claude/skills/` and `.agents/skills/` are symlinks into it — the first is where Claude Code looks, the second is where Codex, Cursor, OpenCode and GitHub Copilot do — and editing the file under `skills/` updates every harness at once. +`skills/` holds agent skills for working on this repository, in the [Agent Skills](https://agentskills.io) format. +`.claude/skills/` (Claude Code) and `.agents/skills/` (Codex, Cursor, OpenCode, GitHub Copilot) are committed symlinks into it, so edit the files under `skills/`. | Skill | Description | | --- | --- | | `update-plugins` | Rebuild the apps and reload the plugins this host has installed from this checkout. | | `adopt-paseo-release` | Review the Paseo releases since the last review and file an issue with what the plugins should adopt. Runs only when invoked by name: `/adopt-paseo-release` in Claude Code, `$adopt-paseo-release` in Codex. | -Working in this repository needs no installation; the symlinks are committed. To use a skill from another checkout, install it by name: +To use a skill from another checkout, install it by name: ```sh npx skills add sleeyax/paseo-plugins --skill update-plugins ``` - -## Plugin settings - -Plugins keep their settings in `$PASEO_HOME/plugin-settings//`, managed by Paseo and removed with the plugin. -Versions from before Paseo 0.9 used `${XDG_CACHE_HOME:-~/.cache}/paseo-plugins//` instead; each plugin's README says whether it carries those settings over. From 27c9994b6f49e5055b7cb26fbea707c49260e31e Mon Sep 17 00:00:00 2001 From: Sleeyax Date: Sun, 11 Oct 2026 13:45:25 +0200 Subject: [PATCH 04/31] docs(discord-rich-presence): rewrite the README to the new template Split usage into tasks, add a settings table, and correct the troubleshooting rows against the code: the real "Discord is not running" label, where the Application ID field sits, and how the plugin finds the daemon and its password. Drop the note on settings from before Paseo 0.9 and the implementation notes that only matter to whoever changes the code. Co-Authored-By: Claude Opus 5.5 --- plugins/discord-rich-presence/README.md | 93 ++++++++++++++----------- 1 file changed, 54 insertions(+), 39 deletions(-) diff --git a/plugins/discord-rich-presence/README.md b/plugins/discord-rich-presence/README.md index 4d99362..e32c13c 100644 --- a/plugins/discord-rich-presence/README.md +++ b/plugins/discord-rich-presence/README.md @@ -2,7 +2,7 @@ [![Paseo Cafe](https://img.shields.io/badge/Paseo_Cafe-discord--rich--presence-blue)](https://paseo.cafe/plugins/discord-rich-presence) -Show your current Paseo activity on Discord. The Paseo daemon and Discord app must run on the same machine. +Show your current Paseo activity on your Discord profile. ## Screenshots @@ -14,27 +14,27 @@ Show your current Paseo activity on Discord. The Paseo daemon and Discord app mu ## Installation -```sh -paseo plugin add sleeyax/discord-rich-presence -``` - -That installs the release published to the [Paseo plugin registry](https://paseo.sh/plugins), which needs Paseo 0.11 or later. To install straight from this repository's default branch instead: +You need Paseo 0.11 or newer. +The Discord desktop app must run on the same machine as the Paseo daemon. +Native, Snap, and Flatpak installs of Discord all work. ```sh -paseo plugin add github:sleeyax/paseo-plugins:plugins/discord-rich-presence +paseo plugin add sleeyax/discord-rich-presence ``` -Either way, `paseo plugin update discord-rich-presence` picks up new releases without a clone, and `paseo plugin update discord-rich-presence --check` says what is installed against what is available. +Run `paseo plugin update discord-rich-presence` to get new releases. -The plugin uses a shared Paseo Discord application by default. Open **Discord** in the Paseo sidebar to see the connection status and change its settings. +## Usage -Native, Snap, and Flatpak Discord installations are supported. The plugin reconnects automatically when Discord starts. +### Open the panel -Requires Paseo 0.11 or newer. Versions of the plugin made for Paseo older than 0.9 stored settings in `${XDG_CACHE_HOME:-~/.cache}/paseo-plugins/discord-rich-presence/`, which is no longer read. After updating from one of those, set your detail levels and application ID again (until then, every project shows as **Detailed**), then delete that directory. +Select **Discord** in the Paseo sidebar. +The panel shows the connection status, a preview of your presence, and the settings. +The presence starts on its own, and reconnects when you start Discord later. -## Settings +### Choose how much to show -**Detail level** decides how much of your work reaches your profile: +The detail level decides how much of your work reaches your profile: | Level | Example | | --- | --- | @@ -42,15 +42,20 @@ Requires Paseo 0.11 or newer. Versions of the plugin made for Paseo older than 0 | Projects only | `paseo-plugins` and `3 workspaces` | | Hidden | `Using Paseo` | -The second line counts only the project named on the first, so work in your other projects never shows through. +Discord names one project at a time, and the second line counts only that project. + +Set the level for every project with **All projects**. +To give one project its own level, change it in the list below. +Workspace titles can contain task details, so check them before you leave a project on **Detailed**. -**All projects** sets the level every project follows. Every project Paseo knows is listed beneath it, whether or not it has a workspace open, and each starts on **Default**. Giving one a level of its own saves it by project path until you set it back to **Default**. Workspace titles can contain task details, so review them before leaving a project on **Detailed**. +You can also search the Command Center for `Discord rich presence`. +It turns the presence on or off, and from a workspace it sets the level of that workspace's project. -A project set to **Hidden** is never named: Paseo names another project with active work when possible, and otherwise falls back to `Using Paseo`. +When no workspace has been active for 30 minutes, Discord shows only `Using Paseo`. -Use the sidebar or Command Center to turn the presence on or off and to show the current project as Detailed, Projects only, Hidden, or at the default level. Turning the presence off removes it immediately. +### Read the status badge -The status badge matches the workspace state shown by Paseo: +The small badge shows the state of the workspace Discord names: | Badge | State | | --- | --- | @@ -60,49 +65,59 @@ The status badge matches the workspace state shown by Paseo: | Green | Finished and waiting for you | | Grey | Idle | -The green badge may appear briefly whenever an agent finishes a turn. It clears after you view the session. +The green badge can appear briefly each time an agent finishes a turn. +It clears once you view the session. -## Use your own Discord application +### Use your own Discord application -The default Application ID is shared. To use your own: +By default, the presence uses a shared Paseo application. +To use your own instead: -1. Create an application in the [Discord Developer Portal](https://discord.com/developers/applications). Its name becomes the bold title in your presence. +1. Create an application in the [Discord Developer Portal](https://discord.com/developers/applications). Its name becomes the bold title of your presence. 2. Copy its **Application ID** from **General Information**. -3. Under **Rich Presence → Art Assets**, upload the six files from `assets/` using their filenames as the asset keys. -4. Enter the new Application ID in the plugin settings and select **Save**. +3. Under **Rich Presence → Art Assets**, upload the six images from [`assets/`](./assets). Keep their file names as the asset keys. +4. In the panel, open **Advanced**, paste the Application ID, and select **Save**. -Discord may take a few minutes to process uploaded artwork. Clearing the Application ID disables the connection until you save another one. +Discord can take a few minutes to process new artwork. + +## Settings + +| Setting | Default | What it does | +| --- | --- | --- | +| Show my activity on Discord | On | Turns the presence on or off. Turning it off removes it from your profile at once. | +| All projects | Detailed | The detail level every project follows. | +| A project's level | Default | Overrides the detail level for that project. | +| Application ID | The shared Paseo application | The Discord application your presence appears under. | ## Troubleshooting -| Message or symptom | What to do | +| Message | What to do | | --- | --- | -| **Discord refused this application ID** | Copy the ID again from the Developer Portal and save it. The plugin does not retry a refused ID until it changes. | -| **Discord not running — retrying…** | Start Discord and wait for the plugin to reconnect. | -| **Cannot read Paseo** | Check that the daemon is reachable and is not listening on a Unix socket. The plugin checks `PASEO_HOST`, `PASEO_LISTEN`, `$PASEO_HOME/paseo.pid`, then the daemon config. | -| Icons appear without images | Check the asset filenames and wait for Discord to finish processing them. | +| **Discord is not running — retrying…** while Discord is open | Run Discord as the same user as the Paseo daemon. The plugin looks for Discord's socket in `$XDG_RUNTIME_DIR`, then `$TMPDIR`, then `/tmp`. | +| **Discord refused this application ID** | Check the ID in the Developer Portal. The plugin tries again only after you save a different ID, or turn the presence off and on. | +| **Cannot read Paseo** | The plugin reads the daemon address from `PASEO_HOST`, `PASEO_LISTEN`, `$PASEO_HOME/paseo.pid`, then `daemon.listen` in `$PASEO_HOME/config.json`, and falls back to `127.0.0.1:6767`. If the daemon needs a password, set `PASEO_PASSWORD` in the daemon's environment. | +| The badge or icon has no image | Check that each asset key matches its file name, then give Discord a few minutes. | Run `paseo plugin logs discord-rich-presence` for more detail. ## Development ```sh -pnpm typecheck -pnpm test +pnpm --filter @paseo-plugins/discord-rich-presence typecheck +pnpm --filter @paseo-plugins/discord-rich-presence test paseo plugin reload discord-rich-presence paseo plugin logs discord-rich-presence ``` -Paseo builds the app bundle from `index.client.tsx` and the daemon bundle from `index.server.ts`. A module's directory decides which it joins: `client/` the app's, `server/` the daemon's, `shared/` both. - -The Discord IPC client in `server/ipc.ts` is implemented without a dependency because CommonJS packages do not survive Paseo's plugin compiler. - -To regenerate the Discord artwork after Paseo changes its assets, run: +To regenerate the artwork in `assets/` after Paseo changes its own, run this against a Paseo checkout: ```sh scripts/render-assets.sh /path/to/paseo ``` -## Attribution +The script needs ImageMagick (`magick`) and `rsvg-convert`. + +## License and attributions -The files in `assets/` are derived from the Apache-2.0-licensed artwork in [getpaseo/paseo](https://github.com/getpaseo/paseo/tree/main/packages/app/assets/images). Paseo relicensed from AGPLv3 to Apache-2.0; note that the licence's trademark clause does not grant any right to Paseo's name or marks. +The images in `assets/` are derived from the Apache-2.0 artwork in [getpaseo/paseo](https://github.com/getpaseo/paseo/tree/main/packages/app/assets/images). +That licence grants no right to Paseo's name or trademarks. From 8588ba61afd6fbfbbc751d2ec7c5ec4c86a71e30 Mon Sep 17 00:00:00 2001 From: Sleeyax Date: Sun, 11 Oct 2026 13:46:19 +0200 Subject: [PATCH 05/31] docs(claude-tty-acp): cut the README down to what users need Keep the reason the adapter exists, requirements, usage, an environment variable table, short limitations and troubleshooting. Move what passes as a design decision out of "How it works" into ADRs: the PTY-driven interactive CLI, reading output from the transcript, hooks for everything interactive, and holding the turn open for background work. Give the package a CLAUDE.md with the traps that break the adapter when unknown, and cut the rest of the internals. Correct the README against the code: document CLAUDE_TTY_ACP_STATE_SCOPE, inline embedded text up to 32 KB instead of writing it to a file, and say that declining the Bypass Permissions disclaimer fails the session start. Co-Authored-By: Claude Opus 5.5 --- apps/claude-tty-acp/CLAUDE.md | 42 ++ apps/claude-tty-acp/README.md | 486 +++--------------- ...0001-drive-the-interactive-cli-in-a-pty.md | 6 + .../0002-read-output-from-the-transcript.md | 6 + ...0003-hooks-carry-everything-interactive.md | 6 + ...-hold-the-turn-open-for-background-work.md | 6 + 6 files changed, 143 insertions(+), 409 deletions(-) create mode 100644 apps/claude-tty-acp/CLAUDE.md create mode 100644 apps/claude-tty-acp/docs/adr/0001-drive-the-interactive-cli-in-a-pty.md create mode 100644 apps/claude-tty-acp/docs/adr/0002-read-output-from-the-transcript.md create mode 100644 apps/claude-tty-acp/docs/adr/0003-hooks-carry-everything-interactive.md create mode 100644 apps/claude-tty-acp/docs/adr/0004-hold-the-turn-open-for-background-work.md diff --git a/apps/claude-tty-acp/CLAUDE.md b/apps/claude-tty-acp/CLAUDE.md new file mode 100644 index 0000000..ced6d94 --- /dev/null +++ b/apps/claude-tty-acp/CLAUDE.md @@ -0,0 +1,42 @@ +# Working on the adapter + +Read the ADRs in `docs/adr/` first: they explain why the adapter types into a PTY, reads the transcript and relies on hooks. + +## stdout is the ACP stream + +The server speaks ACP as newline-delimited JSON on stdout, so any other byte written there corrupts the connection. +Log with `writeLog` from `src/log.ts`, which writes to stderr and to the log file under the state directory. +The daemon drops stderr, so the file is the only record of a session. + +## One write to the PTY is one read for Claude + +Claude reads a multi-character write as pasted text, not as keys. +Send each control key as its own write with a gap after it, as the Ctrl-U clear does, or Claude inserts them as literal characters. +Large pastes go in batches for the same reason, see `PASTE_WRITE_BYTES`. + +## The client has no session until the response + +Paseo drops any session update for a session ID it has not been given yet. +Anything sent from `session/new` or `session/load` (commands, a replayed history) goes out after the handler returns, which is why both use `setImmediate`. + +## Paseo reads "busy" from the open turn only + +ACP has no other busy signal, so every turn must end through a hook or an explicit release. +A prompt Claude never took fires no hook, so it must fail the prompt rather than return; otherwise the session reads busy forever and is never suspended. +Paseo cancels before every mid-turn message and gives the cancelled `session/prompt` 2 seconds to answer, so anything added to the cancel path has to fit inside that budget. + +## Cards Paseo must not answer use only reject kinds + +Paseo's automatic permission modes accept an `allow_*` option without showing a card. +A card for a decision that must come from a person (startup consent, Claude's own dialogs) offers every option with a `reject_*` kind and tells them apart by `optionId`. + +## A failed tool call carries an error + +A `tool_call_update` with `status: "failed"` must carry a non-null `rawOutput`. +Paseo's timeline schema requires an error on failed items, and one item without it makes the client refuse the agent's whole history. + +## Claude's terminal UI is measured, not specified + +Readiness, dialog detection and the input-box echo match what Claude Code draws, measured against the versions named in the code comments. +A Claude Code release can break them without any change here. +The log records every dialog transition with its screen; turn those screens into test fixtures before changing a parser. diff --git a/apps/claude-tty-acp/README.md b/apps/claude-tty-acp/README.md index 2e1dba1..f64aefa 100644 --- a/apps/claude-tty-acp/README.md +++ b/apps/claude-tty-acp/README.md @@ -1,464 +1,132 @@ # Claude TTY ACP -Run the genuine interactive Claude Code CLI as a native Paseo agent. - -Paseo speaks [ACP](https://agentclientprotocol.com) to this adapter, and the adapter keeps a single real `claude` process in a PTY per session. -It translates that process's transcripts and hooks into native Paseo messages, reasoning, tool calls, plans, permissions, models, modes, effort levels, slash commands, attachments, cancellation, and history. -There is no `claude -p` and no Claude Agent SDK anywhere in the path. +An [ACP](https://agentclientprotocol.com) adapter that runs the real interactive Claude Code CLI as a Paseo agent. +Each session is one `claude` process in a terminal, and you get Paseo's native timeline, tool calls, plans, permission cards and pickers on top of it. ## Why? Paseo's built-in Claude agent runs on the Claude Agent SDK. -Agent SDK integrations appear to consume usage limits faster than the Claude Code CLI or its editor extensions, [as reported for t3code and similar tools](https://github.com/pingdotgg/t3code/issues/7338#issuecomment-5426425282); whether the cause is the SDK, its integrations, or Anthropic's backend is unclear. - -This adapter avoids the SDK by driving the same CLI you would otherwise run in a terminal. -You keep its binary, authentication, billing, sessions, `CLAUDE.md` hierarchy, MCP servers, plugins, skills, and permission rules, and get Paseo's native agent UI on top of them. -The price is that every native affordance has to be reconstructed from terminal output, transcript files, and hooks, which is where the [limitations](#limitations) come from. +Agent SDK integrations appear to consume usage limits faster than the Claude Code CLI does, [as reported for t3code and similar tools](https://github.com/pingdotgg/t3code/issues/7338#issuecomment-5426425282). +This adapter avoids the SDK by driving the same CLI you would run in a terminal. +You keep its binary, login, billing, sessions, `CLAUDE.md` files, MCP servers, plugins, skills and permission rules. +The cost is that Paseo's features are rebuilt from Claude's transcripts, hooks and screen, which is where the [limitations](#limitations) come from. ## Requirements - Linux or macOS - Node.js 22 or newer -- pnpm -- A current Claude Code CLI, authenticated on the same host as the Paseo daemon - Paseo 0.11 or newer +- Claude Code, logged in as the user the Paseo daemon runs as ## Installation -The adapter is installed through the [Claude TTY plugin](../../plugins/claude-tty): `paseo plugin add github:sleeyax/paseo-plugins:plugins/claude-tty` clones this repository, builds the adapter, and registers it as the `claude-tty` provider. -The plugin's README covers installing, updating and removing it, and upgrading a host that registered this adapter in its Paseo configuration by hand. - -Setup is host-local: the adapter runs wherever the Paseo daemon runs, so install the plugin on each host that should offer Claude. -See [Multiple hosts](#multiple-hosts) for what that means in practice. - -### Authenticate Claude - -Run `claude` interactively as the same OS user that runs the Paseo daemon, and complete authentication before using the provider. -If the daemon runs through systemd, SSH into that account or use an equivalent login shell so Claude writes its credentials into the correct home directory. - -### Multiple hosts - -Hosts share nothing: no processes, ports, locks, state, credentials, paths, or sessions. -Selecting a VPS in a client makes that VPS's daemon launch its own adapter and Claude process, read the VPS's Claude configuration and transcripts, and stream ACP back to the client. -The same provider ID is therefore safe to use everywhere. - -## Environment - -The adapter inherits the Paseo daemon's environment. - -| Variable | Purpose | -| --- | --- | -| `CLAUDE_BIN` | Absolute path to Claude when the daemon's `PATH` cannot find `claude`. | -| `CLAUDE_CONFIG_DIR` | Existing Claude configuration, credentials, plugins, commands, skills, and transcript root. | -| `CLAUDE_TTY_ACP_STATE_DIR` | Adapter session mappings and locks; defaults to the host's XDG state directory. | -| `CLAUDE_TTY_ACP_IDLE_TIMEOUT_MS` | Stop an idle session's native Claude process after this many milliseconds. Overrides the Claude TTY plugin's saved setting; without either, the default is `3600000` (one hour). Set to `0` to disable suspension. A value that is not a whole number of milliseconds in range is logged and ignored rather than fatal. | -| `XDG_STATE_HOME` | Base for adapter state when `CLAUDE_TTY_ACP_STATE_DIR` is unset. | - -For a systemd service, set `CLAUDE_BIN` to the output of `command -v claude` in the daemon user's login shell rather than relying on a shell-specific `PATH`. - -## Sessions - -Paseo session IDs stay stable even though Claude's own session ID can rotate after `/clear`. -The mapping is persisted only after the first real prompt, so provider probes leave no saved sessions behind. -Loading a session replays its transcript and launches `claude --resume` lazily on the next prompt. -The replay goes out *after* `session/load` has answered, never before: until that answer the client has no session to put an update on, and everything sent ahead of it is dropped -- which is a resumed session coming up with an empty timeline while Claude still holds the whole conversation. -A replay that fails costs the timeline rather than the session, which is left open to carry on. +Install the [Claude TTY plugin](../../plugins/claude-tty/README.md#installation); it builds this adapter and registers it as a provider. -Each active session owns an isolated PTY, hook route, transcript reader, permission bridge, lock, and attachment directory. -Sessions run concurrently, work inside a single session stays serialized, and a second adapter process cannot open a session that is already active on the host. -Locks left behind by dead processes are recovered automatically. +Then run `claude` once, interactively, as the user the daemon runs as, and log in. +If the daemon runs under systemd, open a login shell for that account so the credentials land in its home directory. -Once a session has been quiet for an hour, by default, the adapter stops its native Claude process. -When that timeout expires it sends Ctrl-D, kills the PTY if it does not exit promptly, and thereby stops any background tasks still owned by that Claude process. -The logical ACP session, transcript mapping, selected model and mode, and session lock remain intact. -The next prompt launches `claude --resume ` automatically, so the conversation continues without keeping its process alive indefinitely. -Quiet is observed rather than inferred from prompts: the clock runs from the last thing the session actually did, whether that was the end of a prompt, a hook Claude called, a record it wrote, a turn it was woken for by a task notification, or a step one of its background agents took. -A prompt is only what Paseo asked for; Claude goes on working after one — answering for an agent that reported, launching the next — and a suspension measured from the prompt alone stopped exactly that work an hour into it, mid-run. +The adapter runs on the daemon's host, so repeat both steps on every host that should offer Claude. -The timeout is read at each suspension rather than once at startup: `CLAUDE_TTY_ACP_IDLE_TIMEOUT_MS` if it is set, otherwise `idleTimeoutMs` in the JSON file the Claude TTY plugin passes as `--settings-file`. -Reading it per suspension is what lets a change on that settings screen reach a session that is already connected, and it is also why an unreadable file or a malformed variable leaves the session running rather than taking the adapter down. -An adapter started without `--settings-file` — by hand, or by anything that is not that plugin — has the variable and the default and nothing else. +## Usage -A suspension waits for its session to be genuinely idle. It stands aside while a turn is running and while a permission or question card is still waiting for an answer — stopping Claude then would cancel the request behind that card and leave it on screen in Paseo answering to nothing — and a suspension that fails re-arms rather than giving up, because giving up once would keep that process alive for the rest of its life. +### Sessions -## Models, modes and effort +A new session starts nothing until you send its first prompt. +Reopening a saved session replays its conversation and resumes the same Claude session on your next prompt. +A session can be open in only one adapter at a time. +After a quiet spell Claude is stopped and resumed on the next prompt; the plugin's [Suspend idle Claude](../../plugins/claude-tty/README.md#settings) setting controls when. -The model selector offers Claude Code's rolling aliases — `inherit`, `opus`, `fable`, `sonnet`, `haiku` — plus the full catalog that Paseo's native Claude provider exposes, including explicit releases and 1M-context variants. -Claude Code has no supported way to list models without opening an interactive session, so that catalog is versioned with the adapter while the aliases keep following Claude's. +### Models, modes and effort -The mode selector offers Default, Accept Edits, Plan, Auto, and Bypass Permissions. +The model picker lists Claude Code's rolling aliases (`opus`, `fable`, `sonnet`, `haiku`) and specific releases, including the 1M-context variants. +**Default** passes no flag, so Claude uses whatever its own settings name. +The modes are Default, Accept Edits, Plan, Auto and Bypass Permissions. +The thinking picker holds Claude Code's effort levels, from Low to Max. -The effort selector offers Claude Code's own `--effort` levels — Low, Medium, High, Extra high and Max — ahead of a Default that passes no flag and leaves Claude at whatever its settings name. -Paseo shows it as the session's thinking level. +You can change any of them while the session is idle, and Claude restarts on the same conversation with the new setting. +During a turn the change is refused. -Bypass Permissions is last and named for what it does: nothing stops a command before it runs, which is why it is a mode a person picks per session rather than a default. -Claude gates it behind a disclaimer it keeps once per host, so the first session that asks for the mode raises a card in Paseo carrying the same warning; accepting it answers Claude's dialog, declining it fails the session start rather than quietly running in another mode. -Because Claude remembers the answer, the card appears once on a host and never again — including for the scheduled, unattended sessions the mode exists for. +The first Bypass Permissions session on a host shows Claude's own disclaimer as a card. +Declining it fails the session start; once you accept it, Claude does not ask again on that host. -Changing any of the three before launch changes startup flags. -Changing one while idle restarts and resumes Claude with deterministic flags, and changing one during a turn is rejected. +Auto Accept is covered in the plugin's [settings](../../plugins/claude-tty/README.md#settings). -All three are published twice, because the two ACP bridges Paseo can run this adapter on read different fields. -`session/new` answers with the older `models` and `modes` state *and* with `configOptions` carrying a `model` selector and a `thought_level` one, and the adapter serves both `session/set_model` and `session/set_config_option`. -The daemon's own bridge prefers `models` and takes the effort levels only from the config options; the bridge a Paseo plugin provider runs on reads `configOptions` and nothing else, and would show an empty model picker without them. +### Dialogs Claude opens -### Auto Accept +When Claude stops at a question of its own, such as workspace trust, a plugin suggestion or `/rewind`, you get it as a card in Paseo. +Paseo never answers these cards for you, whatever mode the session is in. +Declining the external `CLAUDE.md` imports question starts the session without those files, and the timeline names them. -`configOptions` also carries `auto_accept`, a boolean the plugin provider's bridge shows as a toggle on the agent, with the ID Paseo gives its own ACP providers' toggle. -While it is on, a `PermissionRequest` is answered with Allow once instead of a card, and none of Claude's permission suggestions are applied, so switching it off again leaves no rule behind. -That covers the prompts Claude Code keeps even in Bypass Permissions mode, such as a removal of the working directory's contents; `AskUserQuestion` and `ExitPlanMode` are for a person and still raise their cards. +When Claude switches to a fallback model by itself, the timeline says so, the model picker follows, and a card with **OK** makes Paseo notify you. -A session nobody has switched follows the plugin's settings for its current mode: `bypassAutoAccept` for a Bypass Permissions session unless it is null, and otherwise `autoAccept`, both in the file named by `--settings-file`. -They are read again at every request and whenever the mode changes, and the session publishes a `config_option_update` when the answer moves, so the toggle shows what the next request gets. -Without a readable file the answer is off. -Switching the toggle is taken even mid-turn, restarts nothing, and is saved with the session, after which the settings no longer apply to it. +### Attachments -## Prompt content +Images, and embedded files that are binary or over 32 KB, are written to private temporary files and passed to Claude as `@path`. +Links to files on the host become `@path` references. +Audio and links to anything other than a host file are rejected. -Images and embedded resources become mode-0600 host-local files for the duration of a turn. -Host-local file links become `@path` references. -Audio and remote resource links are rejected with an explicit error. +## Environment variables -## How it works +The adapter inherits the Paseo daemon's environment. -```text -Paseo ──ACP over stdio──► claude-tty-acp ──keystrokes over a PTY──► claude - ▲ ▲ - hooks over loopback HTTP┘ └transcript JSONL, polled -``` +| Variable | Default | What it does | +| --- | --- | --- | +| `CLAUDE_BIN` | `claude` on `PATH` | The Claude Code executable. Use an absolute path when the daemon's `PATH` cannot find it. | +| `CLAUDE_CONFIG_DIR` | `~/.claude` | Claude's configuration, credentials, commands, skills and transcripts. | +| `CLAUDE_TTY_ACP_STATE_DIR` | `$XDG_STATE_HOME/claude-tty-acp` | Saved sessions, locks and the adapter log. | +| `XDG_STATE_HOME` | `~/.local/state` | Base of the default state directory. | +| `CLAUDE_TTY_ACP_STATE_SCOPE` | `shared` | `workspace` keeps each working directory's sessions and log under `workspaces/` in the state directory. Any other value makes sessions fail to open. | +| `CLAUDE_TTY_ACP_IDLE_TIMEOUT_MS` | `3600000` | Milliseconds of quiet before Claude is stopped; `0` never stops it. Overrides the plugin's setting. An invalid value is logged and ignored. | -The plugin starts one adapter process per ACP session, and that process speaks ACP as newline-delimited JSON on stdout while its structured logs go to stderr — and, because the daemon reads stderr and keeps none of it, to `logs/claude-tty-acp.log` under the state directory as well, where every process on the host appends with its pid and a full file is moved aside once. -The session owns its own `claude` process in a PTY. - -A session starts empty: `session/new` returns an ID immediately and launches nothing, so a provider probe or an untouched draft never spawns Claude. -The first prompt launches `claude --session-id ` and later launches reuse `claude --resume `, with the model, mode and effort selectors translated into `--model`, `--permission-mode` and `--effort`. - -Every launch gets a private runtime directory holding a generated `settings.json` and hook client, passed with `--settings`, so the adapter registers its hooks and its status line without touching the user's Claude configuration. -No global hooks are required; remove any legacy hooks left in `~/.claude/settings.json` after uninstalling the old panel plugin. -Startup is complete once the `SessionStart` hook has arrived and Claude's interactive prompt appears on a headless xterm screen that mirrors the PTY; that screen is used only for readiness, dialog detection, confirming a prompt left the input box, and the terminal snapshot in startup errors. -If Claude stops first at its workspace-trust screen, the adapter surfaces the exact cwd as an ACP permission card in Paseo. -Approving the card selects **Yes, I trust this folder** in the PTY and gives Claude a fresh startup window; denying or cancelling it fails closed without changing Claude's trust state. - -A project whose CLAUDE.md `@`-imports a file from outside the working directory stops at a second question of the same kind — **Allow external CLAUDE.md file imports?** — and it is carded too, with the paths Claude listed in it: an import is read into Claude's context as instructions, and "external imports" with nothing named is not something anybody can approve. -Refusing this one does not fail the start, because **No, disable external imports** is a session that runs without those files — so a refusal takes it, and a notice in the session's timeline names the files that were left out. -An unanswered card lands there too, and that is half the point of the choice: an unattended session comes up on the safe side rather than sitting at a dialog until the handshake times out. - -Readiness also requires the screen to have stopped changing, and that is not a nicety. -A resumed session paints its entire conversation before its input box exists, and text inside that conversation can satisfy every readiness signal on its own — a footer quoted in a message, a token count, the words `auto mode on`. -A prompt pasted into that window is dropped, and the paste then never echoes -- which is the one thing the submit loop reads, and now what it refuses to proceed without. - -The second thing a resume can stop at is Claude asking how to open a long or old conversation — **Resume from summary**, **Resume full session as-is**, **Don't ask me again** — and the idle timeout guarantees every long-lived session eventually meets it. -The adapter answers it the same way it answers the trust screen, by moving the selection and pressing Enter, and it always keeps the full session: the conversation is the ACP session it was asked to restore, and a summary would silently replace the history Paseo has already replayed into its timeline. -Left unanswered this dialog is what a lost first prompt after a suspension looks like from the outside: the readiness signal came from the restored conversation behind the dialog, and the Enter that followed the paste answered the dialog instead of sending the prompt. - -Slash commands are the one thing the adapter never asks Claude for: an interactive session has no way to list them, so the adapter walks the user's, the project's and every enabled plugin's command and skill directories itself and publishes the result as ACP available commands. - -### Prompts go in as keystrokes - -A prompt is flattened into one block of text: images and embedded resources become files in the runtime directory referenced as `@path`, and host-local resource links become `@path` directly. -The adapter clears Claude's input box with Ctrl-U, writes that text into the PTY as bracketed pastes, waits briefly, then writes Enter, exactly as a person pasting into the terminal would. -The text goes in a line at a time, each line as pastes of at most 500 characters and the line breaks as newline keys, because Claude collapses a paste of more than three lines or about 800 characters into a `[Pasted text]` placeholder and hands it to the model inside `` tags, as text the user did not write themselves. -A prompt that arrived as nothing but such a paste was one the model would ask about rather than act on: a guide agent's prompt came back as a question about what to do with "the pasted guide-agent prompt". -Pieces of that size come through inline, measured against Claude Code v2.1.295 up to 1,500 lines, so the prompt reaches the model as the user's own words; Claude turns tabs into spaces either way. -They are written in batches of at most a kilobyte, each ending where a piece does, a moment apart: Claude reads its input in whatever pieces the terminal hands over, and a paste's opening sequence cut between two reads reaches it as text. -A prompt that takes more than one batch is submitted only once its own end is on the screen and the screen has been still a moment, since Claude drops the Enter while it is still taking a long prompt in; a still screen alone does not say it is done, because Claude stops painting while it works through a large input. -The clear is what keeps a prompt from being read as the end of another one: Claude appends a bracketed paste to whatever the box already holds, and the box is not reliably empty -- interrupting a turn puts the prompt it interrupted back for editing, and a submit a cancel abandons between its paste and its Enter leaves that paste behind. -Paseo interrupts before it replaces a turn, so in a session it is steering both are one message away, and what Claude would otherwise receive is the two run together as a single prompt nobody wrote. -One Ctrl-U is not enough, because it kills the visual line the cursor is on and Claude wraps a long prompt across several; the keys go in one per line the box has grown to, bounded by the height of the screen, which is as tall as a box drawn on it can be. -They also go in one key per write with a gap behind them, because a burst of them is not keys at all: Claude reads a single write carrying forty control characters as pasted text and types every one of them into the box, so the clear that was meant to empty it is what fills it and the prompt reaches Claude behind forty U+0015. -That is what around 60 prompts in `~/.claude/projects` did between 2026-09-13 and this being fixed. -The box is read back between keys, since an empty one is the ordinary case and a key on it does nothing, and paying forty keys for it would put a second on the front of every prompt; the reading alone is not trusted, because the screen is sampled and a paste too recent to have been drawn is exactly the residue worth clearing, so the run ends on a few empty readings in a row and a box that never reads empty gets the screenful the bound allows. -It also ends where the keys have stopped changing anything, which is what a box holding a prompt *Claude* is suggesting looks like: that is grey ghost text rather than content, Ctrl-U does not remove it, and on a screen read for its characters it is indistinguishable from something typed — so without that stop every prompt to a session showing one paid the whole screenful and then reported a box it had failed to empty. -What is compared is the screen from the box down rather than the box's own line, because Ctrl-U kills the last of the lines a long prompt wrapped onto and leaves the first, which is the line the box is read from. -A box that did have something in it is named in the log, with the number of keys it took, whether it ended up empty, and whether the keys had simply stopped doing anything. -The paste ends with a space so Claude's completion menu is closed rather than swallowing that Enter, and the adapter watches its input box on the headless screen and presses Enter again while the prompt is still sitting there, because Claude drops the key while it is settling a paste. - -The echo is the first line of the box that is on screen, which for a prompt taller than the box is whichever of its lines Claude has scrolled to, so the box counts as holding the prompt when that line is any stretch of it. -That echo is also what says the prompt went in at all, and it is not always prompt: Claude reads a bracketed paste at once but only shows it a render later, and on a loaded host that render is what slips. -An Enter sent before it lands sends nothing, and the prompt turns up afterwards in a box nothing will press Enter on again -- so the adapter waits for a late echo rather than assuming there is not one coming, and submits it properly when it arrives. -If the echo never arrives, or the box will not let go of the prompt after every attempt, the prompt fails instead of returning: every way a turn ends is a hook Claude fires, so a prompt Claude never took ends nothing, and the session would show as working for the rest of its life with the message gone and no line written anywhere. -The turn it opened is released with it, so the session takes the next prompt normally. -A screen that has painted nothing at all is not read as an empty box -- it says nothing either way -- and a prompt the box was seen to let go of between two samples is checked against the session's own activity before it is called lost. - -Cancellation is the same kind of impersonation: an Escape keystroke, plus a short fallback that ends the turn when no `Stop` hook follows. -A turn held open only for background work is the exception and is ended without one: Claude has answered and gone back to its prompt, so there is no foreground to interrupt, and an Escape there stops nothing a subagent is doing while rewinding the turn Claude just finished and putting its prompt back in the input box. -Since Paseo cancels before it replaces a turn, that is the path every message sent while a subagent is still running takes, which is what makes chatting with a session during its background work work at all. -Changing the model, mode or effort while idle sends Ctrl-D, waits for the process to exit, and relaunches with `--resume`, which is why the change survives as a real flag rather than an in-band command. - -### Output comes out of the transcript - -Nothing that reaches Paseo is scraped from the terminal. -Claude appends its own JSONL transcript under its projects directory, and the adapter tails that file by byte offset every 40ms, re-reading from the start when the first bytes of the file change because Claude rewrote or replaced it. - -A translator turns each record into an ACP session update — user and agent chunks, thinking, tool calls with diffs and file locations, `TodoWrite` into a plan, token usage into a context-window update — and dedupes by record key so re-reads and history replay never emit the same thing twice. -Loading a persisted session runs that translator over the whole file to rebuild the conversation, and still launches nothing until the next prompt. - -Every tool-call update goes out twice: once as the ACP update, and just ahead of it as a copy on the vendor method `_claude_tty/tool_call`. -Paseo has two ACP bridges and only one of them reads a tool call whole. -Its own builds a card out of the update's `kind`, `content` blocks and `locations`; the one behind the plugin SDK's `runAcpProvider` keeps `rawInput` and `rawOutput`, renders every call as an edit or as raw JSON, and drops the rest before any hook it offers can see it. -The copy is the only way a plugin gets those fields back, and a client that has no use for it ignores it, which is what both of Paseo's bridges do with an extension they were not written for. - -A message typed while Claude is working is the one thing that is not written as a turn. -Claude absorbs it mid-turn, at its next tool result, and writes a `queued_command` attachment there: no user turn ever carries the message, and it reaches the transcript nowhere else, so that attachment is the only record of it. -It is read as the message, keyed by the id the queue gave the item rather than by the record it was written in, since a queue rewritten under a new record names the same item and says nothing new. - -### Subagents come out of their own transcripts - -A session's transcript says only that a subagent was launched, and one launched asynchronously answers its launcher the moment it starts. -Left there, an agent that runs for ten minutes would show as a tool call that finished immediately, with everything it then did happening out of sight. - -Claude writes each subagent's turns to `//subagents/agent-.jsonl`, and the adapter follows those files as well, five times a second, joining each to its launcher through the agent ID Claude records beside the launching tool result. -Only the end of a turn reads past that bound, for the last word; the flush a hook does before every tool call leaves it alone, because Claude is blocked on that flush and the subagents are not what it is waiting for. -An agent that has reported is dropped from the poll, so a session that runs hundreds of them is not still stat'ing the first. -Its steps — what it says, and the tools it calls — are streamed onto the launcher's tool call as a bounded tail of recent steps, which is why they never arrive as message chunks: a turn ends by counting those, and a subagent still working after its launcher has answered would hold the session's turn open for as long as it ran. -A card is rendered as plain text and has one line per step, so each is written for that: markdown is read back out of what the subagent says, a path keeps the tail that says which file it is, and a tool call is named by what it was for rather than by whichever argument came first. -A nested subagent's steps join the card of the agent that launched it, marked as its own. - -That tool call stays in progress until Claude reports the agent has stopped, which it does by writing a `` — the only record of it, and one that is otherwise scrubbed out of the text before it is shown. -Where that notification is written depends on what the session was doing when the agent finished: an idle session is woken with it as a user turn of its own, and one that is mid-turn has it queued instead, left behind only as a `queued_command` attachment. -Both are read, since reading the turn alone loses every agent that finished while Claude was working, and the same notification is written many times over — queued, delivered, and rewritten at every turn boundary the queue survives — so it is read as news only while the card it names is still open. -Anything but a clean finish leaves the call failed. -An agent Claude stops itself, with `TaskStop`, is closed on that stop instead: a stopped agent writes no report and sends no notification, so nothing else would ever close its card, and it would go on being counted as running until the bound below gave up on it. -A notification is not the end of an agent, only of a run of one: Claude notifies each time an agent stops with no live background children of its own, and a message sent to a stopped agent starts it again, so the same task id may notify many times over. -So a message Claude sends an agent reopens its card and holds the turn again, and the agent's next notification closes it as the first one did. Claude names the agent outright when the message resumes a stopped one, and names it in the pin it answers with when the message is queued for one that is still going; both are read the same way, since either says the agent is going to run. -Only an agent this session already knows is reopened, and never one a turn has given up on -- that turn stopped counting it deliberately, and every later turn would hold for a poll interval and give up again in the same breath. - -The session's turn is held open for as long as any of those agents is still running. -Paseo reads a session as busy from the turn it has open and from nothing else — an ACP agent has no other way to say so — and Claude goes idle the moment it launches a background agent, so without this a session with ten minutes of work ahead of it reads as ready, and the answer Claude writes when the agent reports arrives outside any turn at all. -The hold ends at the first `Stop` hook with nothing left running, which is the end of the turn Claude runs to react to the notification, so the reply about the agent lands inside the turn that launched it. -A turn that has been waiting on agents that have written nothing for fifteen minutes gives up and ends, because a session that is busy is also a session that is never suspended, and an agent that never reports would hold one open for the rest of its life. -Those fifteen minutes are measured from the agents alone: an agent that is still running shows it by writing, and nothing Claude does says whether it is. -Giving up is the end of it: those agents stop being counted, so the next turn does not hold for a poll interval and give up on them all over again. -Whichever bound has run out, the turn does not end while Claude is still writing: a report wakes Claude for the answer that belongs inside the turn, and that answer is given five minutes of its own, measured against anything Claude writes. -Five rather than one because Claude writes a response to its transcript only once the whole of it has streamed: a sentence followed by the long prompt of the next agent it dispatches shows nothing for as long as that prompt takes to generate, and a minute was not enough to cover one. -Cancelling is unaffected: a held turn ends as promptly as any other, which is what keeps Paseo's replacement of a prompt sent mid-turn inside its two-second budget. - -A command Claude runs in the background holds the turn open the same way, for the same reason. -It is dispatched exactly as an asynchronous agent is — the tool answers with the id of a task still running, Claude goes idle, and it is woken by a `` when the command ends — so a turn that ends at the `Stop` in between leaves everything Claude does from the report onwards outside any turn, which is a session that reads as ready while it works. -Claude records the id its report will name in `backgroundTaskId` beside the launching tool result, and the notification carries it as the ``, so the two are joined the way an agent's launch and report are. -The command has no card of its own: its tool call is the launch, which finished, and everything it does goes to a file the adapter does not follow. -A command an *agent* backgrounds is waited on the same way, and has to be: the agent reports the moment it has launched one, so its own report says nothing about whether the work is over, and a session whose agent left a twenty-minute command running would otherwise read as done the moment that agent stopped. -Its launch is in the agent's own transcript rather than the session's, which is where it is read from, and its notification arrives here naming it by task id, so it settles exactly as one of the session's own. -That notification is the one report that never reaches a user turn: it is written only as a record of Claude's queue, alongside the `queued_command` attachment, which carries what was queued as a prompt rather than this. -So those queue records are read too, for their notifications and nothing else -- without them a wait on a command an agent backgrounded would never end, and the session would read as busy for the rest of its life. -The same notification is written on the way in, on the way out, and again at every turn boundary the queue survives, which costs nothing: a card is closed only while it is open, and ending a wait that has already ended changes nothing. -A `TaskStop` naming its id ends the wait for it as it ends an agent's, since a stop names either through the one `task_id`. -That is also why its bound is a flat thirty minutes from the moment the turn was held rather than a silence: a running command shows nothing to measure a silence against, and the bound starts again whenever a command is launched or reports. -Thirty because backgrounding is what Claude does with a command precisely when it takes a while: on the sessions this was measured on the median command took four minutes and the longest genuine wait was a twenty-one minute code review, while the ones that ran for hours were servers and poll loops, which never report at all and are what the bound is there to let go of. -Each kind of work is bounded on its own and the turn ends only once every one of them has run out, so a command's thirty minutes are not cut short by the agents beside it having gone quiet first. - -### An adapter does not outlive its workspace - -The daemon closes the connection when it archives or deletes an agent — archiving a workspace archives each of its agents first — and that is what normally stops this process. -An adapter that close never reached has nothing left to end it, and a directory that is no longer there is the visible symptom: Paseo archives a workspace by deleting its directory, and an adapter left standing in one that has gone can do nothing for anyone — Claude cannot be started there — so it holds its memory until the machine is rebooted. -The adapter therefore checks the directory of every session it holds once a minute, and stops once all of them are missing at the same time, each having been missing twice running: twice rather than once so a workspace replaced at the same path, which is what reusing one looks like on disk, is not read as the end of the session. -Every directory is watched for as long as its session lives, so one that comes back counts as there again and keeps the adapter standing. -A directory that cannot be read for any other reason is still there, and no session is stopped over a failure to look. - -### Hooks carry everything interactive - -The adapter runs a single loopback HTTP server whose URL carries a per-process secret and a per-session route, and the generated hook client posts each hook payload to it and hands the JSON answer back to Claude. - -`Stop`, `StopFailure` and `SessionEnd` end the turn with `end_turn`, `refusal` and `cancelled`, each first flushing the transcript until the file stops growing so the updates land before the turn does. - -`PermissionRequest` becomes an ACP permission request offering Allow once, Claude's own permission suggestions as always-allow options, and Deny, and the answer becomes the hook's decision — unless [Auto Accept](#auto-accept) is on, which answers Allow once without asking. -`PreToolUse` intercepts two tools before they run: `AskUserQuestion` renders as one permission card for the whole call, and `ExitPlanMode` renders as a plan approval. - -### Claude's own dialogs become cards - -Not everything Claude asks comes through a hook. -The nudges it raises between turns — a plugin it suggests for the project, an LSP it noticed, an effort level it would rather use, a setup question about what it may read — are drawn in the terminal, in the input box's place, and Claude then holds the keyboard until one of them is answered. -Nothing outside the adapter's process can reach that PTY, so until this a session that met one was a session every later prompt failed against, with the message lost and the question answered by whatever keys the next prompt sent into it. - -So Claude's own state file is polled while the session's process is up, and a wait that is not one of the adapter's own becomes a permission card in Paseo: one option per row read off the screen, plus **Dismiss (Esc)**, and the dialog as drawn carried on the card so a dialog nothing here could parse is still readable and still dismissable. - -Reading one takes two things the screen says and nothing else does. -Claude draws a dialog under a rule that runs the whole width, and the first line under that rule is its title — without it the card is titled after whatever the conversation happened to end with, which is what `/model` produced. -And it tells a row from a row's own detail by colour: `/rewind` puts a checkpoint's line and the line summarising its changes in the same column, one in the default colour and one in the grey it writes everything secondary in, so the line naming the keys is read for which grey that is and a row written entirely in it is detail rather than a choice. -Both were measured against Claude Code v2.1.269 driven through those two commands in a PTY, and both fixtures are in the parser's tests. -Every option is a declining one, because Paseo's automatic permission modes accept an allow option without showing anybody anything, and these are the questions that must never be answered by a machine. -Answering moves Claude's own marker to the row and presses Enter, and then reads the state file back, because that is the only proof the question really went. -Which key to press comes from the screen rather than from a guess: the marker is somewhere in the list and the row is somewhere in the list, so the direction is whichever way closes the gap. -Pressing Down until the marker comes round — which is how the startup dialogs are answered — cannot answer these, because Claude's lists do not wrap: `/rewind` opens with the marker on its last row, where Down does nothing at all. -A row the marker will not reach is escaped rather than pressed at, and so is a card answered with an option this session has no row for. -Answering one of these routinely opens the next — `/rewind` asks which checkpoint and then asks what to restore — so the dialog that follows gets a card of its own, while the one just answered does not get a second card if it is still on screen. - -A list longer than the window Claude draws it in is offered as what the window was showing, and the card says so; `↑ 4 more above` is a fact about the window rather than a row or part of the question, so it is kept out of both. -Which rows those are changes while the card is up — the list scrolls — so answering looks for the row wherever the window has moved to: the marker is walked one way until the row is drawn again and then the other, and only a row neither end turns up is escaped. - -What says a card is still answerable is not the words on the screen. -Claude rewrites its own dialog while it is up: a relative timestamp ticks, the line under the question follows whichever row the marker is on, and that row grows what it can do inline — `Summarize from here` becomes `Summarize from here: add context (optional)` once the marker reaches it. -A reading taken a minute later agrees with the carded one about almost none of that, and requiring it to was dropping answers to questions nobody had closed. -So a card belongs to a *waiting episode*: Claude holds the keyboard from the moment it opens a question until whatever that opens is finally answered, every poll that finds it no longer waiting starts the next episode, and an answer from an episode that has ended presses nothing. -The title still has to match, the card's own id is one ACP request answered once, and the chosen row is found by what it starts with rather than by what it said — which is how a row Claude has rewritten is still the row that was offered. -Two rows that begin the same way and no exact match is nothing found, and nothing found is an escape rather than a guess. -Rows Claude never drew are not offered at all, because enumerating them would mean walking its selection around a question nobody has answered yet, and this whole surface exists because keys landing in Claude's dialogs is what went wrong in the first place. - -The card is taken back down whenever the question stops being one this session is waiting on: Claude times two of its nudges out after thirty seconds, a prompt arriving closes whatever is open to get the keyboard back, and a turn ending lets go of every card the adapter was waiting on at once. -That last one is the easiest to leave behind and the worst to: Paseo goes on showing a card the adapter has stopped waiting for, and the daemon rebuilds its list of pending cards from the provider's own, so a card left open there comes back on screen the next time anything is answered. -ACP has no way to withdraw a permission request — the agent asks and waits, and only the client ever ends one — so the plugin answers it on the daemon's side, over the same vendor channel the tool-call mirror uses. -A question closed to deliver a prompt also puts a notice in the session's timeline naming what was closed, since the answer Claude was waiting for would otherwise simply disappear. - -What the adapter answers itself is never carded: the workspace trust screen, the bypass disclaimer, the external-imports question and the resume question are all on the startup path, and the watcher only starts once that path is behind it. -Neither is a wait a hook is already asking about — a permission, an `AskUserQuestion`, a plan — because the card for that is already on screen. -Every transition into a dialog is logged with the screen it was on, because the list of these grows with every Claude release and the log is how the next one gets read properly. - -### A model Claude swaps for itself is said three ways - -With `switchModelsOnFlag` on, a message the model's own safeguards flag is not refused: Claude retries it on a fallback model and writes one line into the transcript — `model_refusal_fallback`, with its own sentence, the two models, a direction and a scope. -`model_fallback` is the same machinery for a model that could not be used at all, and `model_consent_fallback` for a switch somebody agreed to. -Until this the line arrived in the conversation as a sentence from Claude and nothing else, which is a strange way to learn that the last half hour was answered by a different model. - -A switch that happens while the session is running produces a **notice** in the timeline (`Model switched: Fable 5 → Opus 4.8`, with Claude's sentence under it), a **model change** for the session's picker where the switch was for the session rather than for one message, and a **card with a single OK**, which exists because a new permission request is the only thing in Paseo that pushes to a phone. -Nothing waits for that card: Claude has already done the thing it is about, it is withdrawn as the next prompt goes in, and it withdraws itself after ten minutes so a session cannot be left unsuspendable by a card nobody clicked. -The picker is a reading, not a decision — the model the session was launched with is untouched, so changing the model, mode or effort, or any other restart, puts Claude back on it. - -None of it happens for a switch read back out of history. -A session being loaded replays its whole transcript, and a compaction has it re-read from the start, so a switch is only reported when the record was written after the session's translator was made and while Claude is actually running; anything older keeps the sentence in the conversation exactly as before. - -### State on disk - -The state directory holds one JSON file per session, mapping the Paseo session ID to Claude's own session ID, cwd, model, mode and effort, plus a lock file naming the process that has the session open. -A file written before the effort selector existed names none, which reads as the default effort. -Runtime directories live in the host's temporary directory, record their owner PID, and are swept on the next adapter startup if that process is gone. +For a systemd service, set `CLAUDE_BIN` to the output of `command -v claude` in that user's login shell. ## Limitations -Claude keeps its own MCP servers, plugins, skills, permissions, and `CLAUDE.md` hierarchy, but MCP servers injected by Paseo over ACP are rejected, because a running interactive Claude process cannot adopt them. -Claude's own MCP servers are unaffected; it loads them from its usual configuration at startup. - -### Slash commands arrive after the session does - -Paseo learns this adapter's slash commands, and with them Claude's skills, from an ACP `available_commands_update` notification. -The adapter sends it as soon as `session/new` returns, because Paseo drops any session update carrying a session ID it has not received yet. - -A draft agent's composer lists commands for an agent that does not exist yet: Paseo spawns a throwaway session, reads the commands back, and closes it, and it waits for that first batch only when the provider asks it to. -The plugin asks, through `acpOptions.waitForInitialCommands`, for up to 10 seconds; without the wait the composer stays empty until the agent has taken its first turn, after which the live session has the commands cached. - -Related, a draft must carry a model ID that is not literally `default`: Paseo reads `default` as "no model selected" and returns an empty list before the adapter ever launches, which is why the pass-through entry is named `inherit` instead. - -### A question card needs the plugin to be worth answering - -Claude's `AskUserQuestion` raises one permission card for the whole tool call, carrying every question it asks. -Under the Claude TTY plugin that card is Paseo's own question form — radio buttons, checkboxes for a multi-select question, a box for an answer that is on none of the lists — and every answer comes back in one response, because the plugin republishes the request and hands the answers back down here. -See the plugin's own notes for how, and for what it costs: an option's `preview` has no surface in that form and is folded into its description. - -The adapter alone cannot do any of that. -ACP has one shape for asking permission — a list of options, one of them chosen — so an adapter run outside the plugin, or without the `--answers-dir` it is passed at spawn, offers one option per answer while a single question is on the card and *Answer in chat* otherwise. -Whatever the card is left with unanswered is denied back to Claude with instructions to restate those questions in prose and wait for a normal message, which is also what a cancelled or dismissed card does. - -### Prompts sent mid-turn interrupt the turn - -Paseo cannot steer a running turn on an external provider: an ACP session offers `session/prompt` and `session/cancel` and nothing that injects text into a turn that is already in flight. -Sending a prompt while Claude is working therefore takes Paseo's replace path, which cancels the active turn, waits for the pending `session/prompt` to answer, and starts the new turn once it does. - -The adapter cancels the way a person would, by sending Escape into the PTY, and ends the turn as soon as Claude's `Stop` hook confirms the interrupt or, when no hook follows, after a short fallback. -Both settle inside the 2 second budget Paseo allows before it gives up and fails the replacement with `A foreground turn is already active`. - -So a mid-turn prompt discards whatever Claude was doing rather than queueing behind it the way typing into the Claude CLI does, and Claude sees the interruption in its own transcript. -If Escape has not taken effect by the time the fallback fires, Claude also queues the replacement itself and answers it after the interrupted work unwinds. -Real steering would need Paseo's generic ACP provider to offer external providers an entrypoint for it. - -### Auto mode can hang on the denial limit - -Auto mode decides permissions with a classifier, and after 3 consecutive or 20 total classifier denials Claude Code stops deciding and asks the person at the keyboard instead. -That escalation deliberately bypasses every remote channel — the `PermissionRequest` hook, Claude's bridge, and its channel callbacks — so the adapter is never consulted and Paseo shows no permission card. -Claude then waits at a dialog inside its PTY and no `Stop` hook follows, so the turn never finishes: the first escalation in a session denies itself after 2 minutes, and every later one waits indefinitely. - -Cancel the turn from Paseo to recover, which sends Escape into the PTY and dismisses the dialog; sending a new prompt does the same, because Paseo cancels before it replaces the turn. -Default mode avoids the limit entirely, because there every decision reaches the adapter through the `PermissionRequest` hook. -Claude does report the wait over its `Notification` hook, which the adapter does not register yet, so a future release can surface the dialog or end the turn with an explicit error instead of hanging. - -### The token usage meter stays empty, so the reading goes in the conversation - -Paseo's token usage meter renders only once it knows both a context window size and the tokens currently in it, and it fills those in for its own providers alone. -ACP carries the pair as a `usage_update` session notification, which Paseo's generic ACP provider validates and then discards, so nothing an external provider reports can reach the meter. - -The adapter reports a `Context: 137.4k tokens (69%)` line at the end of a turn instead, and stays silent until Claude has reported one. - -Both numbers are Claude's own, because neither can be reconstructed. -The transcript records per-message `usage` counts but nothing about the window holding them: no record type in it carries a context window at all, and its `message.model` drops the `[1m]` marker that separates a 200k session from a 1M one, so a model ID read from there implies nothing. -Nor is the size a table the adapter could keep, because Claude resolves it per session from `CLAUDE_CODE_MAX_CONTEXT_TOKENS`, a `native_1m` flag in its model catalog, the deployment behind the model, a 1M beta header and a clamp back to 200k. -Hooks do not carry it either; their shared payload stops at `session_id`, `transcript_path`, `cwd`, `prompt_id`, `permission_mode`, `agent_id`, `agent_type` and `effort`. - -The status line is where Claude does report it, so the generated `settings.json` registers one. -Claude runs a status line command through a shell and renders its stdout, so the command is `cat > /context.json`: one `cat`, and no output to land on the terminal screen. -It runs when the reading changes rather than on a timer, which is at least once per assistant response: usage moves on every tool result too, so a tool-heavy turn costs several `cat`s rather than one. - -Registering one is not free of the screen, though: Claude drops most of its footer hints once a status line exists, `? for shortcuts` among them, and that was the first signal `isReadyScreen` looked for. -Readiness now rests on the mode indicator, which Claude shows in every mode and writes as `manual mode on` in the default one, because the token badge is absent until a session has context and the bare prompt marker does not match while the input box still holds its placeholder. - -Claude renders that line *after* the `Stop` hook, measured at about 320ms, so reading the file when the turn ends yields the turn before it. -The adapter notes the file's mtime as the hook arrives and then waits, for up to a second, for a rewrite that moves it, staying silent when none does. -The mtime is taken at the hook rather than at the read because draining the transcript and delivering the turn's last message both run in between and can outlast Claude's refresh, and it is compared against itself rather than against `Date.now()` because Linux stamps files from a coarse clock that can date a write milliseconds before the wall clock that observed it. -The timestamp rather than the contents is what marks a reading as this turn's, because two turns that land on the same numbers write identical files. -Claude truncates the file before each rewrite, so a torn read is waited out rather than reported, and a cancelled turn skips the wait entirely because Paseo allows only 2s for the prompt it is replacing to answer. - -That wait is what the turn's own completion is held on, so a session that never reports would otherwise pay it forever: after three turns without a reading it stops waiting and only looks, which costs a `stat`, and says so once in the log. -Looking alone cannot find a reading written after the Stop hook, which is the whole reason the wait exists, so one turn in four keeps waiting properly and a session that starts reporting again is picked up rather than written off. -A wait a stop ended counts towards none of this, because a cancelled wait says nothing about whether Claude reports at all. - -The line is a turn-boundary sample rather than a live meter, and it is not part of the session's history: the adapter generates it, so it never enters Claude's transcript and a reloaded session replays without any of the readings it showed while you worked. -The session title is not an alternative either: Paseo keeps an ACP `session_info_update` title in `runtimeInfo().extra`, which its own clients never read and which does not emit state when it changes. -Filling the real meter needs Paseo's generic ACP provider to honour `usage_update` the way its direct providers do, mapping it onto the `contextWindowMaxTokens` and `contextWindowUsedTokens` its `AgentUsage` already carries. +- **No ACP-injected MCP servers.** A session that Paseo starts with MCP servers fails; Claude's own MCP servers work as usual. +- **A message sent mid-turn interrupts the turn.** Paseo cancels the running turn and sends yours as the next one, so Claude does not queue it the way its terminal does. +- **Auto mode can hang.** After too many classifier denials Claude asks at its own keyboard, which no hook reaches, so the turn never ends. Cancel the turn, or use Default mode. +- **No token usage meter.** Paseo ignores usage from ACP providers, so a `Context: 137.4k tokens (69%)` line ends each turn instead. It is not part of the saved history. +- **Question forms need the plugin.** Without it, a card with several questions can only be answered in chat. +- **Slash commands load late in a draft.** A draft agent's composer can wait up to 10 seconds for them. ## Troubleshooting -| Symptom | Action | +The adapter logs to `${XDG_STATE_HOME:-~/.local/state}/claude-tty-acp/logs/claude-tty-acp.log`, or under `CLAUDE_TTY_ACP_STATE_DIR`. +Paseo drops the adapter's stderr, so that file is the only copy. +The plugin's [Diagnostics](../../plugins/claude-tty/README.md#troubleshooting) checks the host for you. + +| Symptom | What to do | | --- | --- | -| `Could not start claude` | Set `CLAUDE_BIN` to an absolute executable path visible to the daemon. | -| Workspace trust permission appears | Approve only when the displayed folder is a project you created or trust; Claude remembers the choice. | -| Bypass Permissions disclaimer card appears | Claude has not been told this host accepts the mode. Accept it to start the session, or pick another mode; the answer is remembered for the host. | -| External CLAUDE.md imports card appears | This project's CLAUDE.md imports files from outside the working directory; the card names them. Approve only files you trust — declining starts the session without them and says so in the timeline. | -| SessionStart handshake timeout | If no workspace trust, Bypass Permissions or external-imports card appeared, check Claude organization hook policy, inherited settings, loopback access, and the terminal snapshot in the adapter log. | -| Claude opens a login screen | Authenticate as the Paseo daemon user and verify `HOME` or `CLAUDE_CONFIG_DIR`. | -| Persisted session not found | Select the host that created it and verify `CLAUDE_TTY_ACP_STATE_DIR`. | -| Session belongs to another cwd | Load it with its original absolute project path. | -| Session already active | Close the other native agent connection; remove a lock only after verifying its recorded PID is dead. | -| `A foreground turn is already active` | The interrupted turn had not settled when Paseo started its replacement; send the prompt again. | -| Turn never finishes in Auto mode | Claude is waiting at a keyboard-only dialog after the classifier denial limit; cancel the turn and prefer Default mode. | -| PTY exits unexpectedly | Inspect the adapter log and run Claude interactively in the same cwd and environment. | -| Corrupt transcript or state | Preserve the file for diagnosis, then move only the named session JSON or transcript aside before retrying. | -| Commands or plugins missing | Verify the daemon sees the expected `CLAUDE_CONFIG_DIR`, project `.claude` files, and enabled plugin settings. | +| `Could not start claude` | Set `CLAUDE_BIN` to an absolute path the daemon can run. | +| Claude shows a login screen | Log in as the daemon's user, and check that `HOME` or `CLAUDE_CONFIG_DIR` points where you logged in. | +| `did not complete the SessionStart hook handshake` | Check for an organization policy that blocks hooks, settings that disable them, and loopback access. The error includes the terminal screen. | +| `Persisted ACP session … was not found on this host` | Open it on the host that created it, with the same `CLAUDE_TTY_ACP_STATE_DIR`. | +| `ACP session … belongs to ` | Open the session from its original project directory. | +| `ACP session … is already active in process ` | Close the other agent holding it, or stop that adapter from the plugin's session list. | +| `A foreground turn is already active` | The interrupted turn had not finished when Paseo sent yours. Send it again. | +| A turn in Auto mode never ends | Claude hit the classifier denial limit; see [Limitations](#limitations). | +| Commands, skills or plugins are missing | Check that the daemon sees the right `CLAUDE_CONFIG_DIR` and the project's `.claude` directory. | + +Removing the plugin leaves the state directory behind. +Delete it only if you no longer need to resume old sessions. ## Development -The executable writes ACP only to stdout and sends structured diagnostics to stderr, which the daemon reads and drops, so the place to look when something misbehaves is the copy it keeps: `${XDG_STATE_HOME:-~/.local/state}/claude-tty-acp/logs/claude-tty-acp.log` (or under `CLAUDE_TTY_ACP_STATE_DIR`), one line of JSON per record, with the pid of the adapter process that wrote it. - -Check a host without starting ACP: - -```sh -pnpm --filter @paseo-plugins/claude-tty-acp diagnose -``` - -`--json` prints the same checks as one machine-readable line instead of the report, for a caller that wants to act on them rather than read them: - -```sh -apps/claude-tty-acp/bin/claude-tty-acp --diagnose --json -``` - -The line is `{ "version", "ok", "checks": [{ "id", "label", "ok", "detail" }] }`, the check IDs are `node`, `claude`, `config` and `state`, and the exit code is 1 when any check fails, exactly as without `--json`. - -Run the automated checks, none of which consume Claude usage: - ```sh pnpm --filter @paseo-plugins/claude-tty-acp typecheck pnpm --filter @paseo-plugins/claude-tty-acp test pnpm --filter @paseo-plugins/claude-tty-acp build +pnpm --filter @paseo-plugins/claude-tty-acp diagnose ``` -Run a real end-to-end smoke test through ACP and an interactive Claude PTY: +`bin/claude-tty-acp --help` lists the flags; `--diagnose --json` prints the host checks as one JSON line. +The tests use no Claude usage. + +`smoke:live` runs one real prompt through ACP and a Claude terminal, and does use Claude usage: ```sh pnpm --filter @paseo-plugins/claude-tty-acp smoke:live -- /absolute/project/path ``` -The live smoke test asks Claude for a fixed tool-free response and consumes normal Claude usage. - -## Upgrading and uninstalling - -The adapter is updated and removed with the plugin, on each host independently; see the [plugin's installation notes](../../plugins/claude-tty/README.md#installation). -A running adapter process keeps the code it started with, so a rebuilt adapter reaches the sessions started after it. - -Persistent session files are versioned and survive upgrades in the configured state directory. -Removing the plugin leaves that directory in place; remove it only if the session resume mappings are no longer needed. +To run a changed adapter in Paseo, see the plugin's [Development](../../plugins/claude-tty/README.md#development) section. +Design decisions are in [docs/adr](docs/adr), and the traps to know before changing the code are in [CLAUDE.md](CLAUDE.md). diff --git a/apps/claude-tty-acp/docs/adr/0001-drive-the-interactive-cli-in-a-pty.md b/apps/claude-tty-acp/docs/adr/0001-drive-the-interactive-cli-in-a-pty.md new file mode 100644 index 0000000..e6af5ce --- /dev/null +++ b/apps/claude-tty-acp/docs/adr/0001-drive-the-interactive-cli-in-a-pty.md @@ -0,0 +1,6 @@ +# Drive the interactive Claude CLI in a PTY + +Each ACP session owns one real, interactive `claude` process in a PTY, and the adapter types into it the way a person would: prompts as bracketed pastes, cancellation as Escape, a restart as Ctrl-D followed by `claude --resume`. +We chose this over the Claude Agent SDK and `claude -p` because Agent SDK integrations appear to consume usage limits faster than the CLI, and because the interactive CLI keeps the user's own login, settings, plugins, skills and sessions untouched. +The price is that every Paseo feature has to be rebuilt from outside the process, and that the adapter depends on how Claude's terminal UI behaves, which changes between Claude Code releases. +Moving to the SDK later would mean rewriting nearly all of the adapter. diff --git a/apps/claude-tty-acp/docs/adr/0002-read-output-from-the-transcript.md b/apps/claude-tty-acp/docs/adr/0002-read-output-from-the-transcript.md new file mode 100644 index 0000000..77e24cc --- /dev/null +++ b/apps/claude-tty-acp/docs/adr/0002-read-output-from-the-transcript.md @@ -0,0 +1,6 @@ +# Read output from the transcript, not the terminal + +Everything the adapter sends to Paseo as conversation (messages, thinking, tool calls, plans, subagent steps) is translated from the JSONL transcripts Claude writes under its projects directory, never scraped from the terminal screen. +The transcript is structured, complete and stable across UI changes, and it is also what lets a reloaded session replay its history without starting Claude. +The headless terminal screen is kept for what the transcript cannot say: whether Claude is ready for input, whether a dialog is blocking it, and whether a prompt reached its input box. +The cost is latency and indirection: the adapter polls files, joins subagent transcripts to their launchers by ID, and has to flush the transcript before it ends a turn. diff --git a/apps/claude-tty-acp/docs/adr/0003-hooks-carry-everything-interactive.md b/apps/claude-tty-acp/docs/adr/0003-hooks-carry-everything-interactive.md new file mode 100644 index 0000000..0af5b2e --- /dev/null +++ b/apps/claude-tty-acp/docs/adr/0003-hooks-carry-everything-interactive.md @@ -0,0 +1,6 @@ +# Hooks carry everything interactive, through a per-launch settings file + +Permission requests, questions, plan approval and the end of every turn reach the adapter as Claude Code hooks, posted to a loopback HTTP server with a per-process secret and a per-session route. +The hooks are registered in a generated `settings.json` passed with `--settings` on each launch, so nothing is written into the user's Claude configuration and no global hooks are needed. +We chose hooks over detecting Claude's dialogs on the screen because a hook carries structured input and takes a structured answer, so a permission is never decided by keystrokes into a dialog the adapter misread. +The trade-off is that anything Claude asks outside a hook, such as the Auto mode denial-limit prompt or its own between-turn nudges, has to be handled separately or not at all. diff --git a/apps/claude-tty-acp/docs/adr/0004-hold-the-turn-open-for-background-work.md b/apps/claude-tty-acp/docs/adr/0004-hold-the-turn-open-for-background-work.md new file mode 100644 index 0000000..1b5dbb2 --- /dev/null +++ b/apps/claude-tty-acp/docs/adr/0004-hold-the-turn-open-for-background-work.md @@ -0,0 +1,6 @@ +# Hold the turn open while background work runs + +A turn stays open after Claude's `Stop` hook for as long as a subagent or background command it launched has not reported, and ends at the first `Stop` with nothing left running. +Paseo reads a session as busy only from an open ACP turn, and Claude goes idle as soon as it launches background work, so without this a session doing ten minutes of work reads as ready and Claude's reply to the report lands outside any turn. +The cost is that a turn can outlive its prompt by a long time, so it is bounded: agents that write nothing for fifteen minutes, and commands that have not reported after thirty, stop being waited on. +Cancelling still ends a held turn at once, because Paseo cancels before every mid-turn message. From 82f70aed54b1fb76f8c9881fffae48f15bd88396 Mon Sep 17 00:00:00 2001 From: Sleeyax Date: Sun, 11 Oct 2026 13:51:08 +0200 Subject: [PATCH 06/31] docs(guided-review): rewrite the README around what a reviewer does Replace the wall of paragraphs under Installation with a Usage section per task: starting a review, picking from review requests, reading the guide, a change that moves on, commenting, and finishing. Cut troubleshooting to the problems whose message does not give the fix, and name the settings as the settings screen does. Drop what the code no longer does: GitHub comments on a concept are no longer folded into the pending review's body, and the review body is kept by the plugin on both forges. Record that drafts live on the forge as an ADR. Co-Authored-By: Claude Opus 5.5 --- plugins/guided-review/README.md | 153 +++++++++--------- .../docs/adr/0001-drafts-live-on-the-forge.md | 6 + 2 files changed, 84 insertions(+), 75 deletions(-) create mode 100644 plugins/guided-review/docs/adr/0001-drafts-live-on-the-forge.md diff --git a/plugins/guided-review/README.md b/plugins/guided-review/README.md index 99a6986..7e1df58 100644 --- a/plugins/guided-review/README.md +++ b/plugins/guided-review/README.md @@ -1,129 +1,132 @@ # Guided Review -A trunk-first guide to a pull request, with a draft review built alongside it. Paste a GitHub pull request or GitLab merge request URL, and the plugin prepares a Paseo workspace checked out at it, with a **Guided Review** panel in it. +Read a GitHub pull request or GitLab merge request as a guide that starts from the foundations, and build your draft review alongside it. +Each review gets its own Paseo workspace, checked out at the change, with a **Guided Review** panel in it. -## Screenshots +## Installation -_None yet._ +Guided Review needs Paseo 0.11 or newer, and on the daemon's host: -## Installation +- `pnpm` on the daemon's `PATH`, since installing and updating build the plugin. +- [`gh`](https://cli.github.com/) logged in with `gh auth login`, to review GitHub pull requests. +- [`glab`](https://gitlab.com/gitlab-org/cli) logged in with `glab auth login`, to review GitLab merge requests, on gitlab.com or a self-hosted GitLab. ```sh paseo plugin add github:sleeyax/paseo-plugins:plugins/guided-review ``` -Paseo tracks the default branch from there, so `paseo plugin update guided-review` picks up new releases without a clone, and `paseo plugin update guided-review --check` says what is installed against what is available. - -Install and update both run `pnpm install --frozen-lockfile` first, from the manifest's `build` commands, for Paseo's syntax highlighter, which the plugin colours diffs with; so the plugin needs `pnpm` on the daemon's `PATH`. A failing build is reported and the installed version is left running. Installing from a clone works too; run `pnpm install --frozen-lockfile` in it first, since a directory installation runs no build. +Update with `paseo plugin update guided-review`. -The plugin reads pull requests through [`gh`](https://cli.github.com/) and merge requests through [`glab`](https://gitlab.com/gitlab-org/cli), with the logins they already have, so run `gh auth login` or `glab auth login` on the daemon's host first. GitLab works on gitlab.com and on any self-hosted host `glab` is logged in to; the plugin checks with `glab auth status --hostname ` before it calls a host, and turns down a merge request URL on any other. +## Usage -The plugin runs `glab` without `PASEO_AGENT_ID` and `GITLAB_BOT_IDENTITY` in its environment, so a `glab` wrapper that switches to a bot account for agents still reads and reviews as you. +### Start a review -To start a review, select **Guided Review** in the sidebar, then **New review**, paste the URL and select **Start review** (or press Ctrl+Enter, Cmd+Enter on a Mac). To start several at once, paste one URL per line; the button then says how many it starts. The plugin reads each pull request or merge request, creates a workspace checked out at its head, and has its guide generated there, without opening anything, so you can line up several and read them once they are ready. A URL the plugin turns down stays in the box with the reason under it. A review started this way shows how it is going above the list below while the list does not show it, whether or not you are its reviewer, and offers **Open** once its workspace is ready, until you close it with **×** or leave the page. +1. Select **Guided Review** in the sidebar. +2. Select **New review** and paste a pull request or merge request URL, or several, one per line. +3. Select **Start review**, or press Ctrl+Enter (Cmd+Enter on a Mac). -**Assigned to me for review** lists the open pull requests and merge requests you are a reviewer of, so you can pick one instead of pasting its URL. On GitHub that is every pull request you are asked to review, directly or through a team, and every open one you were asked to review, by name or through any team, reviewed, and were not asked to review again, so a review you left unasked is not listed; on GitLab it is every open merge request with you among its reviewers, on every host `glab auth status --all` reports. Each row shows where you stand (requested, commented, changes requested, approved, or an approval GitLab reset), marked **updated since** when commits were pushed after your review, and what you have here: the guide's state, whether its head moved, and your pending drafts, which on GitLab are counted only for merge requests you reviewed in the plugin. Each row's number links to it on the forge; select **Review** to start its review the same way as a pasted URL, which you can do on several rows without waiting for any, each showing how its start and guide go. **Open** opens the panel of a review whose workspace is open, and **Continue**, for one whose workspace was closed, starts it again and opens its panel once it is ready. A review workspace you open some other way, such as from Paseo's sidebar, may show only the guide agent's tab, since nothing opened the panel in it yet; select the **Guided Review** button in the workspace's header to open it. The list can show only GitHub or GitLab, only the states you pick under **States**, whose button names them (all but approved by default; pick only requested to see what you have not reviewed yet), hide drafts, keep only what needs your attention, and search by title, project, author or number; select the **Updated**, **Created** or **Size** heading to sort by it, again to reverse it, and **Columns** to choose what the table shows, size and CI among them. Above the rows it says how many it shows, out of how many when a filter or the search hides some, and how many checked-off reviews it leaves out. The **Guided Review** row in the sidebar shows how many reviews need your attention, whatever filters you picked: those you were never asked about yet, asked about again, whose approval GitLab reset, or pushed to since your review, leaving out what you approved or checked off. It lists them once when Paseo opens, then counts what the list last listed, so it catches up when you open the list or select **Refresh**. +The plugin prepares a workspace for each review and has a guide agent write the guide there. +Nothing opens on its own, so you can start several and read them once they are ready. +Select **Open** when a review is ready. -To set aside a review that waits on someone else while the forge still lists it as yours, such as one you are leaving open for a colleague to try, tick its box in the first column; the forge is not told. It is hidden from then on, unless you pick **With checked off**, or **Checked off only** to see nothing else, and comes back by itself when commits are pushed to it, when you are asked to review it again, or when GitLab resets your approval, but not when you or someone else only comments. Untick the box to bring it back yourself. A row you tick or untick stays in place, dimmed, until you refresh, filter or search the list. The column is on by default; if you chose your columns before it existed, turn on **Checked off** under **Columns**. Check-offs are kept on the daemon, and dropped once the review leaves the list. +To guide the branch you already have checked out, choose **Guided Review: guide this branch's PR/MR** in the Command Center, or select **Guide this branch's PR/MR** in an empty **Guided Review** panel. +If your branch is clean and behind the PR/MR, the plugin fast-forwards it and writes the guide in your workspace. +Otherwise it leaves your branch alone and uses a new workspace. -The plugin keeps those choices on the daemon, so every client of it shows the list the same way. It lists GitHub's pull requests 50 at a time per search and GitLab's 100 per host, and says when a host has more; **Refresh** lists them again. Listing GitLab needs GitLab 19.2 or newer, and GitHub Enterprise hosts are not listed, as the plugin reviews github.com only. +### Pick from your review requests -To guide the branch you are already on, open the Command Center in its workspace and choose **Guided Review: guide this branch's PR/MR**, or select **Guide this branch's PR/MR** in an empty **Guided Review** panel. The plugin reads the workspace's branch and `origin` with `git`, finds the open pull request or merge request whose source branch it is, and offers a choice when there are several. When the working tree is clean and the branch has no commits the pull request or merge request lacks, the plugin fetches its head and fast-forwards the branch to it (`git merge --ff-only`), and the guide lives in that workspace. A branch with uncommitted changes, untracked files included, or one that has diverged is never touched: the panel says why in one line, and the guide goes to a new workspace checked out at the head, which the panel links. +**Assigned to me for review** lists the open pull requests and merge requests you are a reviewer of, on github.com and on every GitLab host `glab` is logged in to. -In that workspace a guide agent, labelled **Guide: **, reads the pull request or merge request and writes the guide, which the panel shows once it is done: the idea behind the change, what you need to know, the decisions the author made, with the alternative they rejected only where their own words say so, quoted from the description, a commit message, a linked issue or the diff, where to spend your attention, and the change split into concepts. Each concept shows the code it covers: the hunks of each file it names, or the lines within a hunk when a hunk holds more than one concept, with old and new line numbers, added and removed lines coloured, and the code highlighted the way Paseo highlights its own diffs, in the same languages. The guide explains; it reports no bugs, risks or style problems and suggests no fixes. While it is being written the panel links the agent, whose chat you can follow. The agent is read-only: it runs in its provider's plan or read-only mode, or another mode you pick that asks before a tool runs, and the plugin denies every request it makes to edit, write or run a command. While it writes the guide or a suggested wording, which nobody is watching, the plugin also allows its requests to read and search files and denies anything else it asks, so the run never stalls on a prompt; in a conversation you started with **Ask about this** or **Ask agent**, those requests are left for you to answer in its chat. Guides are kept per head commit, so reopening the panel shows the stored guide, and archiving the workspace archives the agent. +- Select **Review** to start a review, **Open** to open one, or **Continue** to reopen one whose workspace was closed. +- Filter by forge, by state under **States**, with **Needs my attention** or **Hide drafts**, or search. +- Select a column heading to sort, and **Columns** to choose what the table shows. +- Tick a row's box to check it off while it waits on someone else. + It comes back when someone pushes to it, asks you to review again, or GitLab resets your approval. +- Select **Refresh** to list again. -**Description**, above **Overview** in the panel's list, is the pull request's or merge request's description in full, as its author wrote it, so nothing they wrote for reviewers is missed: headings, lists and task lists, tables, code and folded sections are drawn as on the forge, and links open in the browser. It is there as soon as the review is, while the guide is still being written, and a review in which nothing is marked understood yet opens on it. When the author edits the description after the guide was written, the page shows the new text, marked **Edited since this guide was written**, and its row in the list says **edited**. Images pasted into the description are fetched with your `gh` or `glab` login, so those of a private repository show too, as long as they are PNG, JPEG, GIF or WebP and at most 5 MB; GitLab hands them out only from 17.4. An image hosted anywhere else, or one that cannot be shown, is a link that opens it in the browser, with the reason beside it. Other HTML in the description, apart from folded sections, line breaks and images, is shown as written. In the Paseo phone app the description is a card right before the guide. +The count beside **Guided Review** in the sidebar is how many reviews need your attention. -The concepts are shown in layers, foundations first: each concept names the concepts it builds on and why, and a concept in a later layer builds only on earlier ones, in three layers at most. A concept that builds on others but that nothing builds on is labelled **Leaf** under its title, since it tends to follow a pattern already explained. Tests sit in a **Tests** group after the layers, docs in a **Documentation** group, and pure wiring in a **Supporting** group after that, and so do lockfiles and generated files, which the plugin recognises by their paths and never shows the agent, so a large change stays within its reach. Every change is covered by a concept, in Supporting, or in an **Unsorted** group that lists what the agent placed nowhere, so nothing in the change is skipped silently: when concepts cover only some hunks of a file, the rest of that file is listed in Supporting or Unsorted too, marked as part of the file's changes. Each Supporting and Unsorted file shows its diff under it, the whole of it or the part no concept covers, so tests and wiring can be read where they are listed; lockfiles and generated files start with theirs hidden behind **Show the diff**, and stay in Supporting even when the agent names one in a concept. +If a review workspace shows no panel, select **Open Guided Review** in the workspace's header. -In a wide window the panel lists the guide's entries in a column on its left and keeps the pull request or merge request, your progress and your drafts in a column on its right; in a narrower one both share the left column. Drag a column's edge beside the guide to resize it, and double-click the edge to put it back to its default width. Each workspace's panel keeps its own widths, on the device you set them on, for the last 100 workspaces you resized. When the window gets too narrow to leave the guide its room, the columns give way, and they come back as you left them once it widens again. +### Read the guide -To dig into a concept, select **Ask about this** on it, type your question in the box that opens, and select **Ask agent**. The plugin sends the guide agent your question with what the guide says about the concept, closes the box and opens the agent's chat, where you carry on the conversation. Each file in Supporting and Unsorted has its own **Ask about this**, whose question goes with the file, how it changed and where the guide put it. The agent answers only when it is idle, since a new prompt would cut off the answer it is writing; while it is busy the box says so and keeps your question, and you ask again once it has finished. A Paseo version that cannot open an agent's chat from a plugin does not offer it. +A new review opens on the PR/MR's **Description**. +The guide follows with an **Overview**: the idea behind the change, the author's decisions, and where to spend your attention. +Then come the concepts of the change in layers, foundations first, each with the code it covers. +Tests, documentation, supporting changes, and anything the guide did not place are listed after the layers, so every changed line shows up somewhere. -To keep your place in a large review, select **Mark understood** on each concept, and on each file in Tests, Documentation, Supporting and Unsorted, once you have read it; selecting it again takes the mark back. **Mark all understood** beside a layer's or group's heading marks everything in it at once, to skip all the tests in one go for instance, and selecting it again once all are marked takes every mark back. Above the concepts the panel shows how much you have understood overall, in each layer from the foundations up, and in Tests, Documentation, Supporting and Unsorted, and names the next layer to read, so you can see whether the foundations are covered before moving on. Your marks are kept on disk per head commit and survive Paseo restarts. A guide generated again, after **Try again** or in a new workspace, starts with none, since its concepts may be split differently. +- Select **Mark understood** on a concept or file once you have read it, or **Understood → next** at the bottom to move on. +- Select **Mark all understood** on a group to skip it in one go. +- Select **Ask about this** to ask the guide agent a question; its chat opens with your question. -When new commits are pushed after the guide was written, the panel shows **PR updated since this guide** (**MR updated** on GitLab), which it checks for when it opens and every minute while it is open. Nothing is regenerated on its own, so the guide never changes while you read it, and starting the review again keeps the guide and its workspace where they are. Select **Regenerate** to move on: the plugin reads the pull request or merge request at its new head, fast-forwards the guide's workspace to it the same way as your own branch, and has a new guide agent write a guide for that head, which the panel then shows. The concepts you marked understood stay marked where a concept of the new guide covers exactly the same code, whatever it is called and wherever the push moved its lines, and so do Supporting and Unsorted files whose listed diff did not change; everything else starts unmarked. A workspace that cannot be fast-forwarded, because it has uncommitted changes or the branch was force-pushed, is left untouched, and the new guide goes to a new workspace checked out at the head, which the panel opens. +The panel picks up where you left off, also after a restart. -When the pull request or merge request carries commits of another one that its target branch does not have yet, such as a branch cut from another's branch, or one that merged another in after it landed elsewhere, the diff shows their changes as its own, and so does the guide. A warning line pinned above the guide says so on every page, and its **Fix** opens **Issues**, listed first in the navigator, which shows **Commits from other PRs** (**MRs** on GitLab): how many of the commits come from which pull requests or merge requests, merged where or still open against what, and where its own work starts when all the other work comes before it. A commit counts as another's when the forge also links it to an open or merged pull request or merge request from a different branch; a closed one was never reviewed, and one from the same branch is an earlier attempt at this one. **Ask the author to fix it** opens a comment, drafted from what the banner says, that asks the author to retarget or to bring the other work into the target; edit it, then select **Post**. On GitLab it is a thread the author resolves, which can hold merging back until they do; on GitHub it is a comment on the pull request. On your own pull request or merge request the banner says what to do instead. The commits are checked when the review starts and on every **Regenerate**, so the banner goes once the other work has landed in the target. +### When the PR/MR changes -When all the other work comes before the pull request's or merge request's own, **Issues** asks, before any guide is written, whether to guide the **Whole diff** or its **Own work only**, and the review list shows it as **Choose what to guide** until you do. A guide of the own work explains only the files its own commits change, from the commit its own work starts at; the files only the others change are listed in **From other PRs** (**MRs**) after Unsorted, unexplained, to mark like any other, and a file both change is explained whole. Your choice holds after **Regenerate**, and the banner switches the guide to the other at any time, keeping what you marked understood where the code is the same. Interleaved commits, or more than the forge lists, leave only the whole diff. +When someone pushes new commits, the panel shows **PR updated since this guide** (**MR updated** on GitLab). +The guide does not change on its own. +Select **Regenerate** to guide the new head; concepts you marked understood stay marked where their code did not change. -To comment on the code, tap a line in any diff in the panel, drag across lines to comment on a range (on a touch screen, rest your finger on the first line before dragging, since a plain swipe scrolls), or select **Comment on file** in a file's header. A range stays within one hunk, as the forges require. Saving puts the comment on the forge at once as a draft: on GitHub, a thread on your pending review, which is the one you already started on github.com if there is one; on GitLab, a draft note. Nothing is published until you submit the review. Your drafts show under the lines they are on and in **Your drafts** above the guide, including the ones you started on the web, and you edit and delete them there; select **Refresh** to pick up drafts added on the web since the panel opened. **Regenerate** leaves your drafts on the forge as they are, and new comments go on the lines of the regenerated guide. +When the PR/MR carries commits from another PR/MR, a warning says so above the guide. +Select **Fix** to see which, ask the author to retarget, or choose whether to guide the **Whole diff** or its **Own work only**. -To make a point about a concept that spans several files, select **Comment on this concept** on its card. On GitLab the comment is a draft note on the merge request as a whole, which becomes a thread of its own when you submit; on GitHub it is a paragraph of your pending review's body, after your own text. The plugin remembers which concept each draft came from, a comment on code drawn in a concept included, and **Your drafts** groups them under their concept, with the rest (drafts started on the web, comments on Supporting and Unsorted files) in a list of their own; that link stays on your machine and is never posted. After **Regenerate** a draft stays under the concept that covers the same code, and joins the rest when that code changed. Nothing marks a GitHub paragraph as a concept's comment, so the plugin recognises it by its exact text: one you edit on github.com becomes part of your own body text instead. +### Comment -To comment on something the guide itself says, highlight the text in the overview or in a concept's card with the mouse: as you let go, a comment box opens right under the paragraph or bullet the highlight ends in, without taking the highlight away, so you can still copy it. Highlighting other text in the same paragraph moves the box to it and keeps what you typed; while another comment box is open, or on a touch screen, select **Comment on selection** at the foot of the card instead. The comment goes on the pull request or merge request as a whole, like a comment on a concept: under its concept, or in its own **Overview** group in **Your drafts** when the text is from the overview. The text you highlighted is shown above the comment and kept on your machine, never posted, since nobody reading the review has seen the guide; after **Regenerate** it is marked as from an earlier guide. **Suggest wording** writes the comment about what the text says, in the code's own terms, without quoting it. In the Paseo phone app, which cannot tell what is highlighted, hold a paragraph or a bullet of the guide instead to comment on the whole of it, in a box that opens under it. +- Tap a line in any diff to comment on it, or drag across lines for a range. + On a touch screen, hold the first line before dragging. +- Select **Comment on file** in a file's header. +- Select **Comment on this concept**, or highlight text in the guide, to comment on the change as a whole. + In the phone app, hold a paragraph instead. -To have a comment worded for you, type a rough thought or an instruction in the comment box, or nothing at all, and select **Suggest wording**. The guide agent writes the comment from the lines it is on, or for a comment on a concept from what the guide says about it, the concept they belong to, your conversation with it so far and what you typed, and the box gets the text for you to edit and save; nothing is saved or posted until you do. The wording is meant for people who have never seen the guide, so it names code by its files and functions rather than by the guide's concepts, and it adds no findings of its own; a comment on a concept opens by saying what it is about in plain words ("About the retry handling: …"), since no code sits beside it. Like **Ask about this**, it asks only an idle agent: while the agent is busy, or when it fails, the box says so and keeps what you typed. +Type a rough thought, or nothing, and select **Suggest wording** to have the guide agent write the comment for you. +Select **Ask agent** instead of saving to ask the guide agent about the lines rather than comment on them. -To ask the guide agent something rather than comment on it, type your question in a new comment box, on lines of code, a file, a concept or text you highlighted, and select **Ask agent** instead of saving. The plugin sends the agent your question with what the box is on, the lines or what the guide says there, closes the box and opens the agent's chat, where you carry on the conversation; nothing is saved or posted. Like **Ask about this**, it asks only an idle agent: while the agent is busy, or when nothing could be sent, the box says so and keeps your question. As for **Ask about this**, a Paseo version that cannot open an agent's chat from a plugin does not offer it. +**Save draft** puts the comment on the forge as a draft right away. +Nobody sees it until you finish the review. +**Your drafts** lists them, including drafts you started on the web. -When you are done, select **Finish review** below your drafts. It shows the review body, which is your pending review's own body on GitHub (so one started on github.com is there too), and every draft with where it sits, a comment on a concept by its concept, and offers **Approve**, **Request changes** and **Comment**. **Save body** keeps an edited body without submitting; a submit sends the body as it is in the box, followed on GitHub by your comments on concepts. On your own pull request, and on a closed or merged one, only **Comment** is offered, though you can still write drafts. When commits were pushed since the guide was written, a warning above the verdicts says how many, or that the branch was rewritten, since a verdict would apply to code the guide did not explain, with **Regenerate the guide** beside them; you can still approve or request changes from the guide you read. The same warning shows when the forge cannot be asked where the head is. The plugin asks the forge again the moment you submit, and when the head is not the one you were warned about, it sends nothing and shows the new warning, so you submit again knowing what you approve. On GitHub your review stays on the commit the guide explained, so a repository that dismisses stale approvals may not count it. After a submit the panel lists each step the forge took and whether it landed, so a partial failure tells you what is left to do on the forge. **Discard review** deletes your pending review, its drafts and its body, after you confirm. +### Finish the review -GitLab keeps no review body before a review is submitted, so on a merge request the plugin keeps the body until you submit or discard. A submit publishes all your draft notes at once, your comments on concepts among them, with the body, and marks you as having reviewed the merge request, or as requesting changes. **Approve** then approves the merge request at the head the plugin checked as you submitted, which GitLab refuses if new commits are pushed in between. After every verdict the plugin reads the reviewer list and reports the state GitLab shows for you as a step of its own: for **Request changes**, when GitLab did not record it, the plugin requests changes through GitLab's GraphQL API instead; for **Approve** and **Comment** it sets nothing, and a state that did not take is reported for you to set on the merge request's page. When you are not one of the merge request's reviewers, GitLab keeps no state for you, which the step says without counting it a failure. A GitLab older than 19.2 ignores the body and the reviewer state when it publishes the drafts, so on such a GitLab, or one whose version the plugin cannot read, the body is posted as a comment of its own right after them. **Discard review** deletes all your draft notes, including the ones on the merge request as a whole. +Select **Finish review**, write the review body, and choose **Approve**, **Request changes**, or **Comment**. +If commits were pushed since the guide was written, a warning says so before you submit. +After a submit, the panel lists each step and whether it went through. -The workspace is a worktree of a local clone. The plugin uses the Paseo project whose `origin` is the repository the pull request or merge request targets; when there is none, it clones the repository once with `gh repo clone` or `glab repo clone` into `$PASEO_HOME/plugin-data/guided-review/clones/` and reuses that clone for every later review of it. - -Requires Paseo 0.11 or newer. +**Discard review** deletes your drafts and the body. ## Settings | Setting | Default | What it does | | --- | --- | --- | -| gh path | `gh` | The `gh` the daemon runs: a command on the daemon's `PATH`, or an absolute path. | -| glab path | `glab` | The `glab` the daemon runs, read the same way. | -| Guide agent provider and model | `claude-tty` | The agent that writes guides: a provider for its default model, or `provider/model` as Paseo names them, like `codex/gpt-5.5`. The default is the provider of the [claude-tty](../claude-tty) plugin, and the built-in `claude` takes its place on a host where that is not available. Applies to guides generated after the change. | -| Guide agent effort | _blank_ | One of the model's effort levels as Paseo names them, like `high`; blank for the model's default. | -| Guide agent mode | _blank_ | The mode the guide agent runs in; blank for its provider's plan or read-only mode. Only a mode that asks before a tool runs is taken, like Claude's `default`, since the plugin can deny only what the agent asks for; `acceptEdits`, `auto` and the like are refused. | -| Syntax theme | GitHub | The syntax theme the diffs are highlighted with, one of Paseo's own, in its dark or light variant to suit Paseo's theme. Plugins cannot read the one picked under Appearance in Paseo's settings, so pick the same one here. | +| gh path | `gh` | The `gh` the daemon runs: a command on its `PATH` or an absolute path. | +| glab path | `glab` | The `glab` the daemon runs, the same way. | +| Provider and model | `claude-tty` | The agent that writes guides: a provider, or `provider/model` like `codex/gpt-5.5`. Falls back to `claude` when the [claude-tty](../claude-tty) provider is not available. | +| Effort | _blank_ | The model's effort level, like `high`. Blank uses the model's default. | +| Mode | _blank_ | The guide agent's mode. Blank uses the provider's plan or read-only mode. Only modes that ask before running a tool are accepted. | +| Syntax theme | GitHub | The theme diffs are highlighted with. Pick the same one as under Appearance in Paseo's settings, which plugins cannot read. | + +The guide agent settings apply to guides written after you select **Use these for new guides**. ## Troubleshooting | Message or symptom | What to do | | --- | --- | -| **That is not a GitHub pull request URL, …** | Paste the pull request's or merge request's own URL, like `https://github.com/owner/repo/pull/123` or `https://gitlab.com/group/project/-/merge_requests/123`. | -| **glab is not logged in to …** | Run `glab auth login --hostname <host>` on the daemon's host. A self-hosted GitLab on a port needs that host set up in `glab`'s own config. | -| **Could not run gh at "gh"** or **Could not run glab at "glab"** | The daemon's `PATH` is often shorter than your shell's. Put the output of `command -v gh` or `command -v glab` in the matching path setting. | -| **gh failed: …** or **glab failed: …** | The CLI refused the call; its own words follow. Check `gh auth status` or `glab auth status` on the daemon's host. | -| **Could not create a workspace for …** | Paseo could not check the pull request or merge request out; the reason follows. The worktree is cut from the Paseo project whose `origin` is its repository. | -| **No open pull request in … comes from …** | The workspace's branch is not the source branch of an open pull request or merge request in its `origin` repository. Push it and open one, or start from the URL. | -| **… has uncommitted changes, so it was left untouched** or **… has commits that are not in …** | Commit, stash or push your work, then guide the branch again to have the guide in your own workspace; until then it is in the PR workspace. | -| **The previous PR workspace has uncommitted changes** or **… has commits that are not in … any more** | **Regenerate** could not fast-forward the old workspace to the new head, so the guide moved to a new one. Archive the old workspace once you no longer need it. | -| No **Commits from other PRs** banner though the diff shows another's changes | The forge links the commits to no other open or merged pull request or merge request: a rebase onto another branch makes new commits, which nothing links. When the forge could not be asked, `paseo plugin logs guided-review` has **Could not read which change requests the commits of … belong to** with the CLI's own words. | -| No **PR updated since this guide** banner though commits were pushed | The forge could not be asked where the head is, and the panel shows no banner until it can; `paseo plugin logs guided-review` has **Could not check … for new commits** with the CLI's own words. GitLab shows a push once it has worked out the new diff, a moment after it. | -| **GitLab did not keep the comment where it was put, so the draft was deleted again** | GitLab stored the draft somewhere other than the line, range or file it was sent for, so the plugin took it back rather than leave it misplaced. A comment on a whole file needs GitLab 17.2 or newer; on an older one, comment on a line instead. If the message says deleting it failed too, delete the draft on the merge request's page. | -| **… runs GitLab …; listing your reviews needs 19.2 or newer** | That GitLab host keeps no record of when your review last changed, which the list needs to tell a push after your review apart. Paste the merge request's URL instead. | -| **Could not keep these filters for next time** | The plugin could not write `inbox-preferences.json` in its data directory; the filters still apply until the list is closed. | -| **Could not keep that check-off** | The plugin could not write `inbox-check-offs.json` in its data directory; the box is put back as it was. | -| **This review was interrupted before it was ready** | The plugin restarted while preparing the workspace. Start it again. | -| **The guide agent's answer did not match what was asked for: …** | The agent's guide was not in the expected shape, or named code the change does not have; the problems follow. Select **Try again** in the panel, or pick a stronger model in the **Guide agent** setting. | -| **The … mode of … runs tools without asking** or **The … provider has no … mode** | Pick a mode that asks first in the **Guide agent** settings, like `plan` or `default`, or leave it blank. | -| **The … provider offers no model for the guide agent** or **Could not create the guide agent with …** | Check the **Guide agent** setting against the providers and models Paseo lists for new agents. | -| **The guide agent did not finish within 30 minutes** | Open the guide agent to see where it got stuck, then select **Try again**. | -| **The guide agent is busy with another answer** | It is still answering an earlier question. Open its chat to follow it, and ask again once it has finished. | -| **The guide agent is gone: it was archived or closed** | The guide has no agent left to answer questions; its chat was archived or closed. | -| **The suggestion was lost, most likely to a plugin restart** | The plugin restarted while the guide agent was wording the comment. Select **Suggest wording** again once the agent has finished. | -| **… is not in the diff, so a comment cannot be anchored there** | Forges take line comments only on lines the diff shows. Comment on a line in a hunk, or on the whole file. | -| **A comment on several lines has to stay within one hunk of the diff** | Drag within one hunk, or leave one comment per hunk. | -| **gh failed: … could not be resolved** when saving a draft | GitHub could not place the comment, usually because the pull request has moved on since the guide was written. Comment on the whole file instead. | -| **Could not check … for new commits: …** under the verdicts | The forge could not be asked where the head is, so a verdict may apply to commits the guide did not explain. Close and reopen **Finish review** to ask again, or submit knowing that. | -| **The … changed again since Finish review read it** | Commits were pushed, or the forge became readable or unreadable, between opening **Finish review** and submitting. Nothing was sent; read the new warning and submit again. | -| **This comment is no longer in your pending review's body as it was written** | A comment on a concept was edited or removed on github.com, so the plugin no longer tells it apart from your own body text. Edit it in the review body under **Finish review**. | -| **Only part of your review went through** | The steps marked **Failed** or **Not tried** did not land. Finish those on the forge's web page. On GitLab, a body that was not posted stays in **Finish review**, and submitting again as **Comment** posts it. | -| **GitLab did not record your request for changes: Reviewer not found** | Your drafts and body were published, but an older GitLab records a request for changes only from someone already listed as a reviewer. Add yourself as a reviewer on the merge request's page and request changes there. | -| **GitLab lists you as …, not …** under **Confirm your reviewer state** | Your review was published, but your reviewer state did not follow, which an older GitLab does. Set it with the reviewer menu on the merge request's page. | -| **GitLab did not approve, as the MR got new commits while your review was being submitted** | Your drafts were published, but a push landed between the plugin checking the head and approving. Close and reopen **Finish review** to see the new commits, and approve again. | -| **… of your draft notes could not be deleted** | **Discard review** deleted the rest. Delete the ones it names on the merge request's page. | +| **gh failed: …** or **glab failed: …** | Check `gh auth status` or `glab auth status` on the daemon's host. | +| **glab is not logged in to …** for a GitLab on a non-standard port | The plugin passes the host without its port. Set the port for that host in `glab`'s own config. | +| **… listing your reviews needs 19.2 or newer** | That GitLab is too old for the review list. Start the review from its URL instead. | +| **The guide agent's answer did not match what was asked for** | Select **Try again**, or pick a stronger model under **Provider and model**. | +| **GitLab did not keep the comment where it was put** | Comments on a whole file need GitLab 17.2 or newer. Comment on a line instead. | +| **gh failed: … could not be resolved** when saving a draft | The PR moved on since the guide was written. Comment on the whole file instead. | +| No **PR updated since this guide** warning after a push | The forge could not be reached. Check `paseo plugin logs guided-review`. | Run `paseo plugin logs guided-review` for more detail. ## Development ```sh -pnpm typecheck -pnpm test +pnpm --filter @paseo-plugins/guided-review typecheck +pnpm --filter @paseo-plugins/guided-review test paseo plugin reload guided-review paseo plugin logs guided-review ``` -Paseo builds the app bundle from `index.client.tsx` and the daemon bundle from `index.server.ts`. A module's directory decides which it joins: `client/` the app's, `server/` the daemon's, `shared/` both. +Reload after every change: the reload is what compiles the plugin's bundles. diff --git a/plugins/guided-review/docs/adr/0001-drafts-live-on-the-forge.md b/plugins/guided-review/docs/adr/0001-drafts-live-on-the-forge.md new file mode 100644 index 0000000..d99616b --- /dev/null +++ b/plugins/guided-review/docs/adr/0001-drafts-live-on-the-forge.md @@ -0,0 +1,6 @@ +# Drafts live on the forge + +A comment is saved to the forge as a draft the moment the reviewer saves it: a thread on the viewer's pending review on GitHub, a draft note on GitLab. +The plugin keeps no copy of a draft, and every listing reads the forge, so drafts started on the web show up in the panel and a draft survives the plugin's data being lost. +The cost is a forge round trip per save, and anchoring a draft on lines the forge's own diff has. +Locally the plugin keeps only what the forge cannot hold: which concept a draft was written from, the passage it was highlighted from, the review body until submit, and GitHub's comments on the change as a whole, which GitHub has no draft for. From 748381a6436fb69de17cdf4277d68d1b420c07e4 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 13:52:53 +0200 Subject: [PATCH 07/31] docs(claude-tty): rewrite the README to the new template Turn the intro's essays into a Usage section per task, put the four settings in a table, and keep troubleshooting to what Diagnostics does not tell you. Drop the upgrade notes for the old traecli provider, which no installation is still on, and record the move to a provider of the plugin's own as an ADR instead. Record as ADRs that the adapter is built from the plugin's checkout and that the settings are host-wide in Paseo's store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- plugins/claude-tty/README.md | 148 +++++++++--------- ...1-the-plugin-registers-its-own-provider.md | 7 + ...pter-is-built-from-the-plugins-checkout.md | 6 + ...-settings-are-host-wide-in-paseos-store.md | 6 + 4 files changed, 90 insertions(+), 77 deletions(-) create mode 100644 plugins/claude-tty/docs/adr/0001-the-plugin-registers-its-own-provider.md create mode 100644 plugins/claude-tty/docs/adr/0002-the-adapter-is-built-from-the-plugins-checkout.md create mode 100644 plugins/claude-tty/docs/adr/0003-settings-are-host-wide-in-paseos-store.md diff --git a/plugins/claude-tty/README.md b/plugins/claude-tty/README.md index 67d094a..ae3bd76 100644 --- a/plugins/claude-tty/README.md +++ b/plugins/claude-tty/README.md @@ -1,32 +1,25 @@ # Claude TTY -Adds **Claude TTY** to Paseo's provider list: the genuine interactive Claude Code CLI, driven in a PTY by the [Claude TTY ACP adapter](../../apps/claude-tty-acp). +Adds **Claude TTY** to Paseo's providers: the real interactive Claude Code CLI, run in a terminal by the [Claude TTY ACP adapter](../../apps/claude-tty-acp). +A sidebar panel diagnoses the adapter and manages its saved sessions on the selected host. -The plugin registers the provider itself and runs the adapter it was installed beside, so there is no provider entry to write into the daemon configuration and nothing to reload. It also diagnoses the adapter and manages its saved sessions from Paseo's sidebar, on whichever host is selected. - -Owning the provider is also what lets Claude's own interactions use Paseo's cards for them. A question Claude asks becomes one question form holding every question at once — radio buttons, checkboxes where Claude allows several answers, and a box for an answer that is on none of the lists — and a plan becomes a plan card with Implement and Reject. Both are answerable from a terminal too: `paseo permit allow <agent> <id> --input '{"answers":{"Runtime":"Node"}}'` answers a whole form, keyed by the label each question shows. - -Owning it is also what lets a subagent be more than a card. An agent a session launches opens as a **subsession** of the Paseo agent that launched it, with a timeline of its own: what it was asked to do, what it said, and every tool call it made, with the file it read, the command it ran and the reason anything failed — not the bounded tail the launching card has room for. A subsession takes no prompts, because the subagent behind it takes none: it answers to the one prompt its launcher handed it. It closes when the launch that started it does, which is also what a session whose Claude process stopped reports, so nothing goes on saying it is working after the process running it has gone. A command Claude runs in the background — asked to, or because it outlived its timeout — gets a subsession the same way, showing what it has printed so far, and its card in the conversation shows the end of that output until the command reports. - -Owning it is what gives a session its own pickers, too. The model list is Claude Code's rolling aliases and the releases behind them, and beside it is a **thinking** picker carrying Claude Code's effort levels, which claude-tty had no way to offer before. Either can be changed while a session is idle: the adapter restarts Claude on the same conversation with the new flag, so the choice is a real one rather than a message typed into the box. - -An agent another agent launched has no subsession of its own: the session's conversation never mentions one, so nothing there says when it stopped, and its steps stay on its spawner's card the way they always did. The card is also all there is on a daemon that does not negotiate `session.subsession`, where nothing is emitted at all. - -## Screenshots +## Installation -_None yet._ +You need: -## Installation +- Paseo 0.11 or newer. +- `pnpm` on the daemon's `PATH`, because installing and updating build the adapter. +- Claude Code installed and logged in as the user the daemon runs as (see the [adapter's requirements](../../apps/claude-tty-acp/README.md)). ```sh paseo plugin add github:sleeyax/paseo-plugins:plugins/claude-tty ``` -Paseo tracks the default branch from there, so `paseo plugin update claude-tty` picks up new releases without a clone, and `paseo plugin update claude-tty --check` says what is installed against what is available. +Update with `paseo plugin update claude-tty`. +A failed build is reported, and the installed version keeps running. +Install the plugin on each host that should offer Claude. -Install and update both build the adapter first, from the manifest's `build` commands, which is why this plugin needs `pnpm` on the daemon's `PATH`. A failing build is reported and the installed version is left running. - -Installing from a clone works too, and is what to do while developing the adapter — build it yourself first, since a directory installation runs no build: +To install from a clone, build the adapter first, since a directory install runs no build: ```sh pnpm install --frozen-lockfile @@ -34,88 +27,94 @@ pnpm --filter @paseo-plugins/claude-tty-acp build paseo plugin add "/absolute/path/to/paseo-plugins/plugins/claude-tty" ``` -### Pointing the plugin at an adapter you build yourself - -By default the plugin runs the adapter beside it, in the checkout it was installed from, and that is the whole story for either installation above. **Adapter executable** under Settings is the way out of it: set a path there and that is what the provider spawns, from wherever you build it. - -That is for a host where the default does not fit — no `pnpm` for the daemon to build with, an adapter built by whatever else builds that host's software, or a plugin whose own checkout is not the copy of the adapter you want running. It is a path to a *built* adapter: the adapter is `private` and links a native module, so there is nothing published to point at, and it is built from a clone the way the block above builds one. - -The panel's **Adapter** section says which of the two is in force, and reads the path back: a path that is not there, cannot be executed, or has not been built is a sentence on the panel rather than a session that will not open. Clearing the setting goes back to the checkout's. +If the daemon can't build the adapter, build it from a clone yourself and put the path to `apps/claude-tty-acp/bin/claude-tty-acp` in the **Adapter executable** setting. +The panel's **Adapter** section shows which adapter runs and whether it can. -A session already open keeps the adapter it started on — the process is already running — so a change reaches the sessions started after it. +## Usage -**An authenticated Claude stays yours to arrange.** Run `claude` interactively as the user the daemon runs as; the plugin never touches Claude's configuration, credentials, or transcripts. +### Start a session -Everything is host-local: selecting another host in Paseo shows that host's own answer, and each host is installed separately. +Pick **Claude TTY** as the provider when you create an agent. +Model, mode and effort pickers work as described in the [adapter's README](../../apps/claude-tty-acp/README.md). +A message you send while Claude is working interrupts the turn and starts a new one with your message. -### Upgrading from the `traecli` provider +### Answer questions and plans -Earlier versions of this plugin, and the adapter's own README, registered the adapter in the daemon configuration under the provider ID `traecli`. This version registers a provider of its own, `claude-tty`, and `paseo plugin update claude-tty` leaves the old entry where it was, so Paseo lists Claude TTY twice. The panel says so under **Left over from an older install** for as long as the entry is there, with the number of agents still on it. - -- **An agent stays on the provider it was started on.** Agents started on `traecli` keep using the old entry and cannot resume once it is gone, so remove it once none are left: under **Settings → Providers** on that host it is the Claude TTY entry with an actions menu, and **Remove provider** deletes it from the configuration. The plugin never removes it itself. -- **The provider ID changes.** Anything that names `traecli/<model>` — a spawn script, an agent profile, a schedule — goes on starting agents on the old entry, and stops working once it is removed. Point it at `claude-tty/<model>` instead. -- **The idle timeout does not carry over.** A timeout chosen in the old panel was kept under `${XDG_CACHE_HOME:-~/.cache}/paseo-plugins/claude-tty/`, which this version never reads. Choose it again under **Suspend idle Claude**, and delete that directory. - -## Settings +When Claude asks you something, all its questions arrive in one form. +Pick an option, tick several where Claude allows it, or type your own answer. +A plan arrives as a card with **Implement** and **Reject**. -Under **Settings → Plugins → Claude TTY** on the selected host, or from the Command Center as "Claude TTY: settings". Paseo owns the store, so the value survives a reload, an update and a daemon restart, every client sees a change without reloading, and it is deleted with the plugin. +You can answer a question form from a terminal too, keyed by the header each question shows: -The **Adapter executable** setting is above, under [Pointing the plugin at an adapter you build yourself](#pointing-the-plugin-at-an-adapter-you-build-yourself): empty, which is the default, runs the adapter in the checkout this plugin was installed from. +```sh +paseo permit allow <agent> <id> --input '{"answers":{"Runtime":"Node"}}' +``` -The **Suspend idle Claude** setting controls how long a native Claude process remains alive after the session last did anything. It defaults to one hour, and you can choose 15 minutes through 8 hours, or **Never**. It applies to every session on the host. The adapter's per-session ACP configuration carries the model, the effort level and Auto Accept, and moving the timeout in beside them would also move the value out of the store Paseo owns, so the one that survives a reload and an update stays host-wide. +### Follow subagents and background commands -Suspending stops the PTY and any background tasks it owns, but does not close or archive the Paseo agent. The adapter keeps the persisted session mapping, and the next prompt automatically launches `claude --resume` with the same Claude session, model, mode, and effort level. The timer runs from the last thing the session actually did, not from the last prompt: a turn Claude runs on its own after a task notification, the agents it launches, and the hooks it calls all count, so a session working unattended is not stopped mid-run. +Each subagent a session launches opens as a subsession under its agent. +The subsession shows the subagent's instructions, its replies and every tool call it makes. +A command Claude runs in the background gets a subsession with its output so far. +Subsessions are read-only, and close when the subagent or command finishes. +A subagent launched by another subagent has no subsession; its steps stay on its parent's card. -A session waiting on a subagent is not suspended at all. The adapter holds the turn open until every agent it launched has reported, which is also what makes Paseo show the session as busy while they work, and a suspension stands aside for an active turn and tries again later. A turn whose agents have written nothing for fifteen minutes stops waiting, so a stuck agent cannot keep a session alive indefinitely. A command Claude runs in the background holds the turn the same way, because Claude goes idle while it runs and is woken by its report; a command that has not reported after thirty minutes — a server, typically, which never will — stops being waited on. +### Check the host and manage sessions -The adapter reads the current setting each time it schedules a suspension, so a change also applies to sessions that are already open. A suspension also stands aside while a permission or question card is still waiting for an answer, and tries again later. +Open **Claude TTY** from the sidebar. -Setting `CLAUDE_TTY_ACP_IDLE_TIMEOUT_MS` on the daemon overrides this setting for the hosts that do it, because the adapter inherits the daemon's environment and lets the variable win; the settings screen says so when something has set it. +- **Diagnostics** → **Run** checks the host the adapter runs on, such as whether Claude is on the daemon's `PATH`. +- **Sessions** lists the saved sessions, named after the agent holding each one. + **Open** jumps to that agent, **Stop** closes its Claude terminal, and **Release lock** clears a lock its process left behind. + A stopped session resumes on its next prompt. +- **Danger zone** deletes every saved session. + `paseo plugin remove claude-tty` leaves them on disk; this is how you remove them. -### Permission prompts +## Settings -Every agent has an **Auto Accept** toggle, the same switch Paseo shows for its own ACP providers. While it is on, Claude Code's permission prompts are approved once without a card, including the ones it keeps even in Bypass Permissions mode, such as removing everything in the working directory. Questions Claude asks and plans it proposes still wait for an answer. Paseo's own toggle carries a shield icon, but a plugin provider has no field for one, so this one shows with the generic settings icon. +Open **Settings → Plugins → Claude TTY**, or run **Claude TTY: settings** from the Command Center. +Each host has its own settings. -**Auto-accept in new sessions** is where the toggle starts, and is off unless you turn it on. **In Bypass Permissions sessions** overrides it for sessions in that mode, or is the same as other sessions. These exist for agents nobody is watching: `paseo run` and anything built on it can pick a mode but not a toggle, so an overnight agent started in Bypass Permissions gets auto-accept only from this setting. +| Setting | Default | What it does | +| --- | --- | --- | +| Adapter executable | empty | Path to a built adapter to run instead of the one in the plugin's checkout. Sessions already open keep the adapter they started on. | +| Suspend idle Claude | 1 hour | How long Claude keeps running after a session goes quiet: 15 minutes to 8 hours, or Never. The next prompt resumes the same conversation. | +| Auto-accept in new sessions | off | Where each agent's **Auto Accept** toggle starts. | +| In Bypass Permissions sessions | Same as other sessions | Overrides the switch above for sessions in Bypass Permissions mode. | -An agent whose toggle you switch keeps that value, across suspensions and restarts, and stops following these settings. Every other agent reads them again at each permission prompt and whenever its mode changes, so a change here applies to open sessions too. +**Auto Accept** approves Claude Code's permission prompts without a card. +Questions and plans still wait for you. +An agent whose toggle you switch keeps its own value; every other agent follows these settings, including open ones. +Agents started with `paseo run` can pick a mode but not the toggle, so use **In Bypass Permissions sessions** to auto-accept for unattended agents. ## Troubleshooting -**Diagnostics** runs the adapter's own host checks — Claude on the daemon's `PATH` above all. Paseo drops the adapter's stderr, so running them is the only way to read them; what Paseo itself makes of the provider is in the provider list and in `paseo plugin logs claude-tty`. - -**Sessions** lists the adapter's saved sessions and the locks over them, each named after the Paseo agent holding it and saying when it was last prompted. That reads "last prompted" rather than "active" on purpose: the adapter stamps the time as a prompt starts, so a session an hour into one turn is still working. **Open** reveals the agent holding a session, and is there for as long as Paseo still lists one. - -**Stop** ends the adapter process holding an open session, which closes its Claude terminal. Nothing durable goes with it: the session file, the transcript, and the Paseo agent all survive, and the next prompt resumes the same Claude session. - -A PID outlives the process that earned it, so a stop first establishes that the process really is the one that took the lock — the right kind of process, and one that cannot have started after the lock it holds. Anything else is refused, named, and left running, including a process that has already exited and is waiting to be reaped. The adapter is given ten seconds to close the session itself before it is forced. - -A lock names the process holding a session; the adapter clears its own on exit and recovers one left by a dead process, so **Release lock** is only for a lock that outlived its process and is still in the way — including one left behind by a stop that had to force the process. Releasing is refused while the recorded process is alive. A session file that cannot be read can be moved aside rather than deleted, so the failure is still there to diagnose. +Paseo discards the adapter's error output. +When the provider is unavailable or a session won't start, run **Diagnostics** on the panel first. -The adapter's [troubleshooting table](../../apps/claude-tty-acp/README.md#troubleshooting) covers everything that goes wrong once a session is running. Its log is kept at `${XDG_STATE_HOME:-~/.local/state}/claude-tty-acp/logs/claude-tty-acp.log`, or under `CLAUDE_TTY_ACP_STATE_DIR` where that is set, because the daemon reads the adapter's stderr and keeps none of it. +A systemd daemon's `PATH` usually lacks `claude`. +Set `CLAUDE_BIN` in the daemon's environment to Claude's absolute path, or add its directory to the daemon's `PATH`. -The provider goes away with the plugin, so removing it is `paseo plugin remove claude-tty`. What that leaves behind is the state directory, and the **Danger zone** deletes it: saved sessions stop resuming, it is refused while a session is open, and Claude's own configuration and transcripts are never touched. +The adapter logs to `${XDG_STATE_HOME:-~/.local/state}/claude-tty-acp/logs/claude-tty-acp.log`, or under `CLAUDE_TTY_ACP_STATE_DIR` when that is set. +For errors inside a running session, see the [adapter's troubleshooting](../../apps/claude-tty-acp/README.md#troubleshooting). ## Development ```sh +pnpm --filter @paseo-plugins/claude-tty typecheck +pnpm --filter @paseo-plugins/claude-tty test paseo plugin reload claude-tty paseo plugin logs claude-tty ``` -```sh -pnpm --filter @paseo-plugins/claude-tty typecheck -pnpm --filter @paseo-plugins/claude-tty test -``` +After changing the adapter, rebuild it with `pnpm --filter @paseo-plugins/claude-tty-acp build`. +Sessions already running keep the old build. -### A second copy, to develop against +### A second copy to develop against -A reload stops Claude in every session on the provider, because the plugin process is the transport -for all of them. That is bearable for a session of your own and not for somebody else's, so the -answer is a second copy under an ID of its own: the working sessions stay on `claude-tty`, and the -copy being changed is reloaded as often as it likes. +Reloading the plugin stops Claude in every session on its provider. +To keep your working sessions alive, install a second copy under its own ID and reload only that one. -In a second checkout — a `git worktree` of this one is enough: +In a second checkout, such as a `git worktree` of this one: ```sh pnpm --filter @paseo-plugins/claude-tty identity claude-tty-dev @@ -124,13 +123,8 @@ pnpm --filter @paseo-plugins/claude-tty-acp build paseo plugin add "/absolute/path/to/the/second/checkout/plugins/claude-tty" ``` -That one command writes the plugin ID, the provider ID, the label, the manifest and the adapter's -state directory — the things two copies may not share. `identity --check` says whether they still -agree, `identity` on its own prints them, and `identity claude-tty` puts the checkout back. Sessions, -locks and logs are then under `~/.local/state/claude-tty-dev-acp/`, apart from the stable copy's, so -**Danger zone** on one leaves the other alone. - -The two are separate providers, so an agent started on one cannot be moved to the other: start -throwaway sessions on the dev provider and leave the work on the stable one. +The copy shows up as **Claude TTY (dev)**, with its own provider and its own state under `~/.local/state/claude-tty-dev-acp/`. +Agents can't move between the two providers, so start throwaway sessions on the dev one. +`pnpm --filter @paseo-plugins/claude-tty identity claude-tty` stamps the checkout back. -See [CLAUDE.md](./CLAUDE.md) for the constraints that are not obvious from the code. +See [CLAUDE.md](./CLAUDE.md) before changing the code, and [docs/adr](docs/adr) for the design decisions. diff --git a/plugins/claude-tty/docs/adr/0001-the-plugin-registers-its-own-provider.md b/plugins/claude-tty/docs/adr/0001-the-plugin-registers-its-own-provider.md new file mode 100644 index 0000000..f108793 --- /dev/null +++ b/plugins/claude-tty/docs/adr/0001-the-plugin-registers-its-own-provider.md @@ -0,0 +1,7 @@ +# The plugin registers its own provider + +The plugin registers `claude-tty` as a plugin provider through `registerProvider`, with its own ID, label and icon, and runs the adapter from `connect()`. +Earlier versions instead patched `agents.providers` in the daemon configuration from outside, under the borrowed `traecli` provider ID, because the daemon only waited for a provider's initial slash commands when its ID was `traecli`. +Paseo 0.8 let a plugin own a provider and ask for that wait through `acpOptions`, which removed the install, repair and uninstall machinery and let the plugin wrap the ACP connection to fix up cards, subsessions and steering. +The cost is that every session on the provider lives inside the plugin process, so reloading the plugin stops all of them. +An upgraded host keeps the old `traecli` entry until someone removes it; the plugin reports it and never deletes it, because agents started on it cannot resume once it is gone. diff --git a/plugins/claude-tty/docs/adr/0002-the-adapter-is-built-from-the-plugins-checkout.md b/plugins/claude-tty/docs/adr/0002-the-adapter-is-built-from-the-plugins-checkout.md new file mode 100644 index 0000000..45adce0 --- /dev/null +++ b/plugins/claude-tty/docs/adr/0002-the-adapter-is-built-from-the-plugins-checkout.md @@ -0,0 +1,6 @@ +# The adapter is built from the plugin's own checkout + +The plugin runs the adapter that sits in the same checkout it was installed from, built by the manifest's `build` commands at install and update, rather than an adapter installed from npm. +The adapter is private and links a native module (node-pty), so there is no published artefact to depend on, and building beside the plugin keeps the two at the same commit. +This is why installing needs `pnpm` on the daemon's `PATH` and a clone has to be built by hand before a directory install. +The **Adapter executable** setting is the escape hatch for hosts that cannot build: it names an adapter built elsewhere, and the plugin then ignores its checkout's. diff --git a/plugins/claude-tty/docs/adr/0003-settings-are-host-wide-in-paseos-store.md b/plugins/claude-tty/docs/adr/0003-settings-are-host-wide-in-paseos-store.md new file mode 100644 index 0000000..eb317a6 --- /dev/null +++ b/plugins/claude-tty/docs/adr/0003-settings-are-host-wide-in-paseos-store.md @@ -0,0 +1,6 @@ +# Settings are host-wide, in Paseo's settings store + +The idle timeout and the Auto Accept defaults are host settings in the store Paseo owns, not per-session ACP config options beside the model and effort pickers. +Paseo's store survives reloads and updates, gives one answer per host, and is deleted with the plugin; a session config option would give up all three. +The adapter is a separate process that cannot read that store, so the plugin writes it a resolved copy (`--settings-file`), which the adapter re-reads at every suspension and permission prompt so changes reach open sessions. +Auto Accept is the one exception that is also per agent: the toggle is a session config option, and the host settings only decide where it starts, because `paseo run` can set a mode but not a toggle. From 0730c789c93926fcc9a48f7a793437df66b2ebb1 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 13:54:19 +0200 Subject: [PATCH 08/31] docs: cut every CLAUDE.md down to the traps an agent cannot find out Keep basic orientation and the things that break when unknown; cut rationale, history, walkthroughs of what the code does, and rules the root CLAUDE.md or Paseo's plugin reference already state. Move the decisions with a real trade-off into ADRs: the plugin workspace layout and the linked claude-tty versions at the root; the adapter subprocess, the tool-call vendor method and Paseo's own question cards for claude-tty; the daemon connection for discord-rich-presence; reading the change with git for guided-review. Give the README template a Usage section, make Screenshots and Settings optional, and state the README rules so a feature updates its section instead of appending a paragraph. Correct stale claims on the way: discord's settings schema drops unknown keys rather than rejecting them, and Paseo's install paths are relative to the installed packages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 43 +- ...e-workspace-with-a-plugin-per-directory.md | 5 + ...ude-tty-and-its-adapter-share-a-version.md | 4 + plugins/catppuccin-theme/CLAUDE.md | 20 +- plugins/claude-tty/CLAUDE.md | 373 +++--------------- .../0004-the-adapter-stays-a-subprocess.md | 6 + ...all-details-travel-over-a-vendor-method.md | 6 + ...estions-use-paseos-own-permission-cards.md | 5 + plugins/claude-tty/scripts/set-identity.mjs | 2 +- plugins/discord-rich-presence/CLAUDE.md | 63 +-- .../docs/adr/0001-own-daemon-connection.md | 10 + plugins/guided-review/CLAUDE.md | 181 ++------- ...e-guide-agent-reads-the-change-with-git.md | 5 + 13 files changed, 192 insertions(+), 531 deletions(-) create mode 100644 docs/adr/0001-one-workspace-with-a-plugin-per-directory.md create mode 100644 docs/adr/0002-claude-tty-and-its-adapter-share-a-version.md create mode 100644 plugins/claude-tty/docs/adr/0004-the-adapter-stays-a-subprocess.md create mode 100644 plugins/claude-tty/docs/adr/0005-tool-call-details-travel-over-a-vendor-method.md create mode 100644 plugins/claude-tty/docs/adr/0006-questions-use-paseos-own-permission-cards.md create mode 100644 plugins/discord-rich-presence/docs/adr/0001-own-daemon-connection.md create mode 100644 plugins/guided-review/docs/adr/0002-the-guide-agent-reads-the-change-with-git.md diff --git a/CLAUDE.md b/CLAUDE.md index 64e2e81..28d2907 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,46 +1,59 @@ # Working in this workspace -A pnpm workspace of paseo plugins, one per directory under `plugins/`. -Each package is a plugin directory in its own right: paseo is installed against `plugins/<name>`, and `paseo-plugin.json` and the runtime entries sit at that package's root because the loader stats exactly those names. -Since Paseo 0.8 those entries are `index.client.tsx` and `index.server.ts`, either or both, and every other module lives under `client/`, `server/`, or `shared/`, which is what decides the bundle it joins. +A pnpm workspace of Paseo plugins under `plugins/`, and the apps they run under `apps/`. +Paseo installs a plugin against its package directory, so `paseo-plugin.json`, `index.client.tsx` and `index.server.ts` sit at the package root, where the loader looks for exactly those names. +Every other module lives under `client/`, `server/` or `shared/`, and that directory decides which bundle it joins. Dependencies belong to the package that uses them, not the root, because the daemon's esbuild resolves from the plugin directory. -`pnpm typecheck` and `pnpm test` at the root fan out to every package; the package-level scripts are the ones to run while working on a single plugin. -Paseo documents the plugin contract at `public-docs/plugins/reference.md` in `getpaseo/paseo`: which modules each runtime may import, the settings store, the theme tokens, the host UI components, and the CLI. -`providers.md` beside it covers ACP providers, `publishing.md` npm distribution, and `migration.md` the runtime-entry migration. +Paseo documents the plugin contract at `public-docs/plugins/reference.md` in `getpaseo/paseo`, with `providers.md`, `publishing.md` and `migration.md` beside it. Read it before inferring a rule from a failed build. -Each package carries its own CLAUDE.md, which records only what that reference does not. + +A package's CLAUDE.md holds only what an agent cannot find out from the code, `git log` or that reference: the traps that break something when you don't know them. +Decisions with a real trade-off go in an ADR instead (see **Domain docs** below). ## Plugin READMEs -Every plugin's README follows the same template, so a new plugin starts from this and drops only the sections marked optional: +A README is for someone installing and using the plugin. +Internals, rationale and history stay out of it. +A change updates the section it belongs to rather than appending a paragraph, and leaves out what the UI already tells the user. +Write short, plain sentences, one per line. + +Every plugin's README follows this template; sections marked optional are dropped when there is nothing to put in them: ```md # plugin name -<short description> +<one or two sentences on what it does for the user> -## Screenshots +## Screenshots (optional) ## Installation -<paseo plugin add sleeyax/<name> for a plugin in the Paseo registry, else paseo plugin add github:sleeyax/paseo-plugins:plugins/<name>, plus the update note; a plugin that needs a built artefact from this repo documents the absolute-path form instead> +<the Paseo version it needs and what the host has to provide> + +<paseo plugin add sleeyax/<name> for a plugin in the Paseo registry, else paseo plugin add github:sleeyax/paseo-plugins:plugins/<name>, plus the update note> -<optional extra instructions> +## Usage -## Settings +<one ### per thing a user does with it, written as how to do it> + +## Settings (optional) + +<a table of setting, default, and what it does> ## Troubleshooting (optional) +<only the problems whose fix the message itself does not give> + ## Development -<instructions for developers> +<package-level commands, and what a developer needs beyond them> ## License and attributions (optional) ``` -Where a plugin's settings file lives is documented once in the root README, not per plugin. +Where a plugin's settings live is documented once in the root README, not per plugin. ## Agent skills diff --git a/docs/adr/0001-one-workspace-with-a-plugin-per-directory.md b/docs/adr/0001-one-workspace-with-a-plugin-per-directory.md new file mode 100644 index 0000000..5df04f1 --- /dev/null +++ b/docs/adr/0001-one-workspace-with-a-plugin-per-directory.md @@ -0,0 +1,5 @@ +# One workspace, with a plugin per directory + +All plugins live in this one pnpm workspace, each in its own package directory that Paseo installs directly, as in `paseo plugin add github:sleeyax/paseo-plugins:plugins/<name>`. +That lets plugins share an app they run (such as `claude-tty-acp`) and the repository's tooling, at the cost of every package root being a plugin root: the manifest and runtime entries must sit there, and each package carries its own dependencies because the daemon resolves imports from the plugin directory. +Install sources and registry entries name these paths, so moving a plugin breaks existing installations. diff --git a/docs/adr/0002-claude-tty-and-its-adapter-share-a-version.md b/docs/adr/0002-claude-tty-and-its-adapter-share-a-version.md new file mode 100644 index 0000000..22fc13d --- /dev/null +++ b/docs/adr/0002-claude-tty-and-its-adapter-share-a-version.md @@ -0,0 +1,4 @@ +# `claude-tty` and its adapter share a version + +release-please links the versions of `claude-tty` and `claude-tty-acp`, so a release of either bumps both. +The plugin builds and runs the adapter from its own checkout, and installations only update when the plugin's version moves, so an adapter fix released under the adapter's version alone would never reach anyone. diff --git a/plugins/catppuccin-theme/CLAUDE.md b/plugins/catppuccin-theme/CLAUDE.md index 856f7b1..999b35a 100644 --- a/plugins/catppuccin-theme/CLAUDE.md +++ b/plugins/catppuccin-theme/CLAUDE.md @@ -1,11 +1,13 @@ # Working on this plugin -Run `paseo plugin reload catppuccin-theme` after every change. -The reload is the only compile check of the bundle the daemon builds from `index.client.ts`. -Do this yourself; never leave it to the user. - -The plugin is nothing but data: four `addTheme` calls, no surface and no `server.handle`, so there is no server entry, and `pnpm test` only checks that `contribute` registers every flavour. -`PluginThemeColors` is the eight tokens Paseo expands into its own set, and the host parses a contribution with a Zod `strictObject`, so a ninth key is rejected rather than ignored. -v0.7 added `statusSuccess` and `statusWarning` to `PluginTheme` — what a plugin *receives* — and not to the contribution, so a mismatch with the app is still fixed by choosing a different palette colour for one of the eight. -Paseo expands a contribution by reading `border` as its `surface3` and `ring` as its `surface4` and `foregroundExtraMuted`, which is what those two are really choosing. -Every flavour uses the same palette-to-token mapping, so a fix to one belongs in all four. +Run `paseo plugin reload catppuccin-theme` after every change; it is the only compile check of the client bundle. +Do this yourself rather than leaving it to the user. + +The plugin is four `addTheme` calls in `index.client.ts` and has no server entry. + +## Theme tokens + +- A contribution takes exactly the eight colours in `PluginThemeColors`; the host parses it strictly, so an extra key is rejected, not ignored. +- `statusSuccess` and `statusWarning` exist on `PluginTheme`, which a plugin receives, not on the contribution. Fix a clash with the app's status colours by picking a different palette colour for one of the eight. +- Paseo derives `surface3` from `border`, and `surface4` and `foregroundExtraMuted` from `ring`, so those two choose more than their names say. +- All four flavours use the same palette-to-token mapping; a fix to one belongs in all four. diff --git a/plugins/claude-tty/CLAUDE.md b/plugins/claude-tty/CLAUDE.md index 1f0b723..8db7655 100644 --- a/plugins/claude-tty/CLAUDE.md +++ b/plugins/claude-tty/CLAUDE.md @@ -1,352 +1,81 @@ # Working on this plugin -Run `paseo plugin reload claude-tty` after every change, then `paseo plugin logs claude-tty`. -Do this yourself; never leave it to the user. -Where a reload is not available — another agent's sessions are on this provider, say — the daemon's own compiler answers the same question without touching it: import `compilePlugin` from `@getpaseo/server/dist/server/server/plugins/compiler.js` and run it over the two entries, then `readPluginProviderIcon` from `provider-icon.js` beside it for the icon. -That log is the plugin's; the adapter's is `~/.local/state/claude-tty-acp/logs/claude-tty-acp.log`, because the daemon drops the adapter's stderr, and a running adapter process keeps the code it started with — a rebuilt `dist/` reaches the sessions started after it. +Read `docs/adr/` before changing how the provider, the adapter process, settings, tool cards or question cards work. -`paseo plugin add github:sleeyax/paseo-plugins:plugins/claude-tty` is the supported install: the daemon clones into a staging directory, runs the manifest's `build` commands there with the plugin directory as the cwd, and only then places and starts it. -`pnpm` walks up to the workspace root from that cwd, which is why `pnpm install --frozen-lockfile` and `pnpm --filter @paseo-plugins/claude-tty-acp build` are enough (verified by running both from `plugins/claude-tty`). -A directory install runs no build at all, so a clone has to be built by hand before it is added. -Which is also why the ID is stamped into the checkout by `pnpm identity <id>` (`scripts/set-identity.mjs`) rather than generated: there is no build step to generate it in on the path that matters, and a generated file would be missing exactly where it is needed. -It writes `paseo-plugin.json`, `shared/identity.ts` and the adapter's `APP_NAME` — the places the ID has to appear, none of which can read another — and `pnpm identity --check` is the guard; stamping the ID a checkout already has is a no-op to the byte. -A second checkout stamped `claude-tty-dev` is the better answer to the reload problem above when somebody else's sessions are running: see the README's "A second copy, to develop against". +## Checking a change -To exercise a handler without a client, invoke it over the daemon's own plugin RPC: +Run `paseo plugin reload claude-tty`, then `paseo plugin logs claude-tty`, after every change, yourself. +A reload stops Claude in every session on the provider, other people's included. +When someone else's sessions are on it, work in the `claude-tty-dev` copy (README, "A second copy to develop against"), or compile without reloading: run `compilePlugin` from the installed `@getpaseo/server`'s `dist/server/server/plugins/compiler.js` over the two entries. + +To see what a handler really does on this host, call it over the daemon's plugin RPC with `connectToDaemon` from the installed `@getpaseo/cli`'s `dist/utils/client.js`: ```js -import { connectToDaemon } from "/usr/lib/node_modules/@getpaseo/cli/dist/utils/client.js"; const client = await connectToDaemon({}); console.log(await client.invokePluginRpc("claude-tty", "claude-tty.status", {})); ``` -That is the only way to see what a filesystem guard actually does, so use it rather than reasoning about the code. - -## The checkout is not discoverable, and the daemon asks for the provider before it can be - -By default the plugin runs the adapter built inside the checkout it was installed from, and nothing in the plugin runtime says where that is. -The server bundle is `eval`'d from a string in a wrapper taking only `require`, so it has no `__dirname`, and esbuild compiles `import.meta` to `{}`; the daemon's initialize message carries `pluginId`, `appVersion`, `bundle` and `settingsDirectory` and no path. -The daemon's own configuration is the one record, so `server/checkout.ts` reads `$PASEO_HOME/config.json` — the plugin process inherits `PASEO_HOME` from the daemon — rather than asking over `paseo.config.get()`, because `registerProvider` has to be called synchronously during the contribution and the daemon connects the provider about ten milliseconds after the plugin reports ready, long before any RPC has handed the plugin a `PaseoApi`. -`apps/claude-tty-acp/package.json` has to exist two levels above the plugin directory before that answer is worth anything. -`server/paths.ts` is the naming vocabulary that resolving builds on and computes paths without touching the disk. - -**It is no longer the only source, and failing to find it is no longer fatal.** -`adapterExecutable` in the host settings names an adapter outright, and `server/adapter.ts` is the one place that decides between the two: the setting when it holds a path, the checkout otherwise, and neither is the end of the world on its own. -It reads the setting through `readConfiguredExecutable` in `server/settings.ts`, which treats an invalid document as nothing configured. -Nothing in there throws: a path that is missing, unexecutable or unbuilt comes back as a sentence on `problem`, which the panel shows, `status()` reports as the provider being unavailable, and `connect()` throws only when there is no path at all. -The checkout is still resolved and still reported, because an update still builds in it and a host running the default still wants to see it. - -That is also why the resolution is no longer cached the way the checkout was. -The path the daemon loaded this plugin from cannot change under it; a setting can, so `resolveAdapter` calls `read()` every time and `getCatalogCacheKey` costs that read plus its one `stat`. - -`connect()` is async, so the command is resolved per connection rather than at registration: `server/provider.ts` builds the `runAcpProvider` shim inside `connect` and delegates to it. -That shim spawns one adapter process per ACP session, plus a throwaway one per connection to probe capabilities and another per catalogue fetch, and it drops the adapter's stderr — which is why the diagnostics section still runs the adapter's own `--diagnose`. - -How often that catalogue fetch happens is `getCatalogCacheKey`'s to decide. -Without it the daemon keys the cache on `["target", <cwd>]` and fetches once per distinct workspace directory, which on a machine that spawns worktrees is once per worktree; with it, equal keys share one fetch across every directory. -The key is the adapter's build — the build witness's path, mtime and size, one `stat` — rather than a bare constant, because the catalogue is compiled into the adapter and a rebuilt adapter is where a different one comes from; a constant would serve the old catalogue for the rest of the daemon's life. -`adapterBuildWitness` is what "the build" means for a path: the executable in a checkout is a committed shell wrapper whose mtime never moves, so the `dist/cli.js` it runs is the file to watch, while a configured executable is its own witness because there is nothing else here to know about it. -Nothing else invalidates it. The daemon refetches when something asks it to refresh (`force`), and marks catalogues stale when the settings snapshot is refreshed; there is no expiry. -It is a separate IPC call on essentially every provider snapshot read, so it must stay at one settings `read()` and one `stat`. -An adapter that is not built yet answers with a shared key of its own rather than with none, so that failure is reported once instead of once per workspace, and the build that fixes it changes the key. - -`status()` is how the daemon learns whether the provider can run at all; without it, a registration with no `command` is checked by opening a connection, which spawns an adapter. -It is asked on every provider snapshot too, so it reports `problem` and stops at the settings `read()` and the `stat`s `resolveAdapter` makes; the adapter's own `--diagnose` stays behind the panel's button. -The registration sets no `command` on purpose: with one, the daemon resolves its own launch and hands it to `status` and `connect` as `request.launch`, which `runAcpProvider` spawns in place of the adapter `resolveAdapter` picked, and the **Adapter executable** setting would stop meaning anything. - -## The adapter stays a subprocess, and `connector:` cannot replace it - -`RunAcpProviderOptions` takes a `command` or a `connector`, and the second would run the adapter inside the plugin's own process — no PID, no lock file, no Stop button, none of `server/lock-owner.ts`. -It is not on, and the reason is not native modules: the plugin bundle's injected `require` falls through to a real `createRequire` bound to the daemon's path, so a runtime-computed absolute specifier reaches node-pty's prebuild. - -The reason is the environment. `AcpConnector` is `() => AcpStream`, called with **zero arguments** — measured — and on that branch nothing ever reads an env: only the `command` branch spawns with `{ ...process.env, ...options.env }`, and the `_meta._paseo` blob on `session/new` carries `systemPrompt`, `providerOptions`, `toolPolicy` and `persist` and no env either. -`ProviderSessionConfig.env` is where the daemon puts `PASEO_AGENT_ID`, the adapter hands it straight to the Claude PTY, and the `glab` wrapper's bot-identity swap keys on exactly that. -In process, `process.env` is the plugin worker's — one environment shared by every session, with no agent id in it — so every agent would post to GitLab as the person running the daemon. - -The blast radius is the second reason. Plugin server code runs in a forked child, so a native crash does not take the daemon down — but on child close the daemon fails every provider connection, **removes the plugin**, and closes every agent on it, with no automatic restart anywhere in its plugin runtime. -Today one wedged adapter is one wedged session with a Stop button. -`connector` is also undocumented: `public-docs/plugins/*` shows only the `command` form. -Revisit only if a future SDK gives `AcpConnector` a context argument carrying the session config. - -## The pickers are config options, and the category decides which picker - -The bridge `runAcpProvider` returns builds the whole of `ProviderConfigState` from the session's `configOptions`: `toProviderConfigState` takes the *first* option with `category: "model"` as the model picker and the first with `category: "thought_level"` as the thinking one, and everything else — an absent category included — becomes a `settings` row. -`modes` is the exception and comes only from the ACP `modes` state, so a `category: "mode"` option would be shown as a setting rather than merged into the mode picker; the adapter publishes none. -Groups are flattened and an option's `description` is dropped for both pickers, which is why the descriptions worth keeping are on the modes. - -The v1 `NewSessionResponse` this bridge parses has no `models` field at all, so an adapter answering the older way gets an empty picker and a `session.open` naming a model fails with "ACP session does not expose model configuration". -The daemon's own bridge is the other way round — it prefers `models.availableModels` and reads only the thought levels out of `configOptions` — so the adapter answers with both, and both are exercised: `server/acp-provider.test.ts` drives `runAcpProvider` as a library against the built adapter and asserts the catalogue and a session opened on a named model. - -## A tool call arrives twice, because once is not enough to draw its card - -`toolTimelineItem` in the SDK's ACP connection is the whole of that bridge's card-building: `kind === "edit" || name.includes("edit") ? edit : unknown`, over a snapshot `mergeToolCallSnapshot` has already built without the ACP `content` blocks. -The daemon's own bridge builds eight shapes out of exactly the data that is missing — `mapToolDetail` reads `kind`, the `content` blocks and the `locations` — so the plugin provider would have shipped every shell command, search and subagent log as raw JSON, which is a regression against what a claude-tty session shows today. - -None of the four `AcpTransformer` hooks closes that on its own. -`toolCall` receives a snapshot with no `content` on it — measured, `"content" in snapshot === false` — and returns another snapshot, which goes through `toolTimelineItem` anyway; `notification` never sees a `session/update`, because `routeVendorNotifications` diverts only methods the ACP SDK does not know and `session/update` is one it does. - -So the adapter sends a copy of each tool-call update over the vendor method `_claude_tty/tool_call`, `server/tool-details.ts` keeps it from a `notification` transformer, and the wrapper in the same file puts the card it describes onto the item the bridge emits for that call. -The mapping is a port of the daemon's `mapToolDetail`, so the two bridges draw the same card; the terminal content block is the one branch left out, since it needs ACP terminals the adapter does not implement. - -It is a wrapper rather than the `{ type: "timeline", item }` that hook can return, and the reason is ordering. -A vendor notification is handled where it sits in the stream, synchronously, while a `session/update` goes onto a notification lane of its own — measured with a fake in-process agent: an item returned from the hook is emitted *before* the bridge's own item for that call, and the bridge's `unknown` then overwrites it. -The copy is sent ahead of the update rather than behind it for the same reason: behind, it still arrives first, but only by the depth of that lane, which is whatever one read off the pipe happened to carry. - -## A message sent mid-turn needs `prompt.steer` offered, and then turned down - -`public-docs/plugins/providers.md` says to omit `prompt.steer` when steering is unsupported, so that Paseo can replace the active turn instead. The 0.8.0 daemon does not do that. -Every plugin session has a `steerActiveTurn`. The app's composer sends a mid-turn message with `activeTurnBehavior: "steer"`, and so does the daemon when an agent reports back to its caller, and both reach that method. -It checks the session's capabilities first and *throws* `Provider does not support prompt.steer`, so the message is never sent and no turn is replaced. -Only an *answered* steer that is not `{ type: "steer" }` for the running turn counts as `unavailable`, and `unavailable` is what makes the daemon interrupt the turn and send the message as the next one. -Upstream `main` behaves the same as of 2026-09-11. - -The bridge cannot steer either: `admitPrompt` cancels an active prompt before it forwards any new one, and the adapter refuses a second `session/prompt` while a turn is open. -So `server/steering.ts` offers the capability, on the connection and on every root `session.opened`, and answers each steer with a `failed` result without forwarding it. -Paseo's own `provider-direct` example uses the same answer when there is no turn to steer. -On a throwaway 0.8.0 daemon with a fake ACP agent, the unwrapped provider reproduced the throw, and the wrapped one produced `session/cancel` and then `session/prompt` with the message, with both user messages in the timeline. -That is what the daemon's config-file ACP providers get, since they have no `steerActiveTurn` at all. -Claude absorbing a message into the running turn, the way it does with text typed into its terminal, would need a path to the adapter that avoids the bridge's prompt admission, and there is none. - -## A tool call cancelled with the turn has to be `canceled`, not `failed` - -A `failed` tool call must carry a non-null error: `ToolCallFailedPayloadSchema` in `@getpaseo/protocol` is `error: NonNullUnknownSchema`, while the other three statuses take `error: null`. -An item that breaks that is not dropped on its own — the whole `fetch_agent_timeline_response` fails validation, so the client refuses the *entire* history of that agent, and goes on refusing it for as long as the item is in the timeline. -It shows as "Couldn't refresh agent history", with nothing logged on either side, on a session that is otherwise working perfectly. `paseo logs <agent>` reproduces it from the CLI. - -The bridge makes one on every cancelled turn. In `acp-internal/connection.js`, `terminalizeTransientItems` is handed `"canceled"` or `"completed"` and maps anything that is not `"completed"` to `"failed"`; `toolTimelineItem` then takes the error from `snapshot.output`, which a call that was still running never wrote. -So `{ status: "failed", error: null }`, from a state the bridge had the right word for and threw away — the schema's fourth status, `canceled`, takes a null error. -Paseo cancels a turn before it replaces one, and `server/steering.ts` makes that the path of every message sent mid-turn, so in a session with a subagent running this is one message away at any time. - -`server/tool-call-outcomes.ts` turns a failed tool call carrying no error back into a cancelled one. A genuine failure always carries its output as its error, so a failed call with none did not fail: it was still running when its turn ended. -Its test drives the real bridge, and the canary beside it asserts the bridge's own behaviour, so both fail the day this is fixed upstream and the wrapper can go. -The adapter had the same hole of its own: `settleOpenToolCalls` and a failed subagent card sent `tool_call_update` with no `rawOutput`, which is the same null error by the same route, and both now send one. - -## The host owns the settings, and the adapter gets a resolved copy - -The server reads the settings only through the `read()` and `subscribe()` handle `registerSettings` returns. -`subscribe()` fires on saves, resets and migrations, but not on hand edits of the file. - -The adapter is a separate process and can't hold the handle, so `server/settings-snapshot.ts` writes it a resolved copy: `{ idleTimeoutMs, autoAccept, bypassAutoAccept }`, with defaults applied and `inherit` as null. -`connect()` passes its path as `--settings-file`, and the adapter re-reads it at every suspension and permission request, so changes reach open sessions. -The file is rewritten before every connection and on every `subscribe()` event, but only the connection creates the directory, so a settings save doesn't undo **Remove state**. -There is one file per Paseo home, because daemons with different homes can run as the same user. -An invalid document keeps the last good snapshot; with no file at all, the adapter uses its own defaults with auto-accept off. - -Passing values at spawn wouldn't reach open sessions, and `runAcpProvider` takes no `env` anyway. -A `connector` could push changes instead, but it is undocumented and costs the spawn environment, as the `connector` section explains. - -The setting is global, not per session, and it is now a choice rather than the only option. -A per-session `ProviderSetting` is only ever *listed* from the ACP session's own `configOptions` — `toProviderConfigState` in the SDK's ACP connection builds `settings` from every option whose category is neither `model` nor `thought_level` — and the adapter does advertise config options since it started publishing its model and effort selectors, so the `session/set_config_option` surface that was missing is there. -What is left is the trade: an uncategorised option beside those two would put the timeout in the session's own configuration and take it out of the store Paseo owns, so it would stop surviving a reload, stop being one answer per host, and stop being deleted with the plugin. That is why it stays where it is. - -## An upgrade leaves the old provider entry behind - -Before this plugin registered a provider of its own, it wrote the adapter into the daemon configuration as `agents.providers.traecli`, and kept the idle timeout in `${XDG_CACHE_HOME:-~/.cache}/paseo-plugins/claude-tty/settings.json`. -The old timeout is not carried over, because the SDK gives plugin code no way to write the settings document; the README tells people to set it again. - -`server/upgrade.ts` reports the old entry and never removes it. -An agent started on it cannot resume once it is gone, whether those agents are finished with is not the plugin's to judge, and removing it would bring back `paseo.config.patch` for that one purpose. -`traecli` is also the real Trae CLI's ID, so only an entry whose command's basename is `claude-tty-acp` counts. -The status RPC carries it with a count of the agents still on it, from `paseo.agents.list()`, which leaves archived agents out; the count is null rather than partial when the listing runs out of budget, since a short count reads as safe to remove. -The agents are listed only while the entry exists, so the five-second poll costs one configuration read on every other host. -The app offers **Remove provider** under Settings → Providers only for a provider whose `source` is `custom` (read out of the web UI bundle), which is the old entry and never this plugin's, and that is where the panel and the README send people. +The adapter logs to `~/.local/state/claude-tty-acp/logs/claude-tty-acp.log`; Paseo drops its stderr. +A running adapter keeps the code it started with, so a rebuilt `apps/claude-tty-acp/dist/` reaches only sessions started after it. -## The cards Paseo has for a question, and the answers ACP will not carry +`pnpm identity <id>` stamps the plugin ID into `paseo-plugin.json`, `shared/identity.ts` and the adapter's `APP_NAME`; change the ID only through it, and let `pnpm identity --check` confirm they agree. -`runAcpProvider` builds every permission the same way: `kind: "tool"`, the tool call's title, its raw input, and one action per ACP option. -`description`, `detail`, `metadata`, `variant` and `intent` have nowhere to come from, and on the way back `respondToPermission` resolves the ACP request with the id of the option it matched and drops the rest of the response — `updatedInput` included. -So `server/permission-bridge.ts` wraps the connection that shim returns: it rebuilds the two permissions that ask a person something on the way out, and takes the answers off the response on the way in. -The version of ACP this SDK speaks has no field for a tool's own name, so the adapter's `toolCall.title` carries it and the bridge reports it as the permission's `name`, which is what `server/question-cards.ts` matches on. +## Contracts with the adapter -Paseo's question form is the whole reason the card is worth rebuilding, and its contract is strict (read out of the web UI the daemon serves, `dist/server/web-ui`). -It renders `request.input.questions`, and every question needs a string `question` and a string `header` and options that are objects with a string `label`; anything else makes `parseQuestionFormQuestions` return null, and the card then draws *nothing* and the permission cannot be answered there at all. -`multiSelect` picks checkboxes over radio buttons, `allowOther` adds the free-text box Claude's own schema says the host provides, an option's `description` renders under its label, and there is no surface for an option's `preview` — which is why a preview is folded into that description rather than dropped. -The form ignores `actions` entirely and keys its answers by `header`, so the headers are made distinct before they go out and the answers are read back onto the question text after, the way the daemon's own Claude provider does it. +The plugin and `apps/claude-tty-acp` share these names with no type linking them, so change both sides in one commit: the `_claude_tty/tool_call` vendor method, the `_meta["claudeTty/backgroundCommand"]` mark, the `--settings-file` and `--answers-dir` flags, the `submit` and `reply-in-chat` option IDs, the tool name carried in a permission's `toolCall.title`, and the state directory name. -Submit sends `{ behavior: "allow", updatedInput: { ...input, answers } }` and names no action, and the ACP bridge then resolves the *first* option whose behaviour matches. -That is why `submit` is first among the affirmative options and carries no answer of its own: it is what a bare allow — from that form, from `paseo permit allow`, from any client with a single Allow button — has to land on. -Dismiss sends a plain deny, which lands on `reply-in-chat` the same way. +## Provider registration -The answers themselves travel by file, because nothing on the ACP connection would carry them: the bridge writes them under the directory the adapter is passed as `--answers-dir` and *then* forwards the response, so the adapter reading none means nobody answered rather than that it read too early. -Everything else answers with an option alone: one per possible answer while a single question is on the card, and only "Answer in chat" once there are several, since no one button can answer them all. -`paseo permit ls` shows the request's name and description, which is why the description summarises every question and its options, and `paseo permit allow <agent> <id> --input '{"answers":{...}}'` answers the whole card from a terminal — verified against a 0.8.0 daemon with a throwaway plugin, which is also where the `kind: "question"` and `kind: "plan"` requests below were checked end to end. -That CLI truncates the request id it prints to eight characters, so two of these permissions are told apart by `paseo inspect <agent> --json` rather than by `permit ls`. +- Call `registerProvider` synchronously in the contribution: the daemon connects the provider milliseconds after the plugin reports ready, before any RPC hands it a `PaseoApi`. +- The server bundle has no `__dirname` and `import.meta` compiles to `{}`; the checkout is found through `$PASEO_HOME/config.json` (`server/checkout.ts`). +- Leave `command` off the registration. With one, the daemon launches its own command in place of `resolveAdapter`'s, and **Adapter executable** stops working. +- `status()` and `getCatalogCacheKey()` run on nearly every provider snapshot. Keep each to one settings `read()` and one `stat`; never spawn the adapter from them. -There is no plugin timeline renderer for any of this, and there should not be one: the host's question card already does more than a renderer here would, and it is the only path in the app that sends `updatedInput` at all. -A plan is `kind: "plan"` with `metadata.planText`, which is what the host's plan card reads first, and Paseo's own Implement and Reject actions. -There is no `implement_resume` beside them: that intent means returning to the mode planning interrupted, and the permission mode is an argument the adapter launches Claude with, so a session cannot change it. +## Session events the daemon is strict about -## A card cannot be taken back, so the plugin ends it on the daemon's side +- **Tool cards need the update twice.** The bridge draws a card from the ACP update alone as raw JSON; `server/tool-details.ts` fills it from the `_claude_tty/tool_call` copy, which the adapter sends *before* the update. Sent after, or returned as a timeline item from the hook, it is overwritten by the bridge's own item. +- **A cancelled tool call is `canceled`, never `failed` with a null error.** One `failed` item without an error makes the client refuse the agent's whole timeline ("Couldn't refresh agent history"). The bridge produces these on every cancelled turn, and `server/tool-call-outcomes.ts` repairs them; on the adapter side, every failed `tool_call_update` carries `rawOutput`. +- **Offer `prompt.steer`, then decline every steer with a `failed` result** (`server/steering.ts`). Leaving the capability off makes the daemon throw and drop a mid-turn message; a declined steer makes it cancel the turn and send the message as the next one. +- **Withdraw a card by answering it** with a plain deny through a `session.permission` input (`server/session-notices.ts`). Emitting only `session.permission_resolved` leaves it in the bridge, and the daemon resurrects it the next time any card is answered. Answer only cards still pending: an unknown one becomes `session.runtime_failed`, which the daemon reads as the session falling over. +- **The bridge's `config` update replaces the whole `ProviderConfigState`.** To move one field, such as the model Claude fell back to, send the last published state with that field changed. +- **Pickers come from `configOptions`.** The bridge takes the first option with `category: "model"` and the first with `"thought_level"`; any other option becomes a settings row. The adapter also publishes `models` for the daemon's own bridge, so keep both. -ACP permissions only ever end at the client: the agent sends `session/request_permission` and waits, nothing in the protocol withdraws one, and the bridge's own `permissions` map is cleared only when the transport closes. -That is fine for everything a hook asks, because Claude is blocked on the hook until somebody answers. -It is not fine for the cards the adapter raises for Claude's *own* terminal dialogs: Claude closes two of its nudges by itself after thirty seconds, and a prompt arriving closes whatever is open to get the keyboard back, so the card would be left standing for a question nobody is asking and nobody can make go away. +## Question and plan cards -`server/session-notices.ts` ends it where it can be ended — the daemon — by **answering** it: the wrapper sends the bridge a `session.permission` input for `permission:<toolCallId>` with a plain deny, which is the same input the daemon sends when a person answers a card. -The bridge then does all of it — takes the entry out of its pending map, resolves the ACP request, and emits `session.permission_resolved` itself. -Emitting only that event is not enough, and was measured not to be: the card left `paseo permit ls` and came back as pending the next time any card was raised, because the bridge still held it and the daemon rebuilds the pending list from what the bridge reports. -The deny lands on the first declining option, which on every card the adapter withdraws is its Dismiss. +- Paseo's question form draws nothing, and the permission can't be answered, unless every question has a string `question` and `header` and every option is an object with a string `label`. +- The form keys answers by `header`, so headers go out made unique and answers are mapped back to the question text. +- Submit sends a bare allow, which the bridge resolves to the *first* affirmative ACP option, so the adapter keeps `submit` first and gives it no answer of its own. Dismiss lands on the first declining option, `reply-in-chat`, the same way. +- Write the answers under `--answers-dir` before forwarding the response, so the adapter finding none means nobody answered. -Answering a card that is *not* pending is not harmless, which is why the cards still open are tracked in that wrapper from the bridge's own `session.permission` and `session.permission_resolved` events: `respondToPermission` throws `Unknown ACP permission`, and `send` never rejects — `emitOperationFailure` turns it into `session.runtime_failed` for the session, which the daemon reads as the whole session having fallen over. -A withdrawal and a person can still answer the same card in the same instant, so a `session.runtime_failed` carrying that message is dropped on the way out; nothing else produces one, and it means a card was answered twice rather than that anything failed. +## Subsessions -**A card left open does not merely go stale — it comes back.** -Two things in `@getpaseo/server` 0.8.0's `agent-manager.js` make that so, and together they are why the withdrawal has to reach the provider rather than only the screen. -`respondToPermission` refreshes the agent after every answered card, and `refreshSessionState` rebuilds `agent.pendingPermissions` wholesale from `session.getPendingPermissions()` — the plugin session's own map, which only `session.permission_resolved` ever empties. -And `cancelAgentRun`, which runs whenever Paseo interrupts a turn to replace it, calls `resolvePendingPermissionsForAgent`: that clears the *agent's* copy and tells the provider nothing at all. -So an interrupted turn hides a card, and the next card anybody answers brings it back — on top of whatever is on screen by then, answering a question that closed minutes ago. -A session was watched doing exactly that on 2026-09-16, resurrecting a `/model` card a prompt had closed a minute earlier. +- Add `session.subsession` to the connection's capabilities *and* to every root `session.opened`. A child whose parent lacks it, or whose parent the daemon has already closed, takes the whole connection down with every agent on it. +- Close a parent's children before passing on its `session.close`. +- A child's lifecycle is its launching tool call's. A launch that ends `canceled` decides nothing: Paseo cancels the turn on every mid-turn message while the subagent keeps running. -That is what `server/daemon-permissions.test.ts` drives, against the daemon's own `PluginAgentClientRegistry` rather than an account of it, with the canary beside it that asserts the resurrection while Paseo still behaves this way. -The daemon is not a dependency here — it is what installs this plugin, and making it one would put it in front of every `pnpm install --frozen-lockfile` an install runs — so the test resolves `@getpaseo/server` if a copy is to hand and skips with instructions if not: `PASEO_SERVER_DIST=<path to an installed @getpaseo/server> pnpm test`. +## Sessions panel -The wrapper also ends the dialog card a session already had open when the adapter raises the next one. -A session holds one of Claude's questions at a time, so a card for a new one says the old one is over, whatever became of the withdrawal that should have said so — the backstop that makes the resurrection impossible rather than merely unlikely. -The transformer and the wrapper are built by one factory because the transformer is what hears the adapter's notification and the wrapper is the only thing that can reach the connection. +- `server/sessions.ts` signals only a PID it has proved owns the lock (command path plus start time, `server/lock-owner.ts`), and only a positive one: 0 and negatives signal process groups, and the adapter shares the plugin's group. +- Coalesce concurrent stops of one session: the adapter's SIGTERM handler is `process.once`, so a second SIGTERM kills it before it releases its lock. +- The daemon kills a plugin RPC at 30 seconds and `paseo.agents.list()` waits a minute by default, so agent lookups run on a budget and never gate a mutation. +- `paseo.agents.list()` returns `{ agent, project }` entries, not agents. +- The daemon's `PATH` is not your shell's; report a spawn failure as a sentence for the panel, not a stack trace. -The model a session is on is the third thing in that file, and the third thing ACP has no word for: it carries a session's mode home over `current_mode_update` and nothing else about its configuration, so a model Claude swapped for itself — a message its safeguards flagged, retried on a fallback — has no way back to the picker. -The bridge's `config` vendor update is the route, but it replaces the whole `ProviderConfigState` rather than patching one field, and a transformer is handed no state at all. -So the wrapper keeps the last configuration the bridge published per session and the change is that snapshot with the model moved; a model the session's own catalogue does not list is ignored, since a picker set to an option it does not have is worse than a picker one switch out of date. -It is a reading rather than a decision — the adapter's launch flag is untouched, so restarting the session puts the model back to the one that was chosen. -The transformer and the wrapper are built by one factory because the transformer is what hears the adapter's notification and the wrapper is the only thing that can reach the connection. +## Settings -The model a session is on is the third thing in that file, and the third thing ACP has no word for: it carries a session's mode home over `current_mode_update` and nothing else about its configuration, so a model Claude swapped for itself — a message its safeguards flagged, retried on a fallback — has no way back to the picker. -The bridge's `config` vendor update is the route, but it replaces the whole `ProviderConfigState` rather than patching one field, and a transformer is handed no state at all. -So the wrapper keeps the last configuration the bridge published per session and the change is that snapshot with the model moved; a model the session's own catalogue does not list is ignored, since a picker set to an option it does not have is worse than a picker one switch out of date. -It is a reading rather than a decision — the adapter's launch flag is untouched, so restarting the session puts the model back to the one that was chosen. +- `subscribe()` fires on saves, resets and migrations, not on hand edits of the settings file. +- Only `connect()` creates the snapshot's directory, so a settings save cannot recreate state that **Danger zone** just deleted. -It is wrapped innermost of the four, directly around the steer fallback, so the event the bridge emits for it travels out through every wrapper above — `withPermissionCards` drops its own record of a card on exactly that event. +## Client UI -A notice is the easy half of the same file: `{ type: "notice", notice }` is a vendor update the bridge already turns into `session.notice`, which is a timeline notification rather than a message and sends no push. -It is how something that happened *to* a session gets said — a question of Claude's dismissed to deliver a prompt — and a notice missing an id or a title is dropped rather than drawn empty. - -## Constraints that are not obvious - -The daemon's `PATH` is not your shell's. -A systemd daemon typically has `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin` and nothing else, so Claude is routinely missing from it. -That is a reading the surface reports, not an error to hide, and every spawn failure has to read as a sentence rather than a stack trace. - -The plugin never builds the adapter itself; install and update do, through the manifest's `build`, a directory install leaves it to whoever owns the checkout, and a host that points `adapterExecutable` somewhere has built it however it likes. -The surface reports whether the build witness is there, and whether the executable exists and can be run, because that is what a spawn failure will otherwise say opaquely. - -A plugin session sees every provider in `providers.snapshot()`, custom and plugin-registered alike. -The daemon filters through `isProviderVisibleToClient`, which passes anything whose client declares `all_providers` or an `appVersion` of at least 0.1.45; the plugin's own client declares both, the second from the daemon version the initialize message carries. -Verified against a throwaway plugin on a 0.8.0 daemon, which saw `traecli` and its own provider in the entries. - -The daemon validates a plugin provider on its way in: the ID must match `^[a-z][a-z0-9._-]*$`, may not be a builtin or an ID the configuration already holds, and the registration needs a non-empty label and a `connect`. -The ID is stored verbatim — no namespacing by plugin. -`icon` is a path relative to the plugin directory, read and sanitised once when the plugin starts: a regular SVG file under 64 KiB, with no script, style, `foreignObject`, event-handler attribute, JavaScript URL, or `href` that does not start with `#`. Editing it takes a reload. - -Session and lock liveness is decided with signal 0 exactly the way the adapter decides it, so the two never disagree about which lock is stale. - -Stopping a session signals the process the lock names, which is one adapter process per ACP session, spawned by the plugin process rather than the daemon since the provider moved into the plugin. -`SIGTERM` is enough: the adapter's own handler closes the session, which stops the Claude PTY and releases the lock, so the persisted session and the Paseo agent both survive and the next prompt resumes them. -Never signal a process group: the adapter is spawned undetached, so `-pid` is the plugin process's own group. - -Reloading the plugin does not preserve a session, and there is no re-parenting. -The daemon stops the plugin, retires the provider, and closes every agent on it, persisting a snapshot first; the ACP shim SIGTERMs each adapter on the way out and escalates to `SIGKILL` after one second, which is where a lock can be left behind for "Release lock". -Recovery is lazy: the next thing to touch such an agent resumes it from the `{ version, data }` persistence blob under a fresh provider session ID, with `history: "replay"`, so what survives is whatever the adapter wrote into that blob — never an in-flight turn. - -## Identifying the process a lock names - -Signal 0 proves only that a PID is taken, and a PID outlives the process that earned it, so `ownsLock` has to establish who is actually behind it. - -The command line alone cannot do it. -Matching the adapter's name and its entry file as free-floating substrings passes for the `claude` child — it is handed a `--settings` path carrying the adapter's name, and is itself `node <...>/cli.js` wherever Claude Code is installed as a bundle rather than as a binary — and for any bystander whose arguments merely mention the checkout. -So the command has to match as one path, `<...>/claude-tty-acp/<...>/cli.js`, and even then it says only what kind of process this is, never *which*: a second adapter that inherited the PID looks exactly like the first. -That pattern is built from the same names `server/paths.ts` registers the adapter under, so renaming either cannot leave a guard matching the old one behind. -It lives in `server/lock-owner.ts` rather than beside the session join, because `shared/sessions.ts` is bundled into the client and `server/paths.ts` reaches for `node:os` and `node:path`, which `shared/` may not. - -The start time is the half that settles it. -Two live processes cannot share a PID, so a process that was already running when the lock was written and still holds that PID is the process that wrote it. -`/proc/<pid>/stat` field 22 against `/proc/uptime` gives it, `ps -o lstart=` gives it elsewhere, and both need slack — `ps` truncates to the second and a boot-time reading drifts against the wall clock, while PIDs take far longer than seconds to come round again. -`/proc` also reports the zombie state, which is worth its own sentence to the user: a process waiting to be reaped cannot be stopped and has not been left running. - -Identity is proved again before the `SIGKILL` escalation, because the adapter may have exited during the wait and something else may hold the PID by then. -Concurrent stops of one session are coalesced onto a single promise: the adapter registers its handler with `process.once`, so a second `SIGTERM` arriving mid-shutdown takes the default action and kills it before it can release its lock. - -## Agent titles are a courtesy and are budgeted like one - -A session row is named after the Paseo agent holding it, joined on the ACP session ID: the daemon stores it as an agent's `runtimeInfo.sessionId` and `persistence.sessionId`, and it is this plugin's file stem. -`paseo.agents.list()` answers with the daemon's `{ agent, project }` entries, and the SDK types say so: its `entries` are `FetchAgentsEntry`, which is that wrapper and not the agent itself. -A bare agent is read as well, but only as tolerance for a shape the SDK has never handed over — not because the types and the wire disagree. - -The lookup never gates a decision. -Mutations read the state directory through `readState`, which does not touch the daemon; only the payload handed back is decorated. -It is also raced against a budget and paged explicitly, because the SDK waits a minute by default while the daemon kills a plugin RPC at 30 seconds, and one page is capped at 200 agents. -A daemon that stalls or pages forever costs the titles and nothing else, which is the whole claim. - -## Opening an agent, and the one thing the screen still cannot reach - -`navigation.openAgent({ agentId })` is on `PluginScreenProps` and reveals an agent's terminal; the sessions rows use it. -It is optional in the type because a host older than 0.7 passes none, so the button is hidden rather than dead when it is absent — as it is for a session the daemon no longer lists an agent for. - -`openScreen` and `openSettings` are the other half and are **not** on a screen's props: they live on command contexts and on the client entry's context. -So the panel cannot send anyone to this plugin's own settings screen, and the Command Center item is what does. - -## A subagent is a subsession, and the daemon is strict about how one is opened - -A subagent is a loop inside its session's Claude process rather than an ACP session, so `runAcpProvider` has no way to describe one: it maps ACP session updates and nothing in that protocol carries a child session. -0.8's `session.subsession` is what makes one showable anyway, and it is negotiated in `server/subsessions.ts`, a wrapper around the connection the shim returns, the way `server/permission-bridge.ts` wraps it for the two permissions that ask a person something. -The wrapper adds the capability to what the connection reports *and* to every `session.opened` the adapter emits, because the daemon checks both and they are never allowed to disagree. - -A child is a `session.opened` carrying `parentSessionId`, and the daemon does not make it an agent: `PluginAgentClient.acceptChild` attaches it to the *root* session as a `ProviderSubagentDescriptor`, which is the same surface OpenCode's subagents use — a title, a description, a cwd, a status, and a timeline of its own, fetched over `agent.provider_subagents.*`. -There is no tab, no row in `paseo ls`, and no CLI for it at all; the app is the only client that shows one. -A child's `toolCallId` stays null whatever we do, because `acceptChild` builds its upsert from the `session.opened` alone, so nothing links the descriptor to the card in the conversation. -`restoration: "parent"` is what says the child persists nothing: closing it then removes it locally instead of sending `session.close` to a session the adapter has never heard of. -`capabilities: []` is the honest reading of a subagent — the daemon offers a child session no way to be prompted, and the agent behind it answers to the one prompt its launcher handed it. - -Two ways of getting it wrong take the **whole connection** down, and with it every agent on this provider (verified against a 0.8.0 daemon with a throwaway plugin): a child whose parent's `session.opened` did not carry the capability, and a child for a parent the daemon has already forgotten. -It forgets one as it accepts a `session.close` and again as it publishes `session.closed`, so the wrapper tracks a parent between those two points and emits nothing about it outside them — and closes that parent's children *first*, on the way past, because a child left open then would go on saying it was working for as long as the agent existed. - -What a subagent did is read off the disk, because that is the only place it is written: Claude writes a subagent's turns to `<projects>/<claude session id>/subagents/agent-<agent id>.jsonl`, never into the session's own transcript. -Which Claude session an ACP session is on is in the adapter's state file and nowhere else, and it moves under a compaction, so it is resolved on every poll rather than once. -Beside each transcript is an `agent-<agent id>.meta.json` carrying `agentType`, `description`, `spawnDepth` and — the reason it is read at all — the `toolUseId` of the call that launched it. - -That id is the whole lifecycle. **The session's own transcript is not read here at all**: a launch left open in it says only that nobody was there to hear the end of it, never that the agent is still working, which is the bug the old panel had. -The tool call in the session's conversation says it properly, because the adapter closes one when the agent reports *and* when the Claude process it ran in stops, and the wrapper sees every `timeline.item` on its way to the daemon. -So a child opens while its launch is running and closes when that launch ends *for real* — completed as a clean finish, a failure carrying the error the adapter wrote as an error, which is what a descriptor can say. -A launch cancelled with its turn is neither and decides nothing: Paseo cancels a turn before it replaces one, so every message sent while an agent runs terminalizes the launch — the bridge marks it failed with no error, repaired to `canceled` only *outside* this wrapper — while the agent runs on and the adapter reopens the card as soon as it writes. -Closing the child on that made talking to a session mark its working subagents failed, which was this wrapper's own version of the old panel's bug. -An agent whose launch has already ended when its transcript is found is history and is skipped; an agent another subagent launched names a `toolUseId` the session's conversation never mentions, so it is skipped too and stays on its spawner's card. - -The transcript is read incrementally, the way the adapter reads the session's own, because it runs to megabytes and this polls once a second per open session. -Nothing rewrites a subagent's transcript, so a rewind can only be a truncation, and the tool calls sent as running are forgotten with it; the ones still open when the agent stops are canceled rather than left running. - -A background command is a child too, and is found the other way round: nothing on disk names the call that launched it, so the adapter marks the command's card with `_meta["claudeTty/backgroundCommand"]` carrying its task id and output file, `server/tool-details.ts` keeps that mark from the mirror, and the wrapper opens a child for any running call that has one. -Its lifecycle is its card's, exactly as a subagent's is its launch's, and its timeline is one shell call whose output is the tail of that file. -The adapter learns the output file only from the sentence Claude answers the launch with, so a reworded sentence leaves the child with the command and no output rather than failing. - -## A module's directory picks its bundle - -`index.client.tsx` and `index.server.ts` are compiled separately, and the directory a module sits in decides which bundle it joins: `client/` the app's, `server/` the daemon's, `shared/` both. -Reaching across that line is a compile error rather than something the compiler quietly filters away, so the entries import only their own side, and a module left at the plugin root fails the build. -`shared/` is the strictest of the three: no Node, no React, and no runtime-specific SDK entry, which is why everything here that computes a path or reads the disk is server-side however little it does. -Each entry default-exports one contribution function returning cleanup, and RPC names must match `^[a-z][a-z0-9._-]*$`. - -## The rows are the host's; what is left is what the host has no component for - -Sections, cards and rows come from `@getpaseo/plugin/client/ui`, which the compiler keeps external and the app supplies, so they are the host's own components rather than a copy that drifts. -Two of their behaviours decide how everything here is written: `SettingsCard` draws the divider between its children itself, so nothing passes a `divided`, and `SettingsRow` renders `children` as the control at the right of the row while `label`, `hint` and `error` stack on the left. -`error` is the row's danger colour and is announced, which is why `ReadingRow` puts a bad reading there and a good one in `hint`; there is no leading slot and no way to colour a `hint`, so the tone dot moved into the control. - -What remains in `client/ui.tsx` is what the host exports no equivalent of: a bare `Button` (its own is only ever a `SettingsAction`'s), a `Disclosure` (built on `SettingsCard`, whose divider then appears exactly while it is open), a `StatusDot` and the mono font. -`client/theme.ts` is down to the metric scales the host does not hand over and the two shades its components are written against but do not expose. -Build new controls out of those tokens rather than out of literals. -Icons, `Modal`, `useToast` and `copyText` come from `@getpaseo/plugin/client/react-native`; nothing here draws its own dialog or icon. +- `SettingsCard` draws dividers between its children itself. `SettingsRow` renders `children` as the control on the right, with `label`, `hint` and `error` stacked on the left; `error` is the danger colour. +- A screen's props have no `openSettings` or `openScreen`; the Command Center item is how the panel reaches the settings screen. +- Build controls from `client/theme.ts` tokens and `client/ui.tsx`; icons, `Modal`, `useToast` and `copyText` come from `@getpaseo/plugin/client/react-native`. ## Tests -`pnpm test` is `node --test "{client,server,shared}/**/*.test.ts"` through Node's type stripping, so no TypeScript that has to be emitted and relative imports keep their `.ts` extension. -A test that resolves the plugin root walks up from `import.meta.dirname`, so it counts the directory it sits in and no `src/` above it. -`@getpaseo/client` stays on the version `@getpaseo/plugin` takes as a peer. - -`server/acp-provider.test.ts` is the one exception to all of that: it runs the adapter's own `tsc` build and then spawns the result, because the bridge it exercises takes a command rather than a module, and a stale `dist/` would otherwise decide the result. -It points the adapter at a throwaway state directory so the run touches none of yours. - -## Auto Accept lives in the adapter - -The daemon gives its own ACP providers an `auto_accept` feature and answers their permission requests itself; a plugin provider gets neither. -`ProviderSetting` also has no `icon` or `tooltip`, and the SDK's schema strips both, so the toggle shows with the app's generic settings icon. -So the adapter publishes the toggle as a boolean ACP config option, which `runAcpProvider` maps to a toggle setting and the daemon to an agent feature, and answers the `PermissionRequest` hook itself. -`paseo run` can set a mode but not a feature, and the daemon defaults nothing for a plugin provider's unattended create, which is why a session's starting value comes from the host settings rather than from whoever created it. +- Tests run through Node's type stripping: relative imports keep their `.ts` extension, and nothing may need emitted TypeScript. +- `server/acp-provider.test.ts` builds the adapter and spawns it. +- `server/daemon-permissions.test.ts` runs against a real `@getpaseo/server` and skips without one: `PASEO_SERVER_DIST=<installed @getpaseo/server> pnpm test`. +- The canary tests assert upstream bugs. When one fails because Paseo fixed the bug, delete the workaround it guards. +- Keep `@getpaseo/client` on the version `@getpaseo/plugin` takes as a peer. diff --git a/plugins/claude-tty/docs/adr/0004-the-adapter-stays-a-subprocess.md b/plugins/claude-tty/docs/adr/0004-the-adapter-stays-a-subprocess.md new file mode 100644 index 0000000..0c8f882 --- /dev/null +++ b/plugins/claude-tty/docs/adr/0004-the-adapter-stays-a-subprocess.md @@ -0,0 +1,6 @@ +# The adapter stays a subprocess, not an in-process connector + +`runAcpProvider` takes either a `command` or a `connector`, and the plugin uses `command`, so each ACP session runs in its own adapter process. +A `connector` runs in the plugin's process and is called with no arguments, so every session would share the plugin worker's environment and lose the per-agent `PASEO_AGENT_ID` the daemon passes to a spawned process, which tools inside Claude key on (such as a `glab` wrapper that posts as a bot per agent). +A native crash in process would also take down the plugin, and the daemon then closes every agent on the provider with no automatic restart, whereas a wedged subprocess is one session with a **Stop** button. +Revisit if the SDK passes the session's configuration, including its environment, to the connector. diff --git a/plugins/claude-tty/docs/adr/0005-tool-call-details-travel-over-a-vendor-method.md b/plugins/claude-tty/docs/adr/0005-tool-call-details-travel-over-a-vendor-method.md new file mode 100644 index 0000000..866f9c4 --- /dev/null +++ b/plugins/claude-tty/docs/adr/0005-tool-call-details-travel-over-a-vendor-method.md @@ -0,0 +1,6 @@ +# Tool-call details travel over a vendor method + +The adapter sends a copy of every tool-call update over the vendor method `_claude_tty/tool_call`, ahead of the ACP `session/update`, and the plugin uses it to draw the card for that call. +The plugin SDK's ACP bridge builds tool cards without the ACP `content` blocks, so without the copy every shell command, search and subagent log would show as raw JSON, and none of the bridge's transformer hooks sees a `session/update` with its content. +The card mapping in `server/tool-details.ts` is a port of the daemon's own `mapToolDetail`, so it has to follow the daemon when that changes. +Drop the mirror once the SDK's bridge builds cards from the content blocks itself. diff --git a/plugins/claude-tty/docs/adr/0006-questions-use-paseos-own-permission-cards.md b/plugins/claude-tty/docs/adr/0006-questions-use-paseos-own-permission-cards.md new file mode 100644 index 0000000..ec2e37d --- /dev/null +++ b/plugins/claude-tty/docs/adr/0006-questions-use-paseos-own-permission-cards.md @@ -0,0 +1,5 @@ +# Questions and plans use Paseo's own permission cards + +Claude's questions and plans are rebuilt as `kind: "question"` and `kind: "plan"` permissions that Paseo's own question form and plan card draw, rather than drawn by a timeline renderer of the plugin's own. +The host's question form already handles several questions, multi-select and free-text answers, and it is the only path in the app that returns `updatedInput`. +ACP carries only the chosen option back to the adapter, so the plugin writes the answers to a file under the adapter's `--answers-dir` before forwarding the response, and the adapter reads them from there. diff --git a/plugins/claude-tty/scripts/set-identity.mjs b/plugins/claude-tty/scripts/set-identity.mjs index 418b644..b8d27c2 100644 --- a/plugins/claude-tty/scripts/set-identity.mjs +++ b/plugins/claude-tty/scripts/set-identity.mjs @@ -39,7 +39,7 @@ const ADAPTER_CONSTANTS = path.join(REPO_ROOT, "apps", "claude-tty-acp", "src", const BASE_ID = "claude-tty"; const BASE_LABEL = "Claude TTY"; -/** The daemon's own rule for a plugin ID, quoted in plugins/claude-tty/CLAUDE.md. */ +/** The daemon's own rule for a plugin ID, which it also applies to provider IDs. */ const ID_PATTERN = /^[a-z][a-z0-9._-]*$/; /** What the adapter's state directory is called for a given ID; shared/identity.ts computes the same. */ diff --git a/plugins/discord-rich-presence/CLAUDE.md b/plugins/discord-rich-presence/CLAUDE.md index f3341b1..8e50007 100644 --- a/plugins/discord-rich-presence/CLAUDE.md +++ b/plugins/discord-rich-presence/CLAUDE.md @@ -1,54 +1,29 @@ # Working on this plugin -Run `paseo plugin reload discord-rich-presence` after every change, then `paseo plugin logs discord-rich-presence`. -The reload is the only compile check of the two bundles the daemon builds. -Do this yourself; never leave it to the user. +Run `paseo plugin reload discord-rich-presence` after every change, then read `paseo plugin logs discord-rich-presence`. +The reload is the only compile check of the two bundles, so run it yourself rather than handing it to the user. +If a reload hangs on `Stopping plugin` until the 60s timeout, kill the lingering `plugin-process.js` that is still connected to the daemon port. -The plugin process holds its own daemon connection, which the contribution's cleanup closes. -If a reload hangs on `Stopping plugin` until the 60s timeout, kill the lingering `plugin-process.js` connected to the daemon port. +The server holds its own websocket to the daemon in `server/daemon.ts`; `docs/adr/0001-own-daemon-connection.md` says why. -## Constraints that are not obvious +New UI is built from the tokens in `client/theme.ts` and the controls in `client/ui.tsx`, which mirror Paseo's own controls in `packages/app/src/components/ui`. +`client/preview.tsx` uses Discord's colours on purpose, because it shows what Discord will show. -`paseo` only ever arrives on a context: an RPC handler's second argument, or a lifecycle hook's. -Every lifecycle event is agent or workspace activity, and there is no event for the daemon starting, so nothing exists until a client calls in or someone does something. -A presence that must be live before either therefore keeps its own daemon connection (`server/daemon.ts`), started from `contribute` in `index.server.ts`. -A client id beginning with `plugin:` is routed to the daemon's internal plugin session and hangs forever with no error, so the connection is not named after the plugin id. +## Traps -No CommonJS dependency survives the daemon's compiler: `makeHermesInteropEager` rewrites esbuild's lazy interop getters into eager reads, so anything built by tsup or esbuild exports `undefined` and fails as `class extends value undefined`. -Pre-bundling does not help. That is why the Discord IPC client is written out in `server/ipc.ts`. +**The daemon client id.** The daemon routes any client id starting with `plugin:` to its internal plugin session, and the websocket handshake then hangs forever without an error. +Keep `DAEMON_CLIENT_ID` free of that prefix. -`workspace.activityAt` is null in practice, so the daemon's `activity_at` sort is not a ranking and the model ranks workspaces itself. -A workspace's `name` is often a title an agent generated, which is what the detail levels are for. -`agents.list({ scope: "active" })` still returns closed sessions. -The focused workspace is unreadable: the app heartbeats it, but the daemon keeps it private to the session that sent it. +**CommonJS dependencies.** The daemon's compiler rewrites esbuild's lazy CommonJS interop getters into eager reads (`makeHermesInteropEager`). +A CommonJS package, including anything built by tsup or esbuild, then exports `undefined` and fails at load with `class extends value undefined`; pre-bundling it does not help. +Use an ESM package, or write the code out yourself, as `server/ipc.ts` does for Discord's IPC protocol. -## Settings +**Daemon data.** Three things the daemon's API does not tell you: +- `workspace.activityAt` arrives as null, so the daemon's `activity_at` sort ranks nothing and `rankWorkspaces` does the ranking. +- `agents.list({ scope: "active" })` still returns closed and archived sessions. +- The workspace focused in the app cannot be read: the daemon keeps the app's heartbeat private to the session that sent it. -`shared/settings.ts` defines the settings document; every field has a default. -The schema is strict, so a bad document reads as `invalid` as a whole; `server/followed-settings.ts` decides what the service does with that. -The server can only read the settings. -The settings screen saves through `useSettings`; Command Center items have no React tree and save through `client/settings-writes.ts`. +**New settings fields.** Give every field added to `shared/settings.ts` a `.default()`. +Without one, every document saved before the field existed reads as `invalid`: the service keeps its previous settings and the panel shows an error instead of the settings. -## The panel is styled off paseo's own scale - -The host hands the client colours but no metrics, so `client/theme.ts` restates paseo's spacing, type, and radius scale, and fills in only the shades above `surface2` that the host keeps to itself. -Build new controls out of those tokens and out of `client/ui.tsx`, which mirrors the geometry of paseo's own controls in `packages/app/src/components/ui`. -Icons come from `@getpaseo/plugin/client/react-native`, by Lucide name; nothing here draws its own. -The preview keeps Discord's colours instead, because it stands for what the other side sees rather than for the theme paseo is wearing. -`client/artwork.ts` is generated by `scripts/render-assets.sh`, not edited by hand. - -## A module's directory picks its bundle - -`index.client.tsx` and `index.server.ts` are compiled separately, and the directory a module sits in decides which bundle it joins: `client/` the app's, `server/` the daemon's, `shared/` both. -Reaching across that line is a compile error rather than something the compiler quietly filters away, so the entries import only their own side, and a module left at the plugin root fails the build. -`shared/` is the strictest of the three: no Node, no React, and no runtime-specific SDK entry, because both bundles have to be able to hold it. -Each entry default-exports one contribution function returning cleanup, and RPC names must match `^[a-z][a-z0-9._-]*$`. - -## Tests - -`pnpm test` is `node --test "{client,server,shared}/**/*.test.ts"` through Node's type stripping, so no TypeScript that has to be emitted and relative imports keep their `.ts` extension. -`shared/fixtures/` holds real daemon payloads with the paths scrubbed, read relative to `import.meta.dirname`. - -`paseo plugin install` is a silent no-op against this daemon for any directory, so the plugin is registered by hand in `~/.paseo/config.json` and picked up on the next daemon start. -To exercise a change without restarting the daemon, compile `index.server.ts` with the daemon's own `compilePlugin` and evaluate the bundle the way `plugin-process.js` does. -Point `PASEO_HOME` at a throwaway daemon, or you will change the real settings. +**Tests run on Node's type stripping.** Keep the `.ts` extension on relative imports, and write erasable TypeScript in any module a test imports. diff --git a/plugins/discord-rich-presence/docs/adr/0001-own-daemon-connection.md b/plugins/discord-rich-presence/docs/adr/0001-own-daemon-connection.md new file mode 100644 index 0000000..282bcbd --- /dev/null +++ b/plugins/discord-rich-presence/docs/adr/0001-own-daemon-connection.md @@ -0,0 +1,10 @@ +# The server keeps its own connection to the daemon + +A plugin server only gets a `paseo` handle inside an RPC call or a lifecycle hook, and no hook fires when the daemon starts. +Waiting for one would leave Discord blank until someone opens the panel or an agent does something. +So `server/daemon.ts` opens its own websocket from `contribute`, the way the CLI does, and subscribes to workspace and agent updates. + +## Consequences + +- The plugin resolves the daemon's address and password itself, and cannot dial a daemon that listens on a Unix socket or named pipe. +- The connection must close in the contribution's cleanup, or a reload waits on it. diff --git a/plugins/guided-review/CLAUDE.md b/plugins/guided-review/CLAUDE.md index 8633159..b314dd8 100644 --- a/plugins/guided-review/CLAUDE.md +++ b/plugins/guided-review/CLAUDE.md @@ -1,156 +1,57 @@ # Working on this plugin Run `paseo plugin reload guided-review` after every change, then `paseo plugin logs guided-review`. -The reload is the only compile check of the two bundles the daemon builds. -Do this yourself; never leave it to the user. -Where a reload is not wanted, the daemon's own compiler answers the same question: import `compilePlugin` from `@getpaseo/server/dist/server/server/plugins/compiler.js` (inside the CLI's `node_modules`) and run it over the two entries. +The reload is the only compile check of the two bundles the daemon builds, so do it yourself rather than leaving it to the user. -## The shape later work builds on +## Orientation -`server/review-service.ts` is the top level: every RPC in `shared/contracts.ts` is one of its methods, and `index.server.ts` does nothing but wire them. -The service keeps what an RPC decides (which review, which forge, whether the guide agent can take a prompt, which verdicts are on offer, the branch flow and "Suggest wording" jobs) and hands the larger work to the modules it composes, each built over the same store and ports: +- `server/review-service.ts` is the top level: each RPC in `shared/contracts.ts` is one of its methods, and `index.server.ts` only wires them. + A new RPC goes in `shared/contracts.ts` and becomes a service method. +- The service reaches the outside only through ports: `server/forge/port.ts` (GitHub over `gh`, GitLab over `glab`), `server/workspaces/port.ts`, `server/guide-agent/port.ts`, and `server/command-runner.ts`, which every `gh`, `glab` and `git` call goes through. + A new forge operation goes on the port first, then on each adapter. +- The fakes (`server/fake-*.ts`) sit beside the modules so every test shares them; keep them out of `index.server.ts`'s imports so they stay out of the bundle. +- `pnpm test` runs `node --test` with Node's type stripping: write only erasable TypeScript (no enums, namespaces or parameter properties) and keep the `.ts` extension on relative imports. -- `server/review-preparation.ts`, `ReviewPreparation`: the start and Regenerate job, reading the change request and giving the review its workspace (fast-forwarding a branch, reusing or creating a PR workspace, cloning), then recording it. -- `server/guide-generation.ts`, `GuideGenerations`: generating a guide per head as a background job, picking one up after a restart, carrying marks over, archiving the agents of guides that end, and `shown`, the guide the panel shows. -- `server/review-drafts.ts`, `ReviewDrafts`: drafts, their links to nodes or the overview, GitHub's general-comment paragraphs and the review body, and a submit's or discard's effect on them. -- `server/review-inbox.ts`, `ReviewInbox`: the start screen's review list, every forge's `listReviewRequests` joined with the reviews in the store and the check-offs. -- `server/one-at-a-time.ts`, `oneAtATimePer`: the per-key queue that writes of marks, drafts and the body, and GitHub's pending review, go through. +## Traps -It reaches the outside only through its ports, so its tests replace every one of them: +### Paseo -- `server/forge/port.ts` is the Forge port. `server/forge/github.ts` implements it over `gh` and `server/forge/gitlab.ts` over `glab`, both through `server/forge/cli.ts`, which turns a failed call into a `ForgeError` whose message is shown as it is, and both take their read caps, clone timeout and ghost author from `server/forge/common.ts`. -- `server/workspaces/port.ts` is the workspace port. `server/workspaces/paseo.ts` implements it over the SDK, and `server/workspaces/checkout.ts`, the local checkout module, does the git behind `inspect` and `fastForward`. -- `server/command-runner.ts` is the one seam every `gh`, `glab` and `git` call goes through, and `server/fake-command-runner.ts` replays recorded output through it. -- `server/guide-agent/port.ts` is the guide agent port, which moves text only: `create` starts the agent on a prompt, `reply` waits for its answer, `run` and `send` prompt it again (for "Suggest wording", "Ask about this" and "Ask agent"), `status` says whether it is idle. `server/guide-agent/paseo.ts` implements it over the SDK. Structured output is `server/guide-agent/structured.ts`: `withOutputSchema` puts the JSON Schema in the prompt, `parseReply` finds the JSON in the reply and validates it with zod, and `runStructured` does both for a `{ body }`-style request. `server/guide-output.ts` adds the guide's own checks, which is where layering and coverage go. +- Paseo cuts a plugin RPC off after 30 seconds without stopping its handler. + Anything that can take longer (reading a PR, cloning, creating a workspace, a guide agent's turn) runs as a background job the client polls. +- `paseo` arrives only on a handler's context; `index.server.ts` keeps the first one for jobs and hooks that outlive their RPC. +- `client.openPanel` throws until the app has the workspace cached, which a new workspace reaches a moment later: open panels through `openPanelWhenReady` in `client/open-panel.ts`. +- Import `@getpaseo/highlight` only on the server: its index pulls in every grammar. + The client gets syntax tokens and colours over RPC. -A new forge operation goes on the port first, then on each adapter; a new RPC goes in `shared/contracts.ts` and becomes a service method. -The fakes (`server/fake-*.ts`) sit beside the modules rather than in tests so every test shares them, and nothing in `index.server.ts` imports them, so they never reach the bundle. -On the client, `client/theme.ts` holds Paseo's design scale and the one `Colors` type, and `client/button.tsx` the one `Button` (`primary` for the step's main action, `small` among small text). +### Guide agent -## Constraints that are not obvious +- Paseo passes an output schema only with an agent's first prompt, and only some providers enforce it. + Send every structured request through `server/guide-agent/structured.ts`, which puts the schema in the prompt and validates the reply here. +- Sending to a busy agent interrupts its turn: check `status` before `run` or `send`. +- The permission hook denies every command `readOnlyCommand` (`server/guide-agent/read-only-command.ts`) does not recognise. + A command you add to a prompt has to pass it, or the agent is denied it. +- The `git diff` options in `server/guide-prompt.ts` are pinned so the agent's hunks match the forge's patches, which `splitHunks` in `server/diff.ts` numbers (GitHub joins changes one line apart into one hunk, GitLab does not). + Keep them matching the forge, whatever the host's git config says. -A Command Center item cannot take text, so starting from a URL is the `start` screen, which the sidebar header item opens. -A screen is not handed the client context, so `createStartScreen` takes the panel opener through its closure, and `client/open-panel.ts` retries it: `client.openPanel` throws until the app's cache has the workspace, which a just-created one reaches a moment later. -The panel takes it the same way, so a move to another workspace (Regenerate's, "Open the PR workspace") opens the panel there rather than just the workspace. -`client.openPanel` always navigates to the workspace, so a tab cannot be added in the background, and Paseo restores a workspace with the tabs it last had and offers plugins no hook when one opens. A review started with Review, which does not navigate, therefore has no panel tab until the reviewer opens it, so `client/review-buttons.ts` keeps a header button that opens the panel on every workspace `getReviewWorkspaces` lists, polling it for reviews started since. +### GitHub -Paseo cuts every plugin RPC off after 30 seconds and does not stop the handler. -Reading a PR, cloning and `workspaces.create` can all take longer, so `start` returns a review ID at once and the work runs as a background job, which the panel follows through `startProgress` and the review list through `getLocalReviews`. -Starts run side by side, but `ReviewPreparation` cuts workspaces one repository at a time (`#perRepository`), since two starts of an uncloned repository would otherwise clone over each other. -A job reads as ready only once `GuideGenerations.state` has been asked, so whatever follows it never sees a ready review with no guide and stops following too early. -`paseo` only arrives on a handler's context; it is one connection for the whole process, so `index.server.ts` keeps the first one for the jobs that outlive their RPC. +- GitHub refuses to edit a pending review's body while it is empty, which it is on every review started without one. + The review body lives in `review-body.json` until submit and goes out only with the submit. +- Send GraphQL and JSON bodies on stdin with `--input -`: `gh api -F` turns a repository named `123` into a number. -A `change_request` workspace is a worktree of an existing clone, and the daemon fetches the PR from that clone's `origin`. -So the clone is found by asking each git project for `git remote get-url origin` (a project carries no remote), and when none matches the repository is cloned with `gh repo clone` or `glab repo clone` into the data directory, which then becomes a Paseo project of its own the first time a workspace is cut from it. +### GitLab -The guide prompt carries only what the forge alone knows (the description, linked issues, base and head SHAs) and the commands that read the rest: the agent reads the commits, the file list and the diffs with git in the review's workspace, so the prompt stays the same size however large the change. -Paseo passes an output schema only with a new agent's first prompt, and only Codex and OpenCode enforce it, so every structured request carries the schema in its prompt and the reply is parsed here, whatever the provider. -There is no generic read-only flag either: `readOnlyMode` picks Claude's `plan` or Codex's `read-only` (valid though unadvertised), and the `agent.permission_requested` hook denies a guide agent's edits, writes, commands and mode changes, recognising it by its `guided-review.review` label because the hook's agent carries no labels and an in-memory list would not survive a reload. -Hooks are best-effort, so `reply` answers whatever is pending itself when `waitForFinish` comes back with `status: "permission"`: in plan mode Claude tends to end by asking to leave it, which is denied with a request to answer as a normal message. -`answerPermission` in `server/guide-agent/paseo.ts` is the one policy: the hook denies only what changes something and leaves the rest to the reviewer, while `reply`, which nobody watches (generation and "Suggest wording"), also allows `read` and `search` tool requests and read-only commands and denies the rest, so an unattended run cannot stall on a prompt. -A command counts as changing something unless `readOnlyCommand` in `server/guide-agent/read-only-command.ts` recognises it: an allowlist of programs and `git` subcommands, split on pipes and lists, refusing redirects to files, substitutions and the options that write or run another program; a program with such options takes no unquoted glob, since a file the change adds could be named like one. -A plugin's ACP provider, claude-tty among them, sends a permission request with no `detail`, its name the tool's title (`Bash: Show the diff`) and its `input` the tool's own, so `commandOf` takes the command from `input.command` there, and a tool judged by its name is judged by the part before the colon. -Sending to a busy agent interrupts its turn (`PaseoAgentSendOptions` has no `activeTurnBehavior`), so `run` and `send` refuse unless the agent is idle. +- `glab` sends its token to any `--hostname` it is given. + Check a host with `glab auth status --hostname` before the first call there, and pass `--hostname` on every call, since `glab` otherwise picks a host from the daemon's working directory. +- Address a project by its numeric ID: some GitLab versions refuse a URL-encoded path on the draft notes endpoints. +- Write draft notes as a JSON body on stdin with `--header 'Content-Type: application/json'`; without it GitLab reads a form and drops `position`. +- GitLab may store a draft's position other than the one sent, without an error, and a `PUT` replaces the position with whatever it is given. + `createDraft` compares and deletes a misplaced draft, and `updateDraft` sends the stored position back. +- Read `glab api --paginate` with `--output ndjson` (`Cli.ndjson`): it prints each page as a separate JSON array. +- `glab auth status --all` exits 1 when any host fails; read it with `Cli.report`. +- Run `glab` with `PASEO_AGENT_ID` and `GITLAB_BOT_IDENTITY` unset (`GLAB_UNSET_ENV`), so a wrapper that switches to a bot account for agents still acts as the reviewer. -The guide is generated as a background job keyed by review and head SHA, and `server/review-store.ts` keeps a record per head SHA from the moment the agent is asked, with its agent ID. A panel read that finds a `generating` record with no job behind it waits on the same agent again, which is how a generation survives a plugin reload. A guide belongs to the workspace it was generated in; a new workspace for the review gets a new guide and agent. +### Data -`GuideSchema` in `shared/guide.ts` is what the agent writes; `LayeredGuideSchema` is what the store keeps and the panel shows, and only `layOutGuide` in `server/guide-output.ts` turns one into the other. -A node says why it exists in `why` and what it does in `behaviour`, a fact per entry, because reviewers read the guide instead of the diff; `ReviewStore.getGuide` reads a node stored with a single `explanation` as its `why` with no `behaviour`. -A decision's `alternative` carries a `quote` of the author's words, and `keepQuotedDecisions` in `server/guide-output.ts` sets it to null unless the quote is in the change request's title, description, commits or linked issues, or an added line of the forge's diffs of the files the agent places; a guide stored before then has no `alternative` at all, so readers test it rather than compare with null. -It also drops a node's decision left with no alternative, and an overview decision whose quote a node's decision has, which is the only place those rules are enforced: the schema lets a node decision's alternative be null, since nothing is retried and one unquoted decision should not fail a whole guide. -A dependency may point only at a node listed earlier, which `parseGuide` enforces and which is what makes the nodes a DAG without a cycle check; the agent never writes a layer. -Layers are longest-path from the foundations, capped at `MAX_LAYERS`: a deeper chain stays in the last layer, still in the agent's order. - -A node names its code in `covers` (`shared/guide.ts`): a path, hunk numbers, or line ranges in the new file (the old one for a removed file). `covers` is the only place a node's files come from; `coveredPaths` derives the file list wherever one is wanted (coverage, the panel's Files list, the nodes a whole-file comment or question falls in). The numbers count the `@@` headers of a file's diff as the agent reads it with the `git diff` command `guidePrompt` in `server/guide-prompt.ts` gives it, whose options are pinned so the host's git config cannot cut hunks differently from the forge (GitHub joins changes one line apart into one hunk, `INTER_HUNK_CONTEXT`, where GitLab and git do not); `splitHunks` in `server/diff.ts` cuts the forge's patches the same way for the parser. -`server/diff.ts` parses patches into `shared/diff.ts`'s model and `resolveCode` cuts a file down to what a node covers, which `parseGuide` uses to reject references the diff lacks and the `node-diff` RPC to return a subject's hunks, parsed afresh from the snapshot at the guide's head. The RPC takes the same `GuideSubject` as "Ask about this": a node gives its `covers`, a file (a Supporting or Unsorted entry) what no node covers of it, `entryCode` in `server/diff.ts`. -Every line keeps its kind, its old and new numbers, and GitLab's running counters `oldPos`/`newPos` (a `line_code` is `sha1(path)_oldPos_newPos`, and a new file's old counter is 0, as GitLab's parser has it), so a draft can be anchored from the line alone. A partly covered hunk comes back as one entry per unbroken run of lines, with `complete: false` and a header recomputed for the run; its lines still lie inside the forge's hunk, which a GitHub range needs. -`node-diff` also gives each line the syntax tokens of Paseo's own highlighter, `@getpaseo/highlight`, through `DiffHighlighter` in `server/diff-highlight.ts`, which ports Paseo's server `diff-highlighter.ts`: each side of a file is highlighted whole, from the file at the base or head (`WorkspacePort.fileAt`, `git show` in the review's workspace) when its lines are the diff's, else from the text the hunks give. The tokens are made on the server because the package's index pulls every grammar into whatever imports it, so the client never imports it: `syntax-colors` hands it the dark and light palettes of the syntax theme the `syntaxTheme` setting names, and `client/diff-view.tsx` picks one by `isDark(surface0)`, since the host's theme gives plugins no syntax colours and its syntax theme setting is not theirs to read; `syntax-themes` lists the themes for the setting. The dependency is why the manifest has a `build`. - -Coverage sorts what no node covers, since several nodes may cover different hunks of one file, and nothing may go unshown: the lockfiles and generated files `server/file-classes.ts` set aside before the prompt was built go to Supporting, and `parseGuide` drops any cover naming one (the prompt lists them for the agent to skip, so covers are checked only against the rest, and a node left with none is an error); a file the nodes cover every change of is placed; any other file, whole or with the rest `uncoveredCode` leaves of it (each run of uncovered lines that holds an added or removed line with more than whitespace on it, so the blank lines between a node's ranges place nothing), goes to the agent's Supporting entry for it, else Unsorted. A Supporting or Unsorted entry is still just a path: `entryCode` recomputes its partial diff from the guide's nodes wherever it is wanted (`node-diff`, a whole-file comment subject, whose prompts name the lines its entry holds, and carry-over, which compares what the entry shows), and marks and drafts need nothing more, since marks are by path and drafts anchor on the snapshot. `parseGuide` normalises paths before checking them, and reads a cover of a file whose diff the forge withheld as the whole file, since the agent can only have read its lines in the checkout; nothing is retried. -The panel's Tests and Documentation groups are Supporting's `test` and `docs` entries, split off only where the guide is shown and tallied (`splitSupporting` in `shared/guide.ts`), so the stored guide, marks, carry-over and "Ask about this" still treat them as Supporting entries. - -"Ask agent" is the new comment box's other way out: `askQuestion` takes the box's `CommentSubject`, as "Suggest wording" does, with the typed text as the reviewer's question, and sends the prompt from `questionPrompt` in `server/question-prompt.ts` through `#sendToGuideAgent`, which checks `status` before `send` so a busy or gone agent gets its own message rather than the port's error; the panel then closes the box and opens the agent's chat, so a host without `navigation.openAgent` is not offered it. -"Ask about this" is `AskAction` in `client/ask-action.tsx`, the one control every node and entry renders: it opens a question-only `CommentBox` (an `ask` `OpenBox`) whose "Ask agent" sends through `askQuestion`, a node as its node subject and a Supporting or Unsorted entry as its file as a whole. A whole-file `CodeSubjectContext` carries the file's `FilePlacement`, its Supporting category or Unsorted and, for a file some nodes cover part of, the head-side lines its entry holds, as `codeReferencesOf` its `entryCode`, so its question and wording prompts say where the guide put it. -"Guide this branch's PR/MR" starts from a workspace, not a URL: `startBranch` reads the branch and `origin` with git (Paseo's `gitRuntime` is a cache that may be missing, and the client snapshot has no branch at all), asks each forge's `findByBranch`, and hands the one change request found, or the one chosen, to the same job as a URL, with the workspace as `own`. -The command item cannot show anything, so it calls the RPC and opens the panel, which follows the start through `getPanel`'s `none.branch` and polls an empty panel now and then for a start made from outside it. -A workspace the guide is attached to is recorded with its `branch`, which is how a later start, from the URL too, knows to fast-forward it again rather than make a PR workspace; a branch left alone gives the record a `note` the panel shows in one line. -The fast-forward fetches the change request's own ref (`refs/pull/<n>/head`, `refs/merge-requests/<iid>/head`) into `FETCH_HEAD` only, so fork PRs work and no remote-tracking branch moves; untracked files count as dirty, and a branch ahead of the head counts as diverged, since either way the agent would not read the head. -Whatever the workspace, `WorkspacePort.fetchCommits` makes sure its repository has the base and head commits before a guide agent is created, since the agent diffs them itself: `fetchMissing` in `server/workspaces/checkout.ts` fetches a missing one the same way, the base from its branch, and a guide fails when one cannot be had. - -"Understood" marks take the same subjects as "Ask about this". `ReviewStore` keeps them as a `ProgressRecord` under `progress/<head SHA>.json`: node IDs, which mean something only within their guide, and Supporting and Unsorted paths, which mean the same in any guide of the review, so carrying marks to a later head can match nodes by their hunks and entries by path. The record names the guide agent it was made with, and marks made with another agent count for nothing, because a guide generated again at the same head (a retry, a new workspace) overwrites the old one under the same SHA with IDs of its own. `summariseProgress` in `shared/progress.ts` drops marks the guide has no node or entry for and tallies the rest per layer, Supporting, Unsorted and overall. `getProgress` (the service's `readingProgress`) is its own RPC rather than part of `getPanel`, and returns null while a guide is not ready or is being generated again. `setUnderstood` takes a list of subjects, one card's or a whole group's from its heading, and refuses them all when one is not in the guide; it writes one guide's marks at a time, so toggles sent together all land. The client hands its `ProgressControl` down through `ProgressContext`, so `UnderstoodToggle` and `ProgressSummary` need no props threaded through the tree. - -Drafts live only on the forge, and every listing reads it. `shared/drafts.ts` holds the forge-neutral `DraftLocation` both directions use: a line as a `LineRef` (the old side for a removed line, the new side for any other, which is how both forges read a comment back), a range of one hunk, a file, or `general`, the change as a whole, which is what a comment on a node or on the overview is. The panel sends a location with the `headSha` of the guide it drew, and a head Regenerate has since moved the review off is refused, since the new head numbers its lines differently; `anchorAt` in `server/anchors.ts` looks the lines up in the snapshot at the review's head and hands the port a `DraftAnchor` whose lines carry kind, numbers and GitLab's counters, so an adapter only translates. A range across hunks is refused there, since GitHub cannot take one, and its ends are put in the diff's order. -On GitHub each draft is a thread on the viewer's pending review, built with GraphQL: `pendingReview` finds the viewer's (github.com's own included) or starts one with no `event` on the snapshot's head, and the adapter runs one create at a time per PR so two saves cannot start two. A draft's ID is its comment's node ID (`PRRC_…`), which update and delete take. A comment carries no side, only its thread does, so `listDrafts` pages through `reviewThreads` and keeps the comments whose review is the pending one, replies to published threads included; an outdated thread reads by its original line. The GitHub draft answers under `fixtures/github/` (`pending-review*`, `start-review`, `drafts-*`) are built by hand from the schema, since recording them would write to a real PR. -What the plugin keeps about drafts is `ReviewStore`'s `drafts.json` (`DraftsRecord`), never posted: where each draft was written from, a `DraftLink` keyed by the draft's ID, and GitHub's general comments. `createDraft` takes `from`, a `CommentOrigin` (`shared/drafts.ts`) of the guide at the head the panel drew: a node (its own comment, or one on code drawn inside it, which `CommentNodeContext` in `client/drafts.tsx` supplies) or the overview, with the `quote` the reviewer highlighted there, if any. The link names that guide's head and agent, as marks do, and `listDrafts` returns `LinkedDraft`s whose `from` is followed to the guide the panel shows: the overview stays the overview, and a node goes through `followNode` in `server/carry-over.ts` (the node covering the same code) when the link was made in an earlier guide, else null. The quote stays with the draft, `earlier` once the guide it was highlighted in is not the one shown. `ReviewStore.getDrafts` still reads links written as a bare `nodeId`. A general comment on GitLab is a draft note with no position (`Forge.takesGeneralDrafts`); GitHub has no draft on the whole PR, so it is a paragraph (`server/review-body.ts`) kept in `drafts.json` until a submit posts it through `Forge.postComment` as a PR comment of its own, an issue comment, before the review goes out with the reviewer's own text alone. Its ID is minted (`paragraph-…`, which `updateDraft`/`deleteDraft` route to `drafts.json` rather than the forge). Each paragraph leaves `drafts.json` as soon as it is posted and the first that fails stops the submit, the review skipped, so trying again posts none twice; a Comment with no own text and no draft on the forge posts the comments and discards the pending review instead, since GitHub turns down an empty Comment review. Every read-modify-write of `drafts.json` goes through `ReviewDrafts`' `#changing`, one at a time per review (`oneAtATimePer` in `server/one-at-a-time.ts`, the queue the marks and `github.ts`'s `oneAtATime` use too); a published submit and a discard clear the record. -`client/drafts.tsx` puts a `DraftsControl` in `DraftsContext` at the panel, so `FileDiffView` reaches it without the guide view passing it on, and only one comment box is open at a time (`OpenBox`, keyed by the place that opened it, since two nodes can draw the same line). `client/comment-box.tsx`'s `CommentBox` is the one box every comment is written and edited in; `NewCommentBox` and `DraftCard` are the only places that open one for a comment, which is where actions like "Suggest wording" go, as `CommentBoxAction`s that return the box's new text; `onAsk`, which sends the text elsewhere instead, only `NewCommentBox` and `AskAction` pass, and `AskAction`'s box has no `onSave`, which makes "Ask agent" its main action. A comment on text the reviewer highlights in the guide is a general draft from the overview or the node whose card holds it: `useHighlight` in `client/text-selection.ts` reads the document's selection, which only the web has, and offers it when it lies wholly inside the card's prose (not its code or drafts), and on a mouse's `pointerup`, for a press that started in the text, opens the box through `useCommentOnRelease`, unfocused (`keepSelection`) so the highlight stays copyable, never over another open box. The box carries the text as `OpenBox.quote` and opens under the paragraph or bullet the highlight ends in, `OpenBox.item`: `useCardText` in `client/guide-view.tsx` gives each card a `CardTextContext` in which `Body` and `Bullet` register their elements by item key and draw `ItemCommentBox` under themselves, whose element is registered too and excluded, so neither a highlight nor a press in the box counts as the card's text, and a box with no item, or the button's, goes to the foot of the card, where `OverviewComments` and `NodeComments` list the drafts and offer a comment on the overview or concept with nothing highlighted. A native `Text` reports nothing about its selection, so the phone app comments on a whole paragraph or bullet on a long press instead (`useCommentOnHold`), and its text is not selectable there, which would take the long press. A hunk's lines take the gesture responder: a row reports the line a press starts on as it bubbles past, the container measures itself at the press and maps later moves to rows by their layout. With a mouse a drag selects at once; on touch it arms after a short hold, and before that it yields to the scroll view. -The review record's `header.headSha` is the head of the guide the panel shows, and nothing moves it on its own. `checkHead` asks the forge's `fetchHead` (one small read: GitHub's `headRefOid`, GitLab's `diff_refs.head_sha`, which is what a new read would key the guide by) and says whether it has `moved`; a forge that cannot be asked reads as not moved, with a `message`. Only a moved head is counted, through `commitsSince`, which looks for the guide's head among the change request's latest 100 commits rather than comparing the two heads, so a force-push or rebase that dropped it reads as `rewritten`; a count that cannot be read is left out, never an error. `describeHeadChange` in `shared/head-change.ts` words it for every place that shows it. That is the one "head moved" signal: the banner reads it through `useHeadCheck` in `client/head-check.tsx`, whose query every part of the panel shares, and a verdict should read it too, from the service's `checkHead` at submit time. -A start whose head has moved keeps the guide the review has when its workspace is still open, is the one asked for, and has a guide there that is not failed: only the title, state and draft flag follow the forge, and no branch is fast-forwarded. `regenerate` is the only way to a new head. It runs the start's job in `regenerate` mode, followed through `startProgress` like a start, and the job, whenever the head moved, brings the workspace along: the reviewer's own branch as before, and a PR workspace's branch (Paseo checks a PR out on a local branch) through the same clean fast-forward; a PR workspace that cannot be moved, dirty or force-pushed, is left for a new one, with a `note`, and the client opens the job's `workspaceId` when it is not the panel's. The guide the new one replaces in the same workspace has its agent archived. -The record keeps the head it moved from as `previousHeadSha`, and a guide generated at the new head, by Regenerate or by a later Try again, takes the marks over through `carryMarks` in `server/carry-over.ts` before it reads as ready: a node whose resolved code (each file's path, status and lines' kinds and text, not hunk or line numbers, which a push above renumbers) equals a node's marked in the guide kept at the old head, and a Supporting or Unsorted entry whose diff, what no node covers of its file, did not change. Code in a diff the forge withheld is never equal. Marks at the old head made in another guide than the one kept there carry nothing, as they count for nothing there. -A change request whose branch carries another's commits is told apart in the start and Regenerate job: `Forge.commitChangeRequests` asks which change requests each commit belongs to (GitLab one call per commit, a few at a time, through `mapAtMost`; GitHub one query of `associatedPullRequests`), beside the workspace's preparation, and `foreignWorkOf` in `server/foreign-work.ts` keeps the open and merged ones from another source branch on the record as `foreign`, at the record's head, so a start that keeps the guide keeps it too. Failing to read it never fails the job. `ownWork` walks the first parents from the head, since a merge brings other work in rather than adding any, to find where the own work starts; it is null when the commit list is cut off at `MAX_COMMITS`, since the history is then incomplete. When `ownWork` finds where the own work starts, the job also asks `Forge.changedPaths` for the files changed from its first parent to the head (GitLab's compare, which can time out; GitHub's, which lists at most 300 files: either cut short is null) as `foreign.ownPaths`, and the guide waits on the reviewer's `chooseScope` (`awaitsScope`, the `choosing-scope` guide state, which spawns no agent) before it is generated. The choice is the record's `scope`, carried to every later head, and `ownWorkOf` applies it only where the head's own work can be told apart. A guide of the own work is still of the change request's own diff, so drafts, anchors and marks need nothing new: `keepOwnWork` in `server/file-classes.ts` sets the files outside `ownPaths` aside like lockfiles, as the `foreign` Supporting category, which the agent's schema does not offer and `splitSupporting` makes a group of its own, and `guidePrompt` points the agent's commit and file-list commands at the first parent of `ownFrom`, while hunks are still numbered as the whole diff cuts them. A guide records its `scope`. Switching it is `rescope`, which keeps the ready guide it replaces at the same head under `replaced/<head SHA>.json`, since `saveGuide` would overwrite it, and `#carryMarksOver` carries from that before the previous head; `again`, a retry, still starts unmarked. - -`headsUpNote` in `shared/foreign-work.ts` drafts the comment the banner (`client/foreign-work.tsx`) offers to post through `postHeadsUp`, which `Forge.startDiscussion` makes a resolvable thread on GitLab and an issue comment on GitHub; the reviewer's own change request has no author to ask, so it is refused there. - -"Suggest wording" is `suggestWordingAction` on both boxes. The panel sends a `CommentSubject` (`shared/contracts.ts`), `{ kind: "code", location }`, a node's `{ kind: "node", nodeId, quote? }` or the overview's `{ kind: "overview", quote? }`, with whatever is in the box and, as for a draft, the `headSha` of the guide it drew, refusing one Regenerate has moved the review off; `suggestWording` resolves it with `commentSubjectContext` in `server/comment-subject.ts`, whose `codeSubjectContext` gives a code subject (the anchored lines, via `anchorAt`, and every node whose resolved `covers` include one of them, or any part of the file for a file comment) and builds the prompt with `wordingPrompt`. A node subject's context is the node and its code references, an overview subject's the overview as the guide has it, and their prompt sections and rules (`conceptContext`, `overviewContext`, `generalRules`) say it goes on the change as a whole and carry the "About the …: " rule; a highlighted passage (`quoteContext`) is given as what the comment is about, never to be quoted, since it is the guide's wording. The prompt does not replay the chat: the agent already has it. An agent's turn outlasts an RPC, so the request is a background job keyed by a random ID that the box polls through `getSuggestion`, which hands a finished result out once; the service refuses a second request to an agent that has one running, as well as a busy or gone one, and never touches the forge. - -Finishing a review is `finish`, `saveReviewBody`, `submit` and `discard`. The verdicts on offer are decided only by `verdictOptions` in `server/submit-rules.ts`, from the record's `viewer` against the header's author and a `checkHead` answer: the reviewer's own change request and a closed or merged one hold Approve and Request changes back, while a moved head and an unreadable one leave them on offer with a `warning`, a moved one with `regenerate` too. `submit` calls `checkHead` again whatever the panel last saw, and refuses, sending nothing, a verdict not on offer then, a `headSha` the review was regenerated away from, or an Approve or Request changes whose warning now is about another head than the `forgeHeadSha` the panel's Finish review read (`warnedOfAnotherHead`). It hands the adapter the head it just read as `approveHeadSha`, null when unreadable. The review body lives in `ReviewStore`'s `review-body.json` until submit, on both forges, written before every submit and cleared once one is `published` or discarded; `ownBody`/`saveOwnBody` are the one pair every body edit goes through. GitHub's pending review has a body, but GitHub refuses to edit it while it is empty (`Could not edit a review with a missing body`), which it is on every review started without one, github.com's own included, so it is never written before submit. `submitReview` answers a `SubmitOutcome` rather than throwing at a failed step: an adapter runs its steps through `SubmitSteps` (`server/forge/submit-steps.ts`), which records any failure as the step's message, and says whether the drafts and body are `published`; the service rates the steps `submitted`, `partial` or `failed`. On GitHub it is one step per general comment and then one for the `submitPullRequestReview` mutation on the pending review, started on the snapshot's head when there is none; a submit and a discard queue behind a draft create in the adapter's `oneAtATime`. -The client's `useRegenerate` in `client/head-check.tsx` is the panel's one Regenerate job, which the banner and Finish review's offer both drive, and `useSetHeadCheck` lets Finish review put its fresher head check in the query the banner reads. - -The SDK gives a plugin no data directory. `server/paths.ts` derives one in the Paseo home, per daemon, because the workspace IDs it records mean something only to that daemon. - -`server/command-runner.ts` keeps the whole of stdout, decoded once, because a truncated JSON document is worse than none; only a runaway command past 256 MiB is cut off, and that is a failure rather than a truncation. -It takes stdin for `--input -`, which is how GraphQL and JSON bodies are sent: `gh api -F` turns a repository called `123` into a number. -A call that prints a file, `Cli.bytes`, has stdout come back base64-encoded (`stdoutEncoding`), which a UTF-8 decode would corrupt, under a cap of its own (`maxStdoutBytes`), crossing which sets `outputExceeded` and fails as a sentence that names the cap. - -The description is `getDescription`, read from the snapshot at the review's head, and the head check carries the forge's own (`fetchHead` reads it in the same small read), which `shownDescription` in `client/shown-description.ts` shows instead once the author has edited it. -`ISSUES_KEY` is a page like Finish review, outside `entryOrder`, which the navigator lists first while the review has foreign work: the banner and, while the guide waits on it, the scope choice, which the guide's own place only points to; the stack has no navigator, so it draws both inline. -`DESCRIPTION_KEY` in `client/guide-entries.ts` is a page no progress marks: first in `entryOrder`, where `startEntry` starts while nothing is marked, and skipped by `nextNotUnderstood`; the navigator lists it, and the overview, while the guide is still being written. -Paseo gives plugins no Markdown renderer, so `client/markdown.ts` reads the description with markdown-it's standalone build (its import says why that build) into blocks of its own, and `client/markdown-view.tsx` draws them. -An image goes through `getDescriptionImage`: `Forge.fetchAttachment` recognises only the forge's own attachment URLs (GitHub's `user-attachments`, a repository's `assets` and `user-images`; GitLab's `/uploads/` in either form it writes) and reads them with `gh api <url>` or GitLab's uploads API, which needs 17.4; any other URL is null and never fetched, so a description cannot send the daemon to a host of its choosing. -`server/image-info.ts` reads the format and size from the image's header, since `gh api` gives no content type and GitLab's is `application/octet-stream`, and refuses what the panel's `Image` cannot draw, SVG among them. - -The review list is `getInbox`, listed per host by each forge's `listReviewRequests` (`shared/inbox.ts`), so one host failing comes back as its `error` and leaves the rest listed. -The sidebar row's count (`client/start-sidebar-item.tsx`) is `attentionCount` in `client/inbox-filter.ts`, the default filters with `needsAttention` on, over the list's own `INBOX_QUERY_KEY` query, which the installation's React Query cache shares between the row and the screen. The row asks with `staleTime: Infinity`, so it lists only while nothing is cached and otherwise counts what the screen last listed or a check-off wrote, since every listing runs the forge searches. -GitHub drops a review request once the viewer reviews, so the GitHub adapter runs two searches, `REVIEW_SEARCHES`, the open requests and the open PRs the viewer reviewed and is not asked again on, kept only when a `ReviewRequestedEvent` in their timeline asked the viewer or any team, which together mean what GitLab's reviewer list does; it lists github.com only, as `parsePullRequestUrl` takes no other host. Its pages are 50, since a full page of that query timed out as a 502. -GitHub's search says everything a row shows: `viewerLatestReview.commit` against the head is a push since the review, and a PR that is both requested and reviewed was asked again. -GitLab keeps no record of the commit a review was of, so the GitLab adapter compares the viewer's reviewer record's `updatedAt` with the head commit's date, which needs 19.2; a GitLab older than that is turned down by version. Its listing has no draft count without a call per MR, so `ReviewInbox` counts drafts only for the MRs reviewed here, and on those it takes `submittedHeadSha`, the head a published submit from here recorded, over the dates. -A row's `local` (`LocalReview` in `shared/inbox.ts`, from `ReviewInbox.local`) is what this plugin has of the review: its start or Regenerate while it runs or once it failed, which exists before the review's record does, its guide's state, and its workspace while that is open. `useReviewStarts` in `client/start-review.ts` is the start screen's one starter, which the New review dialog and every row share: it starts any number of reviews and follows each, and every listed review not yet settled (`isSettled` in `client/start-progress.ts`), through `getLocalReviews` by `reviewId` rather than listing again, since a listing runs the forge searches. A row reads what was read there over what the list said, until Refresh drops it. A start is written in as begun at once, so it is followed from then on; only Continue opens the panel once the job is ready, and Review never does. -Check-offs are `inbox-check-offs.json` in the data directory (`server/inbox-check-offs.ts`), by URL, apart from the preferences because the client saves those whole and a check-off would be lost to a save sent around it. Each keeps the head and reviewer state of the row the reviewer ticked, which the client sends, so a push between the listing and the tick still brings it back. `follow` runs inside every `getInbox`: it drops a check-off on a new head or on a state of `RETURNING_STATES` it was not checked off in, takes over any other state so a re-request after the reviewer's own comment still counts, and drops one a host listed without an error and in full no longer has. On the client `useCheckOff` (`client/check-off.ts`) writes a tick into the inbox query at once and keeps the URL `held`, which `visibleItems` lets past the check-off filter alone until a refresh, preference change or search releases it. -The list's filters, sort and columns are `inbox-preferences.json` in the data directory (`server/inbox-preferences.ts`), since a plugin client has no storage but the web's, which is per device, and the settings document needs `daemon.manage`; each field falls back on its own (`shared/inbox-preferences.ts`), and the client saves a change whole without reading the answer back, so a slow answer cannot undo a later change. -The panel's column widths are what is kept per device, in the web's `localStorage`, which the client bundle reaches because the app evaluates it in its own global scope. -`client/column-widths.ts` keeps them per workspace and fits them to the window; `client/column-resize.tsx` draws the handles over DOM pointer events, since a plugin cannot import the host's gesture handler. - -## GitLab through `glab` - -`glab` sends a token from its environment to any `--hostname` it is given, logged in there or not, so the adapter checks each host with `glab auth status --hostname` (exit 0 means logged in) before its first call there, and `matchUrl` turns down an MR URL on any other host with a `ForgeError` the service shows as the rejection. -Every call passes `--hostname`, because `glab` otherwise picks the host from the git remote of whatever directory the daemon runs in; a host on a port cannot be one, so it is the URL's host without the port. - -The adapter addresses a project by its numeric ID, looked up once per path, because some GitLab versions turn down a URL-encoded path on the draft notes endpoints. -`glab api --paginate` prints each page as a JSON array of its own, one after another, so paginated lists are read with `--output ndjson` through `Cli.ndjson`. -GitLab's REST API gives no line counts, so additions and deletions are counted from the diffs; a diff GitLab withheld as too large counts as none. -The change request keeps GitLab's `start_sha` as `startSha` beside the base and head SHAs, because a draft note's position names all three. - -Each draft is a draft note, its ID GitLab's number, which `draftNoteId` checks before it goes in a path. Writes go through `send`: the JSON body on stdin with `--header 'Content-Type: application/json'`, because `--input -` alone sends no content type and GitLab then reads a form and drops `position`, and bracketed `-f` fields are refused in JSON bodies. `positionOf` builds the position from the anchor alone and `locationOf` reads one back; a range's `line_range` ends carry `line_code`s from the anchor lines' counters, and its top-level lines are its last line's. -GitLab can store a position other than the one sent, or none, without an error, so `createDraft` compares the answer with what it sent (a range by its ends' line codes) and deletes a draft that does not match. -A `PUT` sets the position to whatever it is given, none included, so `updateDraft` reads the draft and sends its position back; an MR-level draft's position comes back all null, and that is not sent. -A `general` anchor is sent with no position at all, an MR-level draft note. `listDrafts` lists a draft with no file as `general`. A reply drafted on the web to a published thread has no position of its own, so when there is one the MR's discussions are read and it is listed where its thread's first note is (`general` for a thread on the MR as a whole). -A submit is `bulk_publish` with `reviewer_state` (`requested_changes`, else `reviewed`), then `approve` with `approveHeadSha` as `sha`, or none when it is null (409 once the MR moved past it), then the reviewer list read for the viewer's state after every verdict. For Approve and Comment that is the `reviewer-state` step, which only reports: `approved` or `reviewed` confirms an Approve, `reviewed` a Comment, anything else or an unreadable list fails the step, and a viewer who is no reviewer (GitLab keeps no state for one) is a done step with a note in its `message`. For Request changes the check is inside the `request-changes` step, which falls back to `mergeRequestRequestChanges` when the state did not take or cannot be read; that mutation takes the full path and the IID as strings through `glab api graphql -f`, and fails as HTTP 200 with `errors`. GitLab before 19.2 drops `note` as well as `reviewer_state`, so the body goes in `note` only when `GET version` says 19.2 or newer; otherwise, unreadable version included, it is posted after the publish as an MR note (`notes`), never both, and `published` is false until it is, so the service keeps it. Nothing after a failed publish is tried. Discard deletes every draft note, MR-level ones included, one by one, and names the ones left. -The draft answers under `fixtures/gitlab/` (`draft-notes.ndjson`, `discussions.ndjson`) are built by hand from the API docs on !3931's diff refs, and the adapter tests build each create's answer from the position it expects. The submit answers (`version`, `reviewers`, the mutation's) are built inline in `gitlab.test.ts` from the docs, as recording them would publish a real review. - -The review list asks `glab auth status --all` for the hosts, which exits 1 when any host fails, so it is read through `Cli.report`, which returns what a call printed whatever its exit code; `parseAuthStatus` takes a host from the line that starts its block, and a host it reports logged in is not checked again. - -`glab` runs without `PASEO_AGENT_ID` and `GITLAB_BOT_IDENTITY` (`unsetEnv` on the command runner). -A `glab` wrapper can swap in a bot account's token when it sees either, and a daemon restarted from an agent's terminal inherits `PASEO_AGENT_ID`; the review is the reviewer's own, so it is read and written as them. - -## Tests - -`pnpm test` is `node --test "{client,server,shared}/**/*.test.ts"` through Node's type stripping, so no TypeScript that has to be emitted and relative imports keep their `.ts` extension. -`server/forge/fixtures/github/` holds real `gh` 2.101 output for sleeyax/paseo-plugins#105, with the user trimmed to its public fields (`pull-request.json`'s commit parents, which the query asks for since, read from the same PR; `compare.json`, the comparison of its first and last commits, with emails taken out), and `gh pr list --head` for #107's branch, beside the hand-built draft answers described above. `fetchHead`'s test builds its smaller GraphQL answer from `pull-request.json`'s own head and state. -The head check, Regenerate and carry-over are driven in `server/regenerate.test.ts`, whose guide agent fake answers by the head label it was created with; created PR workspaces are checkouts in `fakeWorkspaces()` on branch `pr-<number>`, so a test sets their fast-forward outcome. -Node comments and draft links are driven in `server/node-comments.test.ts` over both forge fakes (the GitHub fake refuses a `general` draft, as the adapter does), and following a link across Regenerate in `server/regenerate.test.ts`. -The submit rules and partial failures are driven in `server/submit.test.ts`; the fake forge's `submitOutcome` scripts a submit's steps, and a `fakeForge("github")` refuses a `general` draft, like the real one. The GitHub submit and discard answers are built by hand in `github.test.ts`, as recording them would publish or delete a real review. -`server/forge/fixtures/gitlab/` holds real `glab` 1.119 output for gitlab-org/cli!3931 on gitlab.com, a merged MR from a fork, recorded with read-only `glab api` calls; the user is the account `glab` happened to be logged in as, trimmed to its public fields. `merge-requests-for-branch.json` is the open-MR listing for !3967's source branch, `commit-merge-requests.json` the MRs of !3931's head commit and `compare.json` the comparison of its first and last commits, with every email taken out. -`fixtures/github/review-search-reviewed.json` is real output of `REVIEW_SEARCH_QUERY` over `REVIEW_SEARCHES.reviewed`, with a private repository's PR taken out; `review-search-requested.json` is built by hand, since the account had no open request to record. `fixtures/gitlab/auth-status-all*.stderr` are glab 1.120's own reports, the second for a host with a made-up token, and `review-list.json` is built by hand in gitlab.com 19.5's shape, since the MRs a real account reviews are not public. -`server/workspaces/checkout.test.ts` runs the local checkout module against real throwaway repositories: a bare `origin` holding the head under `refs/pull/7/head` and a clone standing in for the workspace, with the host's git config kept out through `GIT_CONFIG_GLOBAL=/dev/null`. -The branch flow's service tests are in `server/review-service-branch.test.ts`, over `fakeWorkspaces().openCheckout` and `openOnBranch` on the forge fake. -Service tests use a real temp directory for the data directory, and restart the service over it to check what survives on disk. -The guide agent fake answers with fixture text (`sampleGuideReply`), or with a promise the test resolves to look at the panel mid-generation, so the real parsing runs in every service test. `server/guide-agent/paseo.test.ts` drives the SDK adapter over a stub of the slice of `PaseoApi` it uses. +- Check-offs (`inbox-check-offs.json`) are kept apart from the list preferences (`inbox-preferences.json`) because the client saves preferences whole, which would overwrite a check-off sent around it. +- Build fixtures for calls that write (drafts, submits, discards) by hand from the API docs; recording them would publish to a real PR or MR. diff --git a/plugins/guided-review/docs/adr/0002-the-guide-agent-reads-the-change-with-git.md b/plugins/guided-review/docs/adr/0002-the-guide-agent-reads-the-change-with-git.md new file mode 100644 index 0000000..0622f34 --- /dev/null +++ b/plugins/guided-review/docs/adr/0002-the-guide-agent-reads-the-change-with-git.md @@ -0,0 +1,5 @@ +# The guide agent reads the change with git + +The guide prompt carries only what the forge alone knows: the description, linked issues, and the base and head commits. +The agent reads the commits, the file list and the diffs itself with git in the review's workspace, so the prompt stays the same size however large the change is. +This is why every review needs a checkout with its base and head commits fetched, and why the prompt pins the `git diff` options: the agent's hunk numbers have to match how the plugin cuts the forge's diff. From 8e0109c5c81533e23bd767036cced33aa9e7eb5a Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 15:49:38 +0200 Subject: [PATCH 09/31] docs: restructure the root README Order it for a user first and a contributor after: overview, installation, development, releasing. Turn development into the steps from a clone to a reloaded plugin, saying that Paseo compiles the bundles itself and only the claude-tty adapter needs a build. Drop the settings location and the pnpm remark from installation, which Paseo and each plugin's README already document, and the plugin-enable step that Paseo no longer has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 2 -- README.md | 97 ++++++++++++++++++++++++++++++++----------------------- 2 files changed, 56 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 28d2907..09bc006 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,8 +53,6 @@ Every plugin's README follows this template; sections marked optional are droppe ## License and attributions (optional) ``` -Where a plugin's settings live is documented once in the root README, not per plugin. - ## Agent skills ### Issue tracker diff --git a/README.md b/README.md index e780750..dd81a1a 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,79 @@ # Paseo plugins and apps -Extensions for [Paseo](https://github.com/getpaseo/paseo), organized in a pnpm workspace. +My plugins for [Paseo](https://github.com/getpaseo/paseo). -| App | Description | -| --- | --- | -| [Claude TTY ACP](apps/claude-tty-acp) | Run genuine interactive Claude Code sessions in Paseo's native agent view. | +## Overview -| Plugin | Description | -| --- | --- | -| [Discord Rich Presence](plugins/discord-rich-presence) | Show your current Paseo activity on Discord. | -| [Catppuccin theme](plugins/catppuccin-theme) | Add all four Catppuccin flavours as app themes. | -| [Claude TTY](plugins/claude-tty) | Offer the Claude TTY ACP adapter as a Paseo provider, and manage it on the daemon host. | -| [Guided Review](plugins/guided-review) | Show a trunk-first guide to a pull request or merge request, and build your draft review alongside it. | +| Plugin | Description | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| [Discord Rich Presence](plugins/discord-rich-presence) | Show your current Paseo activity on Discord. | +| [Catppuccin theme](plugins/catppuccin-theme) | Add all four Catppuccin flavours as app themes. | +| [Claude TTY](plugins/claude-tty) | Offer the Claude TTY ACP adapter as a Paseo provider, and manage it on the daemon host. | +| [Guided Review](plugins/guided-review) | Show a trunk-first guide to a pull request or merge request, and build your draft review alongside it. | -Each app and plugin has its own README. +| ACP | Description | +| ------------------------------------- | -------------------------------------------------------------------------- | +| [Claude TTY ACP](apps/claude-tty-acp) | Run genuine interactive Claude Code sessions in Paseo's native agent view. | ## Installation -Enable plugins in Paseo, then install a plugin by its directory in this repository: +Install a plugin by its directory in this repository: ```sh paseo plugin add github:sleeyax/paseo-plugins:plugins/discord-rich-presence ``` -Plugins in the [Paseo plugin registry](https://paseo.sh/plugins) also install by their registry ID, which their README gives. -Update with `paseo plugin update <id>`, or preview the update with `paseo plugin update <id> --check`. +A plugin in the [Paseo plugin registry](https://paseo.sh/plugins) also installs by the registry ID its README gives. +Update with `paseo plugin update <id>`. -`claude-tty` and `guided-review` run a `pnpm` build when installed or updated, so they need `pnpm` on the daemon's `PATH`. +Check each plugin's README for the Paseo version and anything else it needs. -Plugins keep their settings in `$PASEO_HOME/plugin-settings/<plugin-id>/`. -Paseo manages that directory and removes it with the plugin. +## Development -## Local development +1. Clone this repository and install its dependencies: -Install dependencies and check every package from the repository root: + ```sh + pnpm install + ``` -```sh -pnpm install -pnpm typecheck -pnpm test -``` +2. Build what the plugin needs, if anything. + Paseo compiles a plugin's bundles itself, so most plugins need no build. + The `build` commands in a plugin's `paseo-plugin.json` list what does; today that is only the adapter `claude-tty` runs: -To work on a plugin, install it from your working copy. -A directory installation runs no build, so build what the plugin needs first: + ```sh + pnpm --filter @paseo-plugins/claude-tty-acp build + ``` -```sh -paseo plugin add "/absolute/path/to/paseo-plugins/plugins/discord-rich-presence" -``` +3. Install the plugin from your working copy: -Paseo does not hot-reload plugins. -Run `paseo plugin reload <id>` after each change; the reload is also what compiles the plugin's bundles. + ```sh + paseo plugin add "/absolute/path/to/paseo-plugins/plugins/discord-rich-presence" + ``` -## Releasing a plugin +4. After each change, reload the plugin, and read its logs if something broke: -[release-please](https://github.com/googleapis/release-please) releases from the conventional commits on `main`: a `feat` bumps the minor version, a `fix` the patch version, and `chore` or `docs` release nothing. -It keeps one release pull request open, and merging it is the release: versions, `CHANGELOG.md` files, tags and GitHub releases. + ```sh + paseo plugin reload <id> + paseo plugin logs <id> + ``` -A plugin's `version` in its `package.json` is what [Paseo Cafe](https://github.com/paseo-cafe/paseo-cafe) compares to offer an update, so installations only move when the version does. -release-please picks the package to bump from the files a commit touches, not its scope, so keep each commit inside one package. -`claude-tty` and `claude-tty-acp` share one version. +5. Typecheck and test every package from the repository root: -## Skills + ```sh + pnpm typecheck + pnpm test + ``` + +Each plugin's README has its package-level commands and anything else specific to it in its own Development section. + +### Skills `skills/` holds agent skills for working on this repository, in the [Agent Skills](https://agentskills.io) format. `.claude/skills/` (Claude Code) and `.agents/skills/` (Codex, Cursor, OpenCode, GitHub Copilot) are committed symlinks into it, so edit the files under `skills/`. -| Skill | Description | -| --- | --- | -| `update-plugins` | Rebuild the apps and reload the plugins this host has installed from this checkout. | +| Skill | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `update-plugins` | Rebuild the apps and reload the plugins this host has installed from this checkout. | | `adopt-paseo-release` | Review the Paseo releases since the last review and file an issue with what the plugins should adopt. Runs only when invoked by name: `/adopt-paseo-release` in Claude Code, `$adopt-paseo-release` in Codex. | To use a skill from another checkout, install it by name: @@ -75,3 +81,12 @@ To use a skill from another checkout, install it by name: ```sh npx skills add sleeyax/paseo-plugins --skill update-plugins ``` + +## Releasing a plugin + +[release-please](https://github.com/googleapis/release-please) releases from the conventional commits on `main`: a `feat` bumps the minor version, a `fix` the patch version, and `chore` or `docs` release nothing. +It keeps one release pull request open, and merging it is the release: versions, `CHANGELOG.md` files, tags and GitHub releases. + +A plugin's `version` in its `package.json` is what [Paseo Cafe](https://github.com/paseo-cafe/paseo-cafe) compares to offer an update, so installations only move when the version does. +release-please picks the package to bump from the files a commit touches, not its scope, so keep each commit inside one package. +`claude-tty` and `claude-tty-acp` share one version. From 91ad88722ce67c5c75ee7e9d788f047e48d7d80b Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 15:53:20 +0200 Subject: [PATCH 10/31] docs: add installing from source to the root README Add a From source section for running every change on main as it lands, with updating through git pull or the update-plugins skill, and build the development steps on it. Open the release section by saying releases are automatic, so a contributor knows there is nothing to bump by hand. Drop installing a skill into another checkout, since the skills only serve work on this repository. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index dd81a1a..aca7ce2 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,16 @@ Update with `paseo plugin update <id>`. Check each plugin's README for the Paseo version and anything else it needs. -## Development +### From source + +Installs above only update when a plugin releases a new version. +To run every change as soon as it lands on `main`, install from a clone instead: 1. Clone this repository and install its dependencies: ```sh + git clone https://github.com/sleeyax/paseo-plugins.git + cd paseo-plugins pnpm install ``` @@ -44,20 +49,27 @@ Check each plugin's README for the Paseo version and anything else it needs. pnpm --filter @paseo-plugins/claude-tty-acp build ``` -3. Install the plugin from your working copy: +3. Install the plugin from the clone: ```sh paseo plugin add "/absolute/path/to/paseo-plugins/plugins/discord-rich-presence" ``` -4. After each change, reload the plugin, and read its logs if something broke: +To update, `git pull`, repeat the build, and run `paseo plugin reload <id>`. +The `update-plugins` [skill](#skills) does all of that for every plugin installed from the clone. + +## Development + +Install the plugin you are working on [from source](#from-source), then: + +1. After each change, reload the plugin, and read its logs if something broke: ```sh paseo plugin reload <id> paseo plugin logs <id> ``` -5. Typecheck and test every package from the repository root: +2. Typecheck and test every package from the repository root: ```sh pnpm typecheck @@ -76,15 +88,10 @@ Each plugin's README has its package-level commands and anything else specific t | `update-plugins` | Rebuild the apps and reload the plugins this host has installed from this checkout. | | `adopt-paseo-release` | Review the Paseo releases since the last review and file an issue with what the plugins should adopt. Runs only when invoked by name: `/adopt-paseo-release` in Claude Code, `$adopt-paseo-release` in Codex. | -To use a skill from another checkout, install it by name: - -```sh -npx skills add sleeyax/paseo-plugins --skill update-plugins -``` - ## Releasing a plugin -[release-please](https://github.com/googleapis/release-please) releases from the conventional commits on `main`: a `feat` bumps the minor version, a `fix` the patch version, and `chore` or `docs` release nothing. +Releases are automatic: [release-please](https://github.com/googleapis/release-please) cuts them from the conventional commits on `main`, so there is no version to bump or changelog to write by hand. +A `feat` bumps the minor version, a `fix` the patch version, and `chore` or `docs` release nothing. It keeps one release pull request open, and merging it is the release: versions, `CHANGELOG.md` files, tags and GitHub releases. A plugin's `version` in its `package.json` is what [Paseo Cafe](https://github.com/paseo-cafe/paseo-cafe) compares to offer an update, so installations only move when the version does. From 94153bd5a727c2754d786b5a0e6551aff8b7dbfb Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 15:55:00 +0200 Subject: [PATCH 11/31] docs: point the from-source build step at the manifest Name where the build commands live instead of listing which plugins have one today. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/README.md b/README.md index aca7ce2..61c04d7 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,7 @@ To run every change as soon as it lands on `main`, install from a clone instead: pnpm install ``` -2. Build what the plugin needs, if anything. - Paseo compiles a plugin's bundles itself, so most plugins need no build. - The `build` commands in a plugin's `paseo-plugin.json` list what does; today that is only the adapter `claude-tty` runs: - - ```sh - pnpm --filter @paseo-plugins/claude-tty-acp build - ``` +2. Run the `build` commands in the plugin's `paseo-plugin.json` from its directory, if it has any. 3. Install the plugin from the clone: From ad6a7b283eb5fdf007e75d52bf05842a4e06de3b Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 15:59:08 +0200 Subject: [PATCH 12/31] docs: list the from-source update steps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 61c04d7..4d87933 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,13 @@ To run every change as soon as it lands on `main`, install from a clone instead: paseo plugin add "/absolute/path/to/paseo-plugins/plugins/discord-rich-presence" ``` -To update, `git pull`, repeat the build, and run `paseo plugin reload <id>`. -The `update-plugins` [skill](#skills) does all of that for every plugin installed from the clone. +To update: + +- Run `git pull` +- Repeat the build steps +- Run `paseo plugin reload <id>`. + +Alternatively, run the `update-plugins` [skill](#skills). It does all the update steps above for every plugin installed from the clone. ## Development From 58dbf4245838a2275fb666beda4bff72f5d3e847 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 16:00:41 +0200 Subject: [PATCH 13/31] docs: explain the plugin registries in the root README Releasing mentioned Paseo Cafe without saying what it is, so introduce it and the official registry under Installation and link there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4d87933..06fd26d 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,17 @@ Install a plugin by its directory in this repository: paseo plugin add github:sleeyax/paseo-plugins:plugins/discord-rich-presence ``` -A plugin in the [Paseo plugin registry](https://paseo.sh/plugins) also installs by the registry ID its README gives. Update with `paseo plugin update <id>`. Check each plugin's README for the Paseo version and anything else it needs. +### From a registry + +Some plugins are also listed in a plugin registry: + +- The official [Paseo plugin registry](https://paseo.sh/plugins): install by the registry ID the plugin's README gives, such as `paseo plugin add sleeyax/discord-rich-presence`. +- [Paseo Cafe](https://paseo.cafe), a community directory of Paseo plugins: copy the install command from the plugin's page. + ### From source Installs above only update when a plugin releases a new version. @@ -93,6 +99,6 @@ Releases are automatic: [release-please](https://github.com/googleapis/release-p A `feat` bumps the minor version, a `fix` the patch version, and `chore` or `docs` release nothing. It keeps one release pull request open, and merging it is the release: versions, `CHANGELOG.md` files, tags and GitHub releases. -A plugin's `version` in its `package.json` is what [Paseo Cafe](https://github.com/paseo-cafe/paseo-cafe) compares to offer an update, so installations only move when the version does. +A plugin's `version` in its `package.json` is what [Paseo Cafe](#from-a-registry) compares to offer an update, so installations only move when the version does. release-please picks the package to bump from the files a commit touches, not its scope, so keep each commit inside one package. `claude-tty` and `claude-tty-acp` share one version. From a22b1a8a211bd5bec7e48a1578fe20083b697256 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 16:04:52 +0200 Subject: [PATCH 14/31] docs: trim the release section to what a contributor needs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 06fd26d..43de381 100644 --- a/README.md +++ b/README.md @@ -95,10 +95,8 @@ Each plugin's README has its package-level commands and anything else specific t ## Releasing a plugin -Releases are automatic: [release-please](https://github.com/googleapis/release-please) cuts them from the conventional commits on `main`, so there is no version to bump or changelog to write by hand. -A `feat` bumps the minor version, a `fix` the patch version, and `chore` or `docs` release nothing. -It keeps one release pull request open, and merging it is the release: versions, `CHANGELOG.md` files, tags and GitHub releases. +Releases are automatic, so there is no version to bump or changelog to write by hand. +[release-please](https://github.com/googleapis/release-please) reads the conventional commits on `main` and keeps a release pull request open; merging it releases. -A plugin's `version` in its `package.json` is what [Paseo Cafe](#from-a-registry) compares to offer an update, so installations only move when the version does. -release-please picks the package to bump from the files a commit touches, not its scope, so keep each commit inside one package. -`claude-tty` and `claude-tty-acp` share one version. +- `feat` releases a minor version, `fix` a patch version, and `chore` or `docs` nothing. +- The package a commit releases is the one whose files it touches, so keep each commit inside one package. From b829a255506afaad3c7c2ce96f29522e5692a60e Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 16:05:37 +0200 Subject: [PATCH 15/31] docs: write the release section as prose Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 43de381..2c8915a 100644 --- a/README.md +++ b/README.md @@ -98,5 +98,5 @@ Each plugin's README has its package-level commands and anything else specific t Releases are automatic, so there is no version to bump or changelog to write by hand. [release-please](https://github.com/googleapis/release-please) reads the conventional commits on `main` and keeps a release pull request open; merging it releases. -- `feat` releases a minor version, `fix` a patch version, and `chore` or `docs` nothing. -- The package a commit releases is the one whose files it touches, so keep each commit inside one package. +A `feat` releases a minor version, a `fix` a patch version, and `chore` or `docs` nothing. +The package a commit releases is the one whose files it touches, so keep each commit inside one package. From b2d7afa150848d4a4280124ab36feee6e2942a43 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 16:09:58 +0200 Subject: [PATCH 16/31] docs: state the general install notes before the install methods Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2c8915a..84f0579 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,15 @@ My plugins for [Paseo](https://github.com/getpaseo/paseo). ## Installation -Install a plugin by its directory in this repository: +Check each plugin's README for the Paseo version and anything else it needs. +A plugin installed from GitHub or a registry updates with `paseo plugin update <id>`. + +### From GitHub (recommended) ```sh paseo plugin add github:sleeyax/paseo-plugins:plugins/discord-rich-presence ``` -Update with `paseo plugin update <id>`. - -Check each plugin's README for the Paseo version and anything else it needs. - ### From a registry Some plugins are also listed in a plugin registry: @@ -85,18 +84,17 @@ Each plugin's README has its package-level commands and anything else specific t ### Skills -`skills/` holds agent skills for working on this repository, in the [Agent Skills](https://agentskills.io) format. -`.claude/skills/` (Claude Code) and `.agents/skills/` (Codex, Cursor, OpenCode, GitHub Copilot) are committed symlinks into it, so edit the files under `skills/`. +This repository comes with its own set of local skills to aid in development: | Skill | Description | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `update-plugins` | Rebuild the apps and reload the plugins this host has installed from this checkout. | | `adopt-paseo-release` | Review the Paseo releases since the last review and file an issue with what the plugins should adopt. Runs only when invoked by name: `/adopt-paseo-release` in Claude Code, `$adopt-paseo-release` in Codex. | -## Releasing a plugin +### Releasing a plugin Releases are automatic, so there is no version to bump or changelog to write by hand. [release-please](https://github.com/googleapis/release-please) reads the conventional commits on `main` and keeps a release pull request open; merging it releases. A `feat` releases a minor version, a `fix` a patch version, and `chore` or `docs` nothing. -The package a commit releases is the one whose files it touches, so keep each commit inside one package. +The package a commit releases is the one whose files it touches, so try to keep each commit inside one package. From bff57fcaa96872ced1de3657a36e81c9f5515afe Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 16:14:35 +0200 Subject: [PATCH 17/31] docs: reword the installation notes and registry names Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 84f0579..b949085 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ My plugins for [Paseo](https://github.com/getpaseo/paseo). ## Installation -Check each plugin's README for the Paseo version and anything else it needs. -A plugin installed from GitHub or a registry updates with `paseo plugin update <id>`. +Check each plugin's README for the Paseo version and anything else it may depend on. +A plugin installed from GitHub or a registry can be updated with `paseo plugin update <id>`. ### From GitHub (recommended) @@ -30,12 +30,12 @@ paseo plugin add github:sleeyax/paseo-plugins:plugins/discord-rich-presence Some plugins are also listed in a plugin registry: -- The official [Paseo plugin registry](https://paseo.sh/plugins): install by the registry ID the plugin's README gives, such as `paseo plugin add sleeyax/discord-rich-presence`. -- [Paseo Cafe](https://paseo.cafe), a community directory of Paseo plugins: copy the install command from the plugin's page. +- [Paseo Community Plugins](https://paseo.sh/plugins): install by the registry ID the plugin's README gives, such as `paseo plugin add sleeyax/discord-rich-presence`. +- [Paseo Cafe](https://paseo.cafe): copy the install command from the plugin's page. ### From source -Installs above only update when a plugin releases a new version. +The installation methods above only update when a plugin releases a new version. To run every change as soon as it lands on `main`, install from a clone instead: 1. Clone this repository and install its dependencies: From 659b19c8c41b11dc5f1cb8d8df5374a91c47ff33 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 16:15:04 +0200 Subject: [PATCH 18/31] docs: explain how to cut a major release Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b949085..24fd406 100644 --- a/README.md +++ b/README.md @@ -97,4 +97,6 @@ Releases are automatic, so there is no version to bump or changelog to write by [release-please](https://github.com/googleapis/release-please) reads the conventional commits on `main` and keeps a release pull request open; merging it releases. A `feat` releases a minor version, a `fix` a patch version, and `chore` or `docs` nothing. +A breaking change, marked with `!` after the type (`feat!: ...`) or a `BREAKING CHANGE:` footer, releases a major version. +To release a specific version instead, add a `Release-As: 1.0.0` footer to the commit. The package a commit releases is the one whose files it touches, so try to keep each commit inside one package. From 0da11ea9ed812eb7e82ecbd572ca9f39ce436db3 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 16:17:09 +0200 Subject: [PATCH 19/31] docs: group the version rules into one paragraph Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 24fd406..9da7400 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,6 @@ This repository comes with its own set of local skills to aid in development: Releases are automatic, so there is no version to bump or changelog to write by hand. [release-please](https://github.com/googleapis/release-please) reads the conventional commits on `main` and keeps a release pull request open; merging it releases. -A `feat` releases a minor version, a `fix` a patch version, and `chore` or `docs` nothing. -A breaking change, marked with `!` after the type (`feat!: ...`) or a `BREAKING CHANGE:` footer, releases a major version. -To release a specific version instead, add a `Release-As: 1.0.0` footer to the commit. +A `feat` releases a minor version, a `fix` a patch version, and `chore` or `docs` nothing. A breaking change, marked with `!` after the type (`feat!: ...`) or a `BREAKING CHANGE:` footer, releases a major version. To release a specific version instead, add a `Release-As: 1.0.0` footer to the commit. + The package a commit releases is the one whose files it touches, so try to keep each commit inside one package. From 6b8daf263ade7fd9a2c03c352269f2026d734499 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 16:29:32 +0200 Subject: [PATCH 20/31] docs: give every README the same installation section Each plugin's installation now lists its requirements, installs with the github: command, names the registries it is listed in on one line, and links to the root README for installing from source. The README template in CLAUDE.md carries the same shape, and claude-tty-acp folds its requirements into it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 21 +++++++++++++++++---- apps/claude-tty-acp/README.md | 8 ++++---- plugins/catppuccin-theme/README.md | 12 +++++------- plugins/claude-tty/README.md | 19 ++++++------------- plugins/discord-rich-presence/README.md | 16 +++++++++++----- plugins/guided-review/README.md | 10 ++++++---- 6 files changed, 49 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 09bc006..5c77ef8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ Write short, plain sentences, one per line. Every plugin's README follows this template; sections marked optional are dropped when there is nothing to put in them: -```md +````md # plugin name <one or two sentences on what it does for the user> @@ -30,9 +30,22 @@ Every plugin's README follows this template; sections marked optional are droppe ## Installation -<the Paseo version it needs and what the host has to provide> +Requires: -<paseo plugin add sleeyax/<name> for a plugin in the Paseo registry, else paseo plugin add github:sleeyax/paseo-plugins:plugins/<name>, plus the update note> +- Paseo <version> or newer +- <anything else the daemon's host needs> + +```sh +paseo plugin add github:sleeyax/paseo-plugins:plugins/<name> +``` + +Also available as `sleeyax/<name>` on [Paseo Community Plugins](https://paseo.sh/plugins) and on [Paseo Cafe](https://paseo.cafe/plugins/<name>). +<only when the plugin is listed there> + +Update with `paseo plugin update <name>`. +To install from source, see the [root README](../../README.md#from-source). + +<plugin-specific notes, if any> ## Usage @@ -51,7 +64,7 @@ Every plugin's README follows this template; sections marked optional are droppe <package-level commands, and what a developer needs beyond them> ## License and attributions (optional) -``` +```` ## Agent skills diff --git a/apps/claude-tty-acp/README.md b/apps/claude-tty-acp/README.md index f64aefa..432c793 100644 --- a/apps/claude-tty-acp/README.md +++ b/apps/claude-tty-acp/README.md @@ -11,16 +11,16 @@ This adapter avoids the SDK by driving the same CLI you would run in a terminal. You keep its binary, login, billing, sessions, `CLAUDE.md` files, MCP servers, plugins, skills and permission rules. The cost is that Paseo's features are rebuilt from Claude's transcripts, hooks and screen, which is where the [limitations](#limitations) come from. -## Requirements +## Installation + +Requires: - Linux or macOS - Node.js 22 or newer - Paseo 0.11 or newer - Claude Code, logged in as the user the Paseo daemon runs as -## Installation - -Install the [Claude TTY plugin](../../plugins/claude-tty/README.md#installation); it builds this adapter and registers it as a provider. +Install it through the [Claude TTY plugin](../../plugins/claude-tty/README.md#installation), which builds this adapter and registers it as a provider. Then run `claude` once, interactively, as the user the daemon runs as, and log in. If the daemon runs under systemd, open a login shell for that account so the credentials land in its home directory. diff --git a/plugins/catppuccin-theme/README.md b/plugins/catppuccin-theme/README.md index 6aeade7..2f21a19 100644 --- a/plugins/catppuccin-theme/README.md +++ b/plugins/catppuccin-theme/README.md @@ -16,20 +16,18 @@ Adds the four [Catppuccin](https://catppuccin.com/palette/) flavours to Paseo as ## Installation -Requires Paseo 0.9 or newer. -Install from the [Paseo plugin registry](https://paseo.sh/plugins), which needs Paseo 0.11 or newer: +Requires: -```sh -paseo plugin add sleeyax/catppuccin-theme -``` - -Or install from this repository's default branch: +- Paseo 0.9 or newer ```sh paseo plugin add github:sleeyax/paseo-plugins:plugins/catppuccin-theme ``` +Also available as `sleeyax/catppuccin-theme` on [Paseo Community Plugins](https://paseo.sh/plugins) and on [Paseo Cafe](https://paseo.cafe/plugins/catppuccin-theme). + Update with `paseo plugin update catppuccin-theme`. +To install from source, see the [root README](../../README.md#from-source). ## Usage diff --git a/plugins/claude-tty/README.md b/plugins/claude-tty/README.md index ae3bd76..deb53a0 100644 --- a/plugins/claude-tty/README.md +++ b/plugins/claude-tty/README.md @@ -5,27 +5,20 @@ A sidebar panel diagnoses the adapter and manages its saved sessions on the sele ## Installation -You need: +Requires: -- Paseo 0.11 or newer. -- `pnpm` on the daemon's `PATH`, because installing and updating build the adapter. -- Claude Code installed and logged in as the user the daemon runs as (see the [adapter's requirements](../../apps/claude-tty-acp/README.md)). +- Paseo 0.11 or newer +- `pnpm` on the daemon's `PATH` +- Claude Code, installed and logged in as the user the daemon runs as ```sh paseo plugin add github:sleeyax/paseo-plugins:plugins/claude-tty ``` Update with `paseo plugin update claude-tty`. -A failed build is reported, and the installed version keeps running. -Install the plugin on each host that should offer Claude. - -To install from a clone, build the adapter first, since a directory install runs no build: +To install from source, see the [root README](../../README.md#from-source). -```sh -pnpm install --frozen-lockfile -pnpm --filter @paseo-plugins/claude-tty-acp build -paseo plugin add "/absolute/path/to/paseo-plugins/plugins/claude-tty" -``` +Install the plugin on each host that should offer Claude. If the daemon can't build the adapter, build it from a clone yourself and put the path to `apps/claude-tty-acp/bin/claude-tty-acp` in the **Adapter executable** setting. The panel's **Adapter** section shows which adapter runs and whether it can. diff --git a/plugins/discord-rich-presence/README.md b/plugins/discord-rich-presence/README.md index e32c13c..9c59ebe 100644 --- a/plugins/discord-rich-presence/README.md +++ b/plugins/discord-rich-presence/README.md @@ -14,15 +14,21 @@ Show your current Paseo activity on your Discord profile. ## Installation -You need Paseo 0.11 or newer. -The Discord desktop app must run on the same machine as the Paseo daemon. -Native, Snap, and Flatpak installs of Discord all work. +Requires: + +- Paseo 0.11 or newer +- The Discord desktop app, running on the same machine as the Paseo daemon ```sh -paseo plugin add sleeyax/discord-rich-presence +paseo plugin add github:sleeyax/paseo-plugins:plugins/discord-rich-presence ``` -Run `paseo plugin update discord-rich-presence` to get new releases. +Also available as `sleeyax/discord-rich-presence` on [Paseo Community Plugins](https://paseo.sh/plugins) and on [Paseo Cafe](https://paseo.cafe/plugins/discord-rich-presence). + +Update with `paseo plugin update discord-rich-presence`. +To install from source, see the [root README](../../README.md#from-source). + +Native, Snap and Flatpak installs of Discord all work. ## Usage diff --git a/plugins/guided-review/README.md b/plugins/guided-review/README.md index 7e1df58..ce21234 100644 --- a/plugins/guided-review/README.md +++ b/plugins/guided-review/README.md @@ -5,17 +5,19 @@ Each review gets its own Paseo workspace, checked out at the change, with a **Gu ## Installation -Guided Review needs Paseo 0.11 or newer, and on the daemon's host: +Requires: -- `pnpm` on the daemon's `PATH`, since installing and updating build the plugin. -- [`gh`](https://cli.github.com/) logged in with `gh auth login`, to review GitHub pull requests. -- [`glab`](https://gitlab.com/gitlab-org/cli) logged in with `glab auth login`, to review GitLab merge requests, on gitlab.com or a self-hosted GitLab. +- Paseo 0.11 or newer +- `pnpm` on the daemon's `PATH` +- [`gh`](https://cli.github.com/), logged in with `gh auth login`, to review GitHub pull requests +- [`glab`](https://gitlab.com/gitlab-org/cli), logged in with `glab auth login`, to review GitLab merge requests on gitlab.com or a self-hosted GitLab ```sh paseo plugin add github:sleeyax/paseo-plugins:plugins/guided-review ``` Update with `paseo plugin update guided-review`. +To install from source, see the [root README](../../README.md#from-source). ## Usage From 2f20cf2c94813d22cb4fea36ecc07eef5589d38b Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 16:37:26 +0200 Subject: [PATCH 21/31] docs(guided-review): say only one of gh or glab is required Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- plugins/guided-review/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/guided-review/README.md b/plugins/guided-review/README.md index ce21234..e6f5d72 100644 --- a/plugins/guided-review/README.md +++ b/plugins/guided-review/README.md @@ -9,8 +9,9 @@ Requires: - Paseo 0.11 or newer - `pnpm` on the daemon's `PATH` -- [`gh`](https://cli.github.com/), logged in with `gh auth login`, to review GitHub pull requests -- [`glab`](https://gitlab.com/gitlab-org/cli), logged in with `glab auth login`, to review GitLab merge requests on gitlab.com or a self-hosted GitLab +- Either or both: + - [`gh`](https://cli.github.com/), logged in with `gh auth login`, to review GitHub pull requests + - [`glab`](https://gitlab.com/gitlab-org/cli), logged in with `glab auth login`, to review GitLab merge requests on gitlab.com or a self-hosted GitLab ```sh paseo plugin add github:sleeyax/paseo-plugins:plugins/guided-review From 01d761fdcb8d612c943a5ea02ebf05d06803abda Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 16:55:34 +0200 Subject: [PATCH 22/31] docs: give every README the same development section The shared typecheck, test, reload and logs commands now live only in the root README, so each package's Development section links there and keeps just what is specific to it. READMEs no longer point at CLAUDE.md, which agents load on their own, and every package with ADRs links to them instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 7 ++++++- README.md | 17 +++++++++++++---- apps/claude-tty-acp/README.md | 7 ++++--- plugins/catppuccin-theme/README.md | 7 +------ plugins/claude-tty/README.md | 9 ++------- plugins/discord-rich-presence/README.md | 9 +++------ plugins/guided-review/README.md | 9 ++------- 7 files changed, 31 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5c77ef8..2102a0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,12 @@ To install from source, see the [root README](../../README.md#from-source). ## Development -<package-level commands, and what a developer needs beyond them> +For development instructions, see the [root README](../../README.md#development). + +<anything else this package needs, if any> + +Design decisions are in [docs/adr](docs/adr). +<only when the package has ADRs> ## License and attributions (optional) ```` diff --git a/README.md b/README.md index 9da7400..2f164cb 100644 --- a/README.md +++ b/README.md @@ -69,18 +69,27 @@ Install the plugin you are working on [from source](#from-source), then: 1. After each change, reload the plugin, and read its logs if something broke: ```sh - paseo plugin reload <id> - paseo plugin logs <id> + paseo plugin reload <name> + paseo plugin logs <name> ``` -2. Typecheck and test every package from the repository root: +2. Typecheck and test the package you are working on: + + ```sh + pnpm --filter @paseo-plugins/<name> typecheck + pnpm --filter @paseo-plugins/<name> test + ``` + + Or every package at once, from the repository root: ```sh pnpm typecheck pnpm test ``` -Each plugin's README has its package-level commands and anything else specific to it in its own Development section. +`<name>` is the package's directory name, which is also its plugin ID. + +Each plugin's README has anything else specific to it in its own Development section. ### Skills diff --git a/apps/claude-tty-acp/README.md b/apps/claude-tty-acp/README.md index 432c793..9c43e56 100644 --- a/apps/claude-tty-acp/README.md +++ b/apps/claude-tty-acp/README.md @@ -112,9 +112,9 @@ Delete it only if you no longer need to resume old sessions. ## Development +For development instructions, see the [root README](../../README.md#development). + ```sh -pnpm --filter @paseo-plugins/claude-tty-acp typecheck -pnpm --filter @paseo-plugins/claude-tty-acp test pnpm --filter @paseo-plugins/claude-tty-acp build pnpm --filter @paseo-plugins/claude-tty-acp diagnose ``` @@ -129,4 +129,5 @@ pnpm --filter @paseo-plugins/claude-tty-acp smoke:live -- /absolute/project/path ``` To run a changed adapter in Paseo, see the plugin's [Development](../../plugins/claude-tty/README.md#development) section. -Design decisions are in [docs/adr](docs/adr), and the traps to know before changing the code are in [CLAUDE.md](CLAUDE.md). + +Design decisions are in [docs/adr](docs/adr). diff --git a/plugins/catppuccin-theme/README.md b/plugins/catppuccin-theme/README.md index 2f21a19..9cd792a 100644 --- a/plugins/catppuccin-theme/README.md +++ b/plugins/catppuccin-theme/README.md @@ -36,12 +36,7 @@ The themes recolour the app only; syntax highlighting keeps its own theme. ## Development -```sh -pnpm --filter @paseo-plugins/catppuccin-theme typecheck -pnpm --filter @paseo-plugins/catppuccin-theme test -paseo plugin reload catppuccin-theme -paseo plugin logs catppuccin-theme -``` +For development instructions, see the [root README](../../README.md#development). ## License and attributions diff --git a/plugins/claude-tty/README.md b/plugins/claude-tty/README.md index deb53a0..a75b42d 100644 --- a/plugins/claude-tty/README.md +++ b/plugins/claude-tty/README.md @@ -92,12 +92,7 @@ For errors inside a running session, see the [adapter's troubleshooting](../../a ## Development -```sh -pnpm --filter @paseo-plugins/claude-tty typecheck -pnpm --filter @paseo-plugins/claude-tty test -paseo plugin reload claude-tty -paseo plugin logs claude-tty -``` +For development instructions, see the [root README](../../README.md#development). After changing the adapter, rebuild it with `pnpm --filter @paseo-plugins/claude-tty-acp build`. Sessions already running keep the old build. @@ -120,4 +115,4 @@ The copy shows up as **Claude TTY (dev)**, with its own provider and its own sta Agents can't move between the two providers, so start throwaway sessions on the dev one. `pnpm --filter @paseo-plugins/claude-tty identity claude-tty` stamps the checkout back. -See [CLAUDE.md](./CLAUDE.md) before changing the code, and [docs/adr](docs/adr) for the design decisions. +Design decisions are in [docs/adr](docs/adr). diff --git a/plugins/discord-rich-presence/README.md b/plugins/discord-rich-presence/README.md index 9c59ebe..02cb55e 100644 --- a/plugins/discord-rich-presence/README.md +++ b/plugins/discord-rich-presence/README.md @@ -108,12 +108,7 @@ Run `paseo plugin logs discord-rich-presence` for more detail. ## Development -```sh -pnpm --filter @paseo-plugins/discord-rich-presence typecheck -pnpm --filter @paseo-plugins/discord-rich-presence test -paseo plugin reload discord-rich-presence -paseo plugin logs discord-rich-presence -``` +For development instructions, see the [root README](../../README.md#development). To regenerate the artwork in `assets/` after Paseo changes its own, run this against a Paseo checkout: @@ -123,6 +118,8 @@ scripts/render-assets.sh /path/to/paseo The script needs ImageMagick (`magick`) and `rsvg-convert`. +Design decisions are in [docs/adr](docs/adr). + ## License and attributions The images in `assets/` are derived from the Apache-2.0 artwork in [getpaseo/paseo](https://github.com/getpaseo/paseo/tree/main/packages/app/assets/images). diff --git a/plugins/guided-review/README.md b/plugins/guided-review/README.md index e6f5d72..79fe314 100644 --- a/plugins/guided-review/README.md +++ b/plugins/guided-review/README.md @@ -125,11 +125,6 @@ Run `paseo plugin logs guided-review` for more detail. ## Development -```sh -pnpm --filter @paseo-plugins/guided-review typecheck -pnpm --filter @paseo-plugins/guided-review test -paseo plugin reload guided-review -paseo plugin logs guided-review -``` +For development instructions, see the [root README](../../README.md#development). -Reload after every change: the reload is what compiles the plugin's bundles. +Design decisions are in [docs/adr](docs/adr). From cedda7e75e5c4c39b76978e82075fefff0145f2d Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 17:16:43 +0200 Subject: [PATCH 23/31] docs(guided-review): restore the forge caveats the rewrite cut The README said every saved draft goes on the forge right away, which is false for GitHub comments on a concept or on highlighted text: they stay local until submit and are then posted as separate PR comments. It also no longer said that only github.com is supported, that GitHub ties a review to the explained commit, where review clones come from, or what to do about GitLab's "Reviewer not found", whose message does not give the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- plugins/guided-review/README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/guided-review/README.md b/plugins/guided-review/README.md index 79fe314..273b283 100644 --- a/plugins/guided-review/README.md +++ b/plugins/guided-review/README.md @@ -10,7 +10,7 @@ Requires: - Paseo 0.11 or newer - `pnpm` on the daemon's `PATH` - Either or both: - - [`gh`](https://cli.github.com/), logged in with `gh auth login`, to review GitHub pull requests + - [`gh`](https://cli.github.com/), logged in with `gh auth login`, to review pull requests on github.com; GitHub Enterprise is not supported - [`glab`](https://gitlab.com/gitlab-org/cli), logged in with `glab auth login`, to review GitLab merge requests on gitlab.com or a self-hosted GitLab ```sh @@ -29,6 +29,8 @@ To install from source, see the [root README](../../README.md#from-source). 3. Select **Start review**, or press Ctrl+Enter (Cmd+Enter on a Mac). The plugin prepares a workspace for each review and has a guide agent write the guide there. +The workspace belongs to the Paseo project whose `origin` is the repository under review. +When you have none, the plugin clones the repository once into `$PASEO_HOME/plugin-data/guided-review/clones/`, which then shows up as a Paseo project of its own. Nothing opens on its own, so you can start several and read them once they are ready. Select **Open** when a review is ready. @@ -88,11 +90,15 @@ Select **Ask agent** instead of saving to ask the guide agent about the lines ra Nobody sees it until you finish the review. **Your drafts** lists them, including drafts you started on the web. +On GitHub, comments on a concept or on highlighted text are the exception: they stay on your machine until you finish the review, and are then posted as separate comments on the pull request, before the review itself. +On GitLab they are draft notes on the merge request as a whole, like any other draft. + ### Finish the review Select **Finish review**, write the review body, and choose **Approve**, **Request changes**, or **Comment**. If commits were pushed since the guide was written, a warning says so before you submit. After a submit, the panel lists each step and whether it went through. +On GitHub your review stays on the commit the guide explained, so a repository that dismisses stale approvals may not count an approval when commits were pushed since. **Discard review** deletes your drafts and the body. @@ -119,6 +125,7 @@ The guide agent settings apply to guides written after you select **Use these fo | **The guide agent's answer did not match what was asked for** | Select **Try again**, or pick a stronger model under **Provider and model**. | | **GitLab did not keep the comment where it was put** | Comments on a whole file need GitLab 17.2 or newer. Comment on a line instead. | | **gh failed: … could not be resolved** when saving a draft | The PR moved on since the guide was written. Comment on the whole file instead. | +| **GitLab did not record your request for changes: Reviewer not found** | Your drafts and body were published, but an older GitLab records a request for changes only from someone already listed as a reviewer. Add yourself as a reviewer on the merge request's page and request changes there. | | No **PR updated since this guide** warning after a push | The forge could not be reached. Check `paseo plugin logs guided-review`. | Run `paseo plugin logs guided-review` for more detail. From 8d2539fa4444eb7d72a3c641a11305c261e8b588 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 17:17:02 +0200 Subject: [PATCH 24/31] docs(guided-review): keep the rule for recording read fixtures The rewrite dropped the only note that recorded fixtures are trimmed to public fields with emails and private repositories taken out, which nothing in the code says. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- plugins/guided-review/CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/guided-review/CLAUDE.md b/plugins/guided-review/CLAUDE.md index b314dd8..f6b130d 100644 --- a/plugins/guided-review/CLAUDE.md +++ b/plugins/guided-review/CLAUDE.md @@ -55,3 +55,4 @@ The reload is the only compile check of the two bundles the daemon builds, so do - Check-offs (`inbox-check-offs.json`) are kept apart from the list preferences (`inbox-preferences.json`) because the client saves preferences whole, which would overwrite a check-off sent around it. - Build fixtures for calls that write (drafts, submits, discards) by hand from the API docs; recording them would publish to a real PR or MR. +- Record read fixtures under `server/forge/fixtures/` only from public PRs and MRs with read-only calls, then trim every user to its public fields, take out every email, and drop anything from a private repository, since they are committed. From 3b6c53c8931a73e50d5e0bbc181263aed7982960 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 17:17:14 +0200 Subject: [PATCH 25/31] docs(claude-tty-acp): restore the context mtime rationale and fix the PTY write trap A comment in claude-runtime.ts pointed at a README explanation the rewrite removed, so the reason now sits in the comment itself. CLAUDE.md said one write to the PTY is one read for Claude, while prompt-content.ts batches pastes because a large write can be split across reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/claude-tty-acp/CLAUDE.md | 7 ++++--- apps/claude-tty-acp/src/claude-runtime.ts | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/claude-tty-acp/CLAUDE.md b/apps/claude-tty-acp/CLAUDE.md index ced6d94..e44074d 100644 --- a/apps/claude-tty-acp/CLAUDE.md +++ b/apps/claude-tty-acp/CLAUDE.md @@ -8,11 +8,12 @@ The server speaks ACP as newline-delimited JSON on stdout, so any other byte wri Log with `writeLog` from `src/log.ts`, which writes to stderr and to the log file under the state directory. The daemon drops stderr, so the file is the only record of a session. -## One write to the PTY is one read for Claude +## How a write is cut up decides what Claude reads -Claude reads a multi-character write as pasted text, not as keys. +Claude reads several characters that arrive together as pasted text, not as keys. Send each control key as its own write with a gap after it, as the Ctrl-U clear does, or Claude inserts them as literal characters. -Large pastes go in batches for the same reason, see `PASTE_WRITE_BYTES`. +A large write goes the other way: the terminal can hand it over in several reads, and a paste's opening sequence cut between two of them reaches Claude as text. +So pastes go in batches that each end on a whole piece, see `PASTE_WRITE_BYTES`. ## The client has no session until the response diff --git a/apps/claude-tty-acp/src/claude-runtime.ts b/apps/claude-tty-acp/src/claude-runtime.ts index 24bdb81..6cafb17 100644 --- a/apps/claude-tty-acp/src/claude-runtime.ts +++ b/apps/claude-tty-acp/src/claude-runtime.ts @@ -765,7 +765,8 @@ export class ClaudeRuntime { private async emitContextUsage(stopReason: string): Promise<void> { if (stopReason === "cancelled" || this.closed || !this.contextFilePath) return; // The mtime rather than the contents marks this turn's reading, because two turns that land on the same numbers write identical files. - // See the README on why it is taken at the Stop hook and compared against itself rather than against Date.now(). + // It is taken at the Stop hook rather than here, because draining the transcript and delivering the turn's last message both run in between and can outlast Claude's refresh. + // It is compared against itself rather than against Date.now(), because Linux stamps files from a coarse clock that can date a write milliseconds before the wall clock that observed it. const before = this.contextMtimeAtTurnEnd; const waits = this.contextWaitMisses < CONTEXT_WAIT_MISS_LIMIT || this.contextWaitMisses % CONTEXT_WAIT_RETRY_TURNS === 0; const deadline = Date.now() + this.contextRefreshTimeoutMs; From 2e894882cb92096a81a1e033e16ebb8c1be44f19 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 17:18:23 +0200 Subject: [PATCH 26/31] docs: fix the root CLAUDE.md claims about entries, skills and glossaries The loader does not need exactly index.client.tsx and index.server.ts: catppuccin-theme ships index.client.ts and no server entry. The note that each skill is a symlink from .claude/skills/ and .agents/skills/ into skills/ was cut with the README's skills section, which leaves an agent adding a skill to only one harness. GLOSSARY-MAP.md and the per-context glossaries were described as existing, while none does yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2102a0d..121ef34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,8 @@ # Working in this workspace A pnpm workspace of Paseo plugins under `plugins/`, and the apps they run under `apps/`. -Paseo installs a plugin against its package directory, so `paseo-plugin.json`, `index.client.tsx` and `index.server.ts` sit at the package root, where the loader looks for exactly those names. +Paseo installs a plugin against its package directory, so `paseo-plugin.json` and the runtime entries sit at the package root, where the loader looks for them. +The entries are `index.client.tsx` (or `index.client.ts`) and `index.server.ts`, either or both. Every other module lives under `client/`, `server/` or `shared/`, and that directory decides which bundle it joins. Dependencies belong to the package that uses them, not the root, because the daemon's esbuild resolves from the plugin directory. @@ -71,6 +72,11 @@ Design decisions are in [docs/adr](docs/adr). ## License and attributions (optional) ```` +## Repository skills + +The repository's own skills live in `skills/`. +`.claude/skills/` and `.agents/skills/` hold one symlink per skill into it, so a new skill goes in `skills/` with a link in each, or only one harness sees it. + ## Agent skills ### Issue tracker @@ -85,5 +91,6 @@ See `docs/agents/triage-labels.md`. ### Domain docs -Multi-context: a root `GLOSSARY-MAP.md` points at one `GLOSSARY.md` per plugin or app, and `docs/adr/` at the root holds workspace-wide decisions. +Multi-context: each plugin and app is its own context whose decisions go in its own `docs/adr/`, and `docs/adr/` at the root holds workspace-wide decisions. +No glossary exists yet; `/domain-modeling` creates `GLOSSARY-MAP.md` and a context's `GLOSSARY.md` once terms get settled. See `docs/agents/domain.md`. From 455f7e674e892c21c1f11c8fbac55ef3f7385fa4 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 17:18:37 +0200 Subject: [PATCH 27/31] docs: say what each installation method updates to The README said a GitHub or registry install only updates when a plugin releases, but paseo plugin update moves a Git install to the remote's default HEAD, as the old README said. Installing from source is for running your own changes, and installing by registry ID needs Paseo 0.11, which the rewrite no longer said anywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2f164cb..9ca2d9f 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ My plugins for [Paseo](https://github.com/getpaseo/paseo). Check each plugin's README for the Paseo version and anything else it may depend on. A plugin installed from GitHub or a registry can be updated with `paseo plugin update <id>`. +From GitHub that installs the latest commit on `main`, and from a registry the revision the registry last approved. ### From GitHub (recommended) @@ -31,12 +32,12 @@ paseo plugin add github:sleeyax/paseo-plugins:plugins/discord-rich-presence Some plugins are also listed in a plugin registry: - [Paseo Community Plugins](https://paseo.sh/plugins): install by the registry ID the plugin's README gives, such as `paseo plugin add sleeyax/discord-rich-presence`. + It needs Paseo 0.11 or newer. - [Paseo Cafe](https://paseo.cafe): copy the install command from the plugin's page. ### From source -The installation methods above only update when a plugin releases a new version. -To run every change as soon as it lands on `main`, install from a clone instead: +To run changes of your own, install from a clone instead: 1. Clone this repository and install its dependencies: From 034c035f8e43db7d16c75cea67ba2bd3a8cc746e Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 17:18:43 +0200 Subject: [PATCH 28/31] docs(catppuccin-theme): say a registry install needs Paseo 0.11 The plugin itself runs on 0.9, so the requirements list alone invited a 0.9 or 0.10 user to a registry install that fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- plugins/catppuccin-theme/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/catppuccin-theme/README.md b/plugins/catppuccin-theme/README.md index 9cd792a..6b46ebf 100644 --- a/plugins/catppuccin-theme/README.md +++ b/plugins/catppuccin-theme/README.md @@ -25,6 +25,7 @@ paseo plugin add github:sleeyax/paseo-plugins:plugins/catppuccin-theme ``` Also available as `sleeyax/catppuccin-theme` on [Paseo Community Plugins](https://paseo.sh/plugins) and on [Paseo Cafe](https://paseo.cafe/plugins/catppuccin-theme). +Installing by registry ID needs Paseo 0.11 or newer. Update with `paseo plugin update catppuccin-theme`. To install from source, see the [root README](../../README.md#from-source). From 7733699755bd8f6d6478a4eb069879682f736720 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 17:18:52 +0200 Subject: [PATCH 29/31] docs(discord-rich-presence): describe the socket lookup the plugin actually does runtimeDir takes the first of XDG_RUNTIME_DIR, TMPDIR, TMP and TEMP that is set and never falls back to the next, so a daemon whose XDG_RUNTIME_DIR differs from Discord's misses the socket even as the same user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- plugins/discord-rich-presence/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/discord-rich-presence/README.md b/plugins/discord-rich-presence/README.md index 02cb55e..4a8da2e 100644 --- a/plugins/discord-rich-presence/README.md +++ b/plugins/discord-rich-presence/README.md @@ -99,7 +99,7 @@ Discord can take a few minutes to process new artwork. | Message | What to do | | --- | --- | -| **Discord is not running — retrying…** while Discord is open | Run Discord as the same user as the Paseo daemon. The plugin looks for Discord's socket in `$XDG_RUNTIME_DIR`, then `$TMPDIR`, then `/tmp`. | +| **Discord is not running — retrying…** while Discord is open | The plugin looks for Discord's socket only in the first of `$XDG_RUNTIME_DIR`, `$TMPDIR`, `$TMP`, `$TEMP` that is set in the daemon's environment, else in `/tmp`. Run the daemon as the same user as Discord, with the same `XDG_RUNTIME_DIR`. | | **Discord refused this application ID** | Check the ID in the Developer Portal. The plugin tries again only after you save a different ID, or turn the presence off and on. | | **Cannot read Paseo** | The plugin reads the daemon address from `PASEO_HOST`, `PASEO_LISTEN`, `$PASEO_HOME/paseo.pid`, then `daemon.listen` in `$PASEO_HOME/config.json`, and falls back to `127.0.0.1:6767`. If the daemon needs a password, set `PASEO_PASSWORD` in the daemon's environment. | | The badge or icon has no image | Check that each asset key matches its file name, then give Discord a few minutes. | From d68fc2454ff3aec1fc80aa7dd6c6aed5e9f9b657 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 17:19:12 +0200 Subject: [PATCH 30/31] docs(claude-tty): list every untyped contract with the adapter The list read as complete but left out the other vendor methods, the dialog input marker, the settings snapshot, the diagnose output, the session and lock files, and Claude's config directory. It now points at the "Mirrors the adapter's own" marks so a contract it misses is still found. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- plugins/claude-tty/CLAUDE.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/claude-tty/CLAUDE.md b/plugins/claude-tty/CLAUDE.md index 8db7655..e6908de 100644 --- a/plugins/claude-tty/CLAUDE.md +++ b/plugins/claude-tty/CLAUDE.md @@ -22,7 +22,15 @@ A running adapter keeps the code it started with, so a rebuilt `apps/claude-tty- ## Contracts with the adapter -The plugin and `apps/claude-tty-acp` share these names with no type linking them, so change both sides in one commit: the `_claude_tty/tool_call` vendor method, the `_meta["claudeTty/backgroundCommand"]` mark, the `--settings-file` and `--answers-dir` flags, the `submit` and `reply-in-chat` option IDs, the tool name carried in a permission's `toolCall.title`, and the state directory name. +The plugin and `apps/claude-tty-acp` share names and shapes with no type linking them, so change both sides in one commit. +The plugin side of each is marked "Mirrors the adapter's own …"; they include: + +- the `_claude_tty/tool_call`, `_claude_tty/notice`, `_claude_tty/card_withdrawn` and `_claude_tty/model` vendor methods +- the `_meta["claudeTty/backgroundCommand"]` mark and the `claudeDialog` input marker +- the `--settings-file` and `--answers-dir` flags, and the snapshot shape `--settings-file` points at (`server/settings-snapshot.ts`) +- the output of `--diagnose --json`, which `server/doctor.ts` parses +- the `submit` and `reply-in-chat` option IDs, and the tool name carried in a permission's `toolCall.title` +- the state directory name, its session and lock files (`shared/sessions.ts`), and Claude's config directory (`server/paths.ts`) ## Provider registration From e72ed38ef2f314b17ae4a4957169b22967e19896 Mon Sep 17 00:00:00 2001 From: Sleeyax <sleeyax@gmail.com> Date: Sun, 11 Oct 2026 17:19:17 +0200 Subject: [PATCH 31/31] chore: drop the setup instructions left in the agent docs The triage label table and the PR triage flag still carried the setup skill's notes on how to fill them in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/agents/issue-tracker.md | 2 +- docs/agents/triage-labels.md | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index a63510f..527c6dc 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -16,7 +16,7 @@ Infer the repo from `git remote -v`; `gh` does this automatically when run insid ## Pull requests as a triage surface -**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_ +**PRs as a request surface: no.** `/triage` reads this flag. When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents: diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md index 5276464..ad2d1ca 100644 --- a/docs/agents/triage-labels.md +++ b/docs/agents/triage-labels.md @@ -11,5 +11,3 @@ The skills speak in terms of five canonical triage roles. This file maps those r | `wontfix` | `wontfix` | Will not be actioned | When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. - -Edit the "Label in our tracker" column to match whatever vocabulary you actually use.