Repository navigation
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
📝 Walkthrough
|
Stacked on #16050 (and #15010). Review only the top 9 commits: 5e1ce8d.
Problem
A trusted local plugin can react to events and give agents tools, but it cannot give the user a command. A plugin that deploys a branch, opens a dashboard for the current project or files a thread somewhere needs a button. Today the user has to ask an agent to call it, or run a script by hand.
This PR lets an enabled plugin declare a few actions in its manifest. Web, desktop and mobile offer them where the user already looks for commands: the command palette, the composer slash menu and the thread menus.
Why this qualifies
This is the proposal route in CONTRIBUTING, and no maintainer has agreed to it yet. It needs #6837 (Pi-style extension API, which names UI contributions), on top of the plugin-system approval the plugin host PR needs on #6714 / #6837.
It stacks on the plugin settings PR, which stacks on the plugin tools, event delivery and plugin host PRs. It only uses the host (catalogue, consent, supervised children); the PRs in between are ordering only (the shared manifest-capability list, neighbouring contract lines). It is the first PR in the stack with a client consumer, so it brings the small client helper that checks a server capability on the same session a request uses. If the answer is no, we close this and the plugin PRs above it. Previous PR in this stack: feat(server): typed plugin settings, write-only secrets and plugin storage (#16050).
Fix
Declaration. A manifest with
"capabilities": ["actions"]and"proposedApi": truedeclares up to 16actions:{ name, title, description?, target: "environment" | "project" | "thread", placements: ["command-palette" | "thread-menu" | "composer-slash"] }. The name is also the slash command (/name). The declaration is part of the consented manifest bytes, so changing it needs fresh consent. Duplicate names, repeated placements, a missing capability or a missingproposedApiare refused at add with a readable reason. The plugin answers withcontext.proposed.handle("action:<name>", handler). The handler gets the action name and its target (thread id, project id, cwd, branch or workspace root, resolved by the server). It returns an optional message of up to 500 characters, or throws to fail.Server.
PluginActionsis a server service that takes the catalogue, threads and projects from its environment. The WebSocket handlers only call it.pluginActions.subscribesends the whole list now, then a fresh whole list on each change. It is built from the consented manifests, so listing never starts a plugin. An installation is offered only while it is enabled, has the capability and is not quarantined or incompatible. One environment offers at most 128 actions and 128 KiB, taking whole plugins in catalogue order. Plugins left out are counted inomitted.pluginActions.invoke({ actionId, target })runs one listed action on its target. Ids are opaque and issued by the server. Each id is bound to one registration, so after a disable, change, re-enable or restart the old id fails withnot-foundorstale. A disable mid-call fails the call withstopped. A call has a 30 s deadline.orchestration:read; invoke =orchestration:operate. Running an action an administrator already enabled is ordinary operation, like starting a turn: it cannot change what code runs. So a standard pairing can run actions, and a read-only session cannot.pluginActionsenvironment capability.Clients.
pluginActionsis never subscribed to and shows no actions. The invoke request is checked against the capabilities of the same session that sends it, so a reconnect to an older server between the pick and the send cannot receive it./nameand runs the action. The text is never sent to the agent. Results and failures show as a toast.A short user guide,
docs/user/plugin-actions.md, covers declaring actions, where they appear, that picking one runs plugin code rather than sending a prompt, and what to do when an action is refused.Size: 48 files, +2870 / −17. About 1.7k of the added lines are tests, the test plugin and the guide.
Evidence
Environment: macOS 26.5 arm64. Parent = the plugin settings PR (9b6d57b), head = 46653e7. Isolated servers on fresh local state; web in headless Chromium at 1440×1000; the built desktop app (
vp run build:desktop) with a separate HOME; Android emulator (API 36). Light and dark use the app's own appearance setting.How to exercise it: use an isolated
vp run dev. From an administrative session, add, consent to and enable a plugin that declares actions. The test plugin declared a thread action (echo-target: palette, thread menu, slash), a project action (open-dashboard: palette, slash) and two environment actions (say-hello,fail). Then open the palette, a thread's menu and the composer's/menu. Each thread had one real agent turn first.Before: the parent server refuses the plugin (
this server does not support actions) and does not knowpluginActions.subscribe. No client shows any plugin entry.After, by entry point (recordings are real time, H.264):
Ran on thread … (no branch)and "Fail on purpose" shows the plugin's error. Video: after-web-dark-flow-palette.mp4; parent: before-web-light-flow-palette.mp4. Built desktop: after-electron-dark-flow-palette.mp4.Keep this line, start a new line, then/echo. The menu offers/echo-targetwith the plugin badge. Enter runs it: a toast appears,/echois removed andKeep this linestays. No message is sent: the timeline still shows only the original turn, and the database has 2 runs and 2 user messages per revision, both from the setup turns. Video: after-web-dark-flow-slash.mp4; desktop after-electron-dark-flow-slash.mp4; Android after-android-light-flow-slash.mp4; parent before-web-light-flow-slash.mp4.Menu.popup. On the head, the sidebar and header menus both contain an enabled "Echo thread target" after a separator; the parent has none (after-electron-native-menu-items.png, before-electron-native-menu-items.png). Running that native item's own click handler ran the action on the right thread (after-electron-dark-native-menu-click-toast.png, after-electron-dark-flow-native-menu-trace.mp4). The native item has no icon. The desktop menu only draws icons on destructive items, and this PR does not change that./offers only/open-dashboardand/say-hello; the thread action is withheld. The dashboard action runs on the selected project. Web after-web-dark-flow-draft-project.mp4; Android New Task after-android-light-flow-newtask-project.mp4.requiredScope=orchestration:operate. A standard pairing runs them. After a disable, an old id is refused asnot-found; after a re-enable it is refused asstale, and the new id runs.vp run dev --share, a fresh browser paired with a standard pairing over the tailnet: the palette's "Say hello" and Thread B's menu entry both ran (after-remote-dark-flow-remote.mp4). Over the same origin, a read-only session could list but was refused invoke, and the standard session could do both.Every capture (web and desktop in light and dark, before and after; Android; remote) is published next to the files linked above.
Checks at this head (
46653e7ad3), re-run 2026-10-05 (CI=true vp test run, all exit 0):PluginActions.test.ts,PluginActionsRpc.test.ts,PluginCatalog.test.ts; contractspluginActions,plugin,pluginCatalog; client-runtimestate/pluginActions; webthreadActionMenu.logic,composerSlashCommandSearch,CommandPalette.logic,ChatComposer.pluginActions: 11 files, 106 tests pass. Mobileuse-composer-command-menu(two files),commandPaletteItems: 3 files, 13 tests pass.ChatComposer.pluginActions.test.tsxrenders the real composer in jsdom, with the real editor, slash menu and draft store, and mocks only the action list and the invoke call. It picks a thread action with Enter and with the pointer. Each pick sends the invoke for the environment and thread the menu was opened on. The typed/deplis removed, the text on the lines around it stays, and nothing is sent to the agent (onSendis never called). A new, unsent thread offers only the project action and runs it on the project. The mobile test mounts the composer menu hook the same way, for an open thread and for New Task with a selected project.PluginActions.test.tsruns real plugin child processes. It covers listing without a launch, run on the resolved target, typed failures, refused malformed ids, the environment bound, and the reverse states (disable/re-enable issues new ids; old ids fail).PluginActionsRpc.test.tsserves the two RPCs through the real scope middleware. A standard pairing can list and run. A session with onlyorchestration:readis refused invoke withrequiredScope: orchestration:operate, and the handler never runs. A session withoutorchestration:readcannot list. With invoke registered atorchestration:read, the denial test fails.vp run --filtertypecheck for contracts, client-runtime, server, web and mobile;vp lint --report-unused-disable-directivesandvp fmt --checkon the touched files;vp run knip:check;vp run lint:mobile; web build;vp run build:desktop;node scripts/release-smoke.ts. All pass. Lint warnings are only on lines this PR does not change.Surfaces
pluginActions.ts; optionalactionson the plugin manifest and the catalogue summary; optionalpluginActionscapability; two RPCs. Old server: no capability, so clients neither subscribe nor invoke. Old client: never calls them and ignores the extra summary key. A newer server's unknown placements or target kinds are dropped per element.docs/user/plugin-actions.md, next to the plugin tools and settings pages. The management UI PR folds these into one plugin guide. No internals doc.Not verified
--shareorigin.Claude Opus 5.5 (build), GPT-6.1 Sol (review) and GPT-6 Astra (captures) via T3 Code
🤖 Generated with Claude Code