mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 18:04:55 +00:00
* feat(protocol): option controls, options patch, and provider.controls.list
Add AgentOptionControl descriptors, snapshot options/controls, an options patch on
agent.config.apply, provider.controls.list RPC, and server_info.features.providerOptionControls.
featureValues stays accepted on the wire (COMPAT).
* feat(server): fold featureValues into providerOptions and describe composer controls
Providers emit option controls over top-level providerOptions keys; the daemon stores one
options record per agent, merges config.json defaults as effective values, and applies patches
live via AgentSession.applyOptions. Legacy featureValues (wire, stored records, profiles,
schedules, hooks, MCP) fold into providerOptions; snapshots still carry features built from
controls. Snapshot options expose only keys that have a control.
* fix(server): address Phase 1 audit findings on option controls
- Removing auto_accept (OpenCode, ACP) turns live approval off; ACP selects return to their session default.
- agent.create.before hook featureValues edits override options the create already set.
- Claude commits fast_mode only after the runtime accepts it.
- Codex snapshots show a saved Fast preference as the tier the model resolves it to (AgentSession.displayOptions).
- Profiles are folded into options in the daemon config store; the config API still answers with featureValues.
- Snapshot options and provider.controls.list carry all effective options.
- Test: Claude fast_mode survives a restart through stored providerOptions.
* fix(server): keep snapshot options as configured, restore OMP baseline, cover options through public interfaces
- Codex: drop displayOptions; a select choice may carry aliases so a saved Fast preference selects the model's Fast tier from options + controls alone.
- OMP: removing fast_mode restores the runtime's starting setting.
- ACP/Cursor removal tests run through real session initialization.
- agent.config.apply of a non-control option reaches the provider and shows in the next snapshot (daemon e2e).
- Daemon config store persists legacy-featureValues profiles as options on the next profile save.
* fix(server): read a pre-v0.10 Codex fast_mode record as service_tier "fast" and store it that way
A record holding `fast_mode: true` without `service_tier` ran Fast but the speed control showed
Normal, and picking Normal then wrote "default". The Codex session now normalizes the old boolean
at construction and reports the new shape through its existing options_changed event.
* fix(server): show Fast for a stored Codex fast_mode: true without rewriting the record
Reverts the storage migration from a987b4a43. The Codex speed control now takes its default from
the session's current options with the same rule the runtime uses (an explicit service_tier wins;
fast_mode: true applies only while service_tier is absent). This holds on create, resume, live
apply and for config.json defaults, and stored options stay as set.
* chore(website): regenerate the config schema for profile options
* refactor(server): providers report option differences once, after create, resume and apply
Providers used to write their own reports into the agent's options while a session was being
created, resumed or updated, which needed a growing set of reconciliation, baseline and
suppression rules to stay in step with the daemon.
The contract is now transactional. A provider opens or updates with the options it is given and
emits nothing while doing so; if the result differs it says so once, afterwards, as
`options_changed`. Live apply hands the provider only the keys the patch sets and the daemon stores
them onto the agent's current options when the provider accepts, so the last write wins. A change
the provider makes on its own is stored and forwarded, not undone. Removing an option drops the key
and sends nothing live. Controls carry no state; `options` is the only option state.
A legacy `agent.create.before` hook's `featureValues` edits are applied as a patch over the
returned options, so its deletions and replacements hold. The unreleased `aliases` field on option
choices is gone; a pre-v0.10 Codex Fast preference is reported once as the model's tier id.
* refactor(server): providers return what apply and open ended with instead of reporting it afterwards
applyOptions returns the options the session ended up with and the session
exposes its open difference as openOptionsPatch, so the manager stores the
patch and the provider's answer in one step. Removal is an ordinary patch:
the provider receives the default the key falls back to, or null, and returns
what it kept. Controls no longer take a default from a stored legacy feature
value.
* fix(server): apply an old app's profile featureValues over the profile's stored options
The unchanged app's profile form only knows the controls, so a name-only
edit dropped options it never shows, such as sandbox_mode. Its
featureValues now apply as a patch over the options already stored for the
profile (null removes a key), and a write with no featureValues keeps them.
* fix(server): ACP reports the selects it moved during open or apply as that call's result
A config_option_update that arrived before the write response was emitted
as an event on top of the result the manager stores. Notifications during
open or apply now fold into the open patch or apply result, and a rejected
apply reports what the agent moved since nothing is stored. Live options
are validated with the creation schema. Tests drive a stdio ACP agent.
* fix(server): providers validate live option patches and Claude reports Fast dropped by a model switch
Live patches go through the schema used at creation. A Claude model switch
that cannot keep Fast reports fast_mode as removed so the stored options
match the session.
* fix(server): Claude reports Fast as off, not removed, when a model switch drops it
A removed key lets a config.json default of fast_mode: true fill back in
while Fast is off at runtime.
* fix(server): fold an old app's profile featureValues by the provider and options the write carries
A write with options folds featureValues over those options. A write
without them patches the stored options, unless the profile moved to
another provider, which starts from no options.
* docs(server): a rejected apply reports what the provider moved after the call ends
* test(server): a patch key is resolved against nested configured defaults
* test(server): a setting a plugin changes on its own is forwarded as options_changed
* test(server): assert the forwarded plugin setting without nesting callbacks
* test(server): removal of auto_accept returns ACP to asking; Claude keeps opening restrictions across a patch
* fix(server): Codex returns the tier it resolved and drops a speed the new model lacks
A live service_tier: fast is stored as the catalog's Fast tier id instead of
a value the control cannot show, and a model switch to a model without the
stored speed reports Normal so choosing a supporting model again does not
bring the paid tier back.
* fix(server): Claude applies only the keys a patch sets over its current options
A Fast patch kept a copy of the options taken before the runtime answered, and
assigning it afterwards dropped a patch that landed meanwhile.
* fix(server): a plugin that moves settings and then refuses an apply reports what moved
* refactor(server): the legacy featureValues folds take one named-input object
* fix(server): a live patch is rejected for an option the session only reads at start, and opening reports every option it ended up with
A stored option has to describe what runs. OpenCode, OMP and ACP now reject a live change
to a key they apply only when the agent opens; Claude applies such a change from the next
turn and rejects it while a turn is running. Opening reports every select or setting the
session came up with, including ones nobody set, so a native default (Cursor opening with
Fast on) is stored as option state like any other.
* fix(server): Claude rejects a live change to an option it reads when the query starts
Replacing a started query to apply the option would end its background shells and
tasks. Once the query exists only Fast can change; before the first prompt every option
still applies, because it is read when the query is built.
* fix(server): a live options patch is rejected when the session cannot apply it
A session with no applyOptions (Pi) now rejects a patch instead of storing a value that
fails on reopen. Codex applies only service_tier, plan_mode and fast_mode live, since the
approval policy and sandbox are fixed when the thread starts. Claude also rejects a launch
option change while its first query is starting, after the options have been copied.
* fix(server): plugins reject keys they do not report as settings, and Codex opening reports the speed it runs
A plugin session applied the settings it knew and silently stored the rest; it now rejects
a patch that names a key the plugin does not report. Codex opening on a model without the
saved speed ran Normal while the stored options still said priority; opening now reports
the tier it runs, as a later model switch does.
* refactor(server): option helpers take named inputs, control types come from their schemas, and a plugin accepts launch options it opened with
A whole profile is applied by the unchanged app, so a launch-only plugin option whose value
has not changed must not fail the apply. applyProviderOptionsPatch and featuresFromControls
take one object, and the protocol control types are inferred from the wire schemas.
* fix(server): ACP reports a select that appears after startup with its native value
A model switch can introduce a select (Cursor Fast) that the session did not have at open.
Its value is now stored like any other native selection instead of being skipped because
the previous snapshot did not name it.
* fix(server): a loaded agent's options stay what its session opened with when a provider default is edited
effectiveOptions was recomputed from the current config.json defaults on every state emission, so editing a default changed the snapshot of an agent whose session never received it. It is now computed when the session opens and moves only through an apply or an options_changed. Editing a default affects agents the next time they open.
* fix(server): a live apply shows the merged defaults the provider was given in the options snapshot
The snapshot took the raw patch, so a nested patch over configured defaults dropped the defaults the provider had accepted. The snapshot now takes what the provider was sent plus what it reports back; stored options still keep the raw patch.
* fix(server): an option a session drops when opening is not brought back by a configured default
The opening snapshot merged defaults over the options after the session's openOptionsPatch had removed a key. Defaults now merge into what the session was given and the patch applies after.
* fix(server): Codex keeps the speed it opened with when the legacy Fast key is removed
Opening moves a legacy fast_mode into service_tier, so removing fast_mode afterwards changed the runtime speed to Normal while the stored options still said Fast. Removing the alias now changes no speed.
* fix(server): opening snapshots keep the defaults the session launched with, and config-change pushes carry legacy profile values
An opening resolved defaults once for the session and again at registration; an edit in between showed options the session never received. Create, resume and reload now resolve once, before the provider opens, and registration starts from that. The daemon_config_changed push skipped the featureValues projection that config replies apply, so the unchanged app lost a profile's legacy values when any setting changed.
* fix(server): every opening path hands registration the options it launched with
Registration took the launch options as optional and reread the defaults when a path did not supply them, as provider-session import did. It is now required, so create, resume, reload and import each resolve once before opening. The option helpers take named inputs.
* fix(server): a saved legacy Codex Fast preference wins over a configured service_tier default
The defaults merge put a configured service_tier next to the agent's own legacy fast_mode, and Codex reads service_tier first, so the agent opened Normal. The agent's own alias now replaces the default of the key it stands for.
* fix(server): Codex keeps a Fast chosen by alias as the catalog's tier id
A live fast_mode or service_tier "fast" was stored as the alias while the apply reported the catalog id, so a later switch to a model with a different Fast id ran Fast under an option that said otherwise. The session now stores the id it accepted and drops the folded fast_mode.
* fix(server): the legacy Codex Fast alias folds into one speed on apply and in configured defaults
A patch with both speed keys left the alias in the stored options, so a reload started Fast again after the speed was removed; the apply now reports the folded alias. A configured fast_mode default now supplies the service_tier default, so removing an agent's own speed falls back to the configured Fast like opening does.
* fix(server): an OMP replacement runtime starts with the Fast the session has now
A restart (for example an approval-mode change) restored Fast from the options the session opened with, turning back on a Fast the user had switched off. The session now keeps the Fast it applied or the provider changed, which is what a replacement reads.
* refactor(server): Codex's legacy fast_mode converts to service_tier where old data enters
Stored records, config.json defaults, profiles, older clients' requests, MCP settings and
plugin hooks go through convertLegacyOptions as they come in. Past that point the defaults
merge, the manager and the Codex session never see fast_mode for Codex, so the alias branch
in the defaults merge and Codex's fast_mode handling are deleted.
* fix(server): a legacy hook's replaced speed and an apply request's options convert before they are compared or applied
A hook that swaps the canonical speed it was sent for fast_mode is diffed in canonical form, so
its replacement is not read as a deletion. An apply request carrying featureValues and options
sends one options patch with options winning, so a superseded legacy value is never applied.
* refactor(server): the agent manager converts Codex's legacy fast_mode for the adapter behind the provider id
The rename is adapter-specific, so it lives where the provider id resolves to its adapter:
AgentManager converts an agent's own options on create, resume, import, reload, apply and
draft controls, following derivedFromProviderId, so a custom provider extending codex keeps
working with a saved or requested fast_mode. Storage, MCP, schedules, hub and profiles pass
options through. The featureValues-to-options fold stays provider-agnostic at the legacy edges.
* fix(server): a rejected OpenCode auto_accept leaves the session asking for permission
applyOptions committed the new flag before approving pending requests, so a failed reply left
the session auto-approving while the stored option stayed off. The flag is restored when the
apply throws.
* fix(server): legacy Fast reaches draft discovery and saved profiles, and overlapping or replaced options keep the state they committed
Draft controls and commands convert the config they hand to the provider, so inspecting a
legacy Fast preference no longer fails Codex's strict options. A profile naming codex converts
its saved fast_mode for the app's controls. OpenCode serializes auto_accept applies so a failed
one restores exactly what it changed, and a replacement OMP runtime starts from the Fast the
session opened with.
* fix(server): an OMP replacement runtime that refuses the session's Fast publishes what it accepted
The approval-mode restart installed the new runtime's state without comparing its Fast result to the session's, so a refusal never reached options_changed, the remembered value, or storage. The replacement now goes through the same comparison as refreshState.
* fix(server): OMP turn completion publishes a Fast change, and Claude opens with Fast off on a model that has none
OMP installed runtime state in several places and only one compared Fast; every state read from the runtime now goes through one install step. Claude kept a Fast default on a model without Fast, which made unrelated options reject until the user cleared it; the session now opens with Fast off and reports it in openOptionsPatch, as the model-switch path already does.
* fix(server): a malformed Codex fast_mode is rejected, and profile options are renamed for clients by the agent manager's conversion
convertLegacyOptions read any truthy fast_mode as Fast, so the string "false" ran priority. It now accepts only true, false or null. The profile store no longer renames keys: a literal codex profile was renamed in storage while an extends-codex profile was not. The config API now converts each profile's options with the manager's adapter-aware conversion, so codex and my-codex profiles read the same.
* fix(server): a profile is stored in the current option keys, converted for the adapter its provider extends
Profiles in config.json are old data entering, so the store converts them (stored value and incoming patch) with the same conversion the agent manager uses. Storage and clients now share one vocabulary: resetting a legacy Codex profile's speed removes what was shown, and a malformed fast_mode rejects the write instead of breaking later config reads. The config API goes back to copying the field shape only. The extends chain is followed by one function over either the registry's definitions or config.json's providers.
* fix(server): profile options convert against the provider graph a write commits
A write that edited a provider and a profile together folded the profile against a shallow merge that dropped the provider's extends, so its legacy Fast was not converted. The fold now sees the same deep-merged providers the write commits.
* fix(server): draft speed shows the tier the first turn will run with
Draft discovery reported the options as requested, so a Fast configured as a legacy fast_mode or the fast alias read as Normal while creating sent the model's own Fast tier. The draft now reports the options the scratch session opened with, as registration does.
* fix(server): ACP option applies run one at a time
Two overlapping applies shared one transaction guard, so when the first was refused after the agent had moved a select, its report was dropped as 'still in a transaction' and the second saw no difference. Applies are serialized, as OpenCode's are, so each reconciles against the state the previous one left.
* fix(server): an ACP removal reports the choice the select lands on, and the Codex plan tests enable plan mode
A removal asks the select for its empty choice and compared the result against that
same empty choice, so applyOptions returned nothing and the manager stored the removal
as null while the session ran the Default choice. The difference is now computed against
what was asked (null), so the options hold the value the session runs with.
The Codex plan-mode end-to-end tests called the removed session.setFeature through an
optional call and silently skipped plan mode; they apply the option and fail loudly if
the method is gone. The MCP parity wrapper forwards listControls instead of the removed
listFeatures.
* fix(server): plugin option applies run one at a time, and OpenCode reports the Auto Accept legacy full-access turns on
Plugin sessions let two applyOptions overlap. The second finished and re-recorded the
settings the plugin held, which included a change the first had already made and not yet
reported; when the first then failed, nothing was left to report and the stored options
kept the old value while the plugin ran the new one. Applies now queue on the session,
the way the ACP adapter's do, so each reconciles against what the one before left.
OpenCode normalizes the legacy full-access mode to build with Auto Accept on, but the
session never said so, so the options the manager keeps did not hold Auto Accept and a
reopen turned it off. The session now reports it through openOptionsPatch on create and
resume.
* refactor(server): openCodeOpenOptionsPatch takes one named-fields object
* feat(app): the client and app work on options and controls
The agent snapshot's `options` are the state and `controls` only draw them.
Running and closed agents draw from the snapshot; drafts and profile forms ask
`provider.controls.list.request` with their `providerOptions`, so aliases such
as a legacy Fast profile's `service_tier: "fast"` are resolved once, by the
daemon, and the draft keeps Fast highlighted. A draft adds only the user's
edits and each provider's last-used control values; applying a profile seeds
its non-control options (e.g. `sandbox_mode`) into the draft, and a profile
form saves them back instead of pruning to the controls it can draw.
A daemon without `server_info.features.providerOptionControls` still sends
`features` and `featureValues`. `LegacyProviderOptions` in the client package
is the one place that translates snapshots, draft listings, create and apply
requests, and daemon-config profiles, so the app never branches on it.
Persisted preferences and tab layouts rename `featureValues` through tagged
COMPAT preprocessors.
The features/controls bridge moves from the server into the protocol package
so the client can reuse it.
* fix(app): drafts and profile forms send every option layer to the daemon
A cold create against a daemon without providerOptionControls routed control
options (Claude fast_mode, Codex service_tier) as providerOptions, which that
daemon does not read for them. The client now lists the provider's features
before the first create or workspace create that carries options, so callers
never prime a cache.
Draft options moved into a plain model. Applying a profile replaces provider
and options in one step, so the form's provider change that follows no longer
clears a cross-provider profile's non-control options; choosing a provider by
hand still starts over. The model sends its complete options (seed, last-used
values once the provider says it still draws them, edits) to the daemon and
the controls draw only the answer, so a remembered legacy fast is drawn as
what it runs as. A toggle shows over the last answer until its own arrives.
The profile form does the same: its edits go through resolution too.
* fix(client): a legacy create routes its options from the controls of its own config
Which options a legacy daemon exposes as features depends on the model, mode
and thinking level, not only the provider: a Haiku snapshot with no features
says nothing about Opus. The provider-keyed memory of control keys sent an
Opus create's fast_mode as providerOptions, which that daemon does not read.
A create that carries options now lists the controls for its own draft
config and splits on that answer. The memory, and the snapshot and listing
hooks that fed it, are gone.
* fix(app): a closed agent shows its stored options, and a provider switch asks about its own options
A closed agent's snapshot is projected from its stored record, which kept
controls and provider options but dropped them from the payload, so the
app drew no speed or other controls for it. The stored projection now
carries them, with the legacy features derived from the same source.
The draft read the form's provider and the model's options separately, so
for the render after a manual provider change it asked the new provider
about the previous provider's options. The request is now built from one
snapshot of the model and is absent until the model has observed the new
provider.
* fix: persisted options keep their full shape, and every legacy snapshot gains controls
Draft setups and form preferences validated option values as booleans,
strings or null, but the options they now receive come from agents and
profiles and can be objects, arrays or numbers. One such value failed
validation and wiped the whole saved layout or the saved preferences. Both
schemas now accept any JSON value.
The legacy-daemon translation skipped the snapshots carried by
agent_created, agent_resumed and timeline responses, so a created agent or
a timeline refresh left the SDK handle without options and controls.
The profile journey asserted the create request's options field; the wire
field is providerOptions.
* fix(client): a legacy profile with both featureValues and options keeps both
A legacy daemon keeps an unknown `options` field on a profile it stored
(profiles are passthrough), for example after a downgrade, while its own app
keeps editing `featureValues`. Reading such a profile took `options` and
dropped `featureValues`, so a Fast setting there was lost and the next save
replaced it. Both are merged on read with an explicit `options` key winning,
the same rule the daemon applies when it folds `featureValues`.
The draft form input of draftControlsRequest is a named type.
* fix(app): a draft stops sending a toggle the model in use does not offer
A toggle made on one model stayed in the draft's edits after the form moved
to a model whose controls do not include it, and was sent with the create.
On a legacy daemon that key is not a feature for the new model, so it went
out as a native provider option the daemon rejects. The draft now sends an
edit only while the latest answer draws a control for it, and sends it again
when a control for it returns.
* fix(server): a closed agent's options include the provider defaults, like a live one's
The stored snapshot projected only the options the agent owns, so an agent
that ran with a config.json default (for example Claude fast_mode: true)
showed the option as missing, and its legacy Fast control as off, until it
was resumed. The projection takes the same resolver the live agent uses
(the provider's defaults under the agent's own options).
* fix(app): a draft keeps sending an option no control has drawn
The previous change stopped sending any edit the latest answer drew no control
for, which also dropped options set without a control (a plugin setting a
token budget on the draft). Only an edit of a key an earlier answer for this
draft drew as a control stops applying when a later answer draws none; every
other edit always applies.
* test(server): the MCP test agent manager resolves effective options
* fix(app): a profile stops saving a toggle the model in use does not offer
Switching a profile from Opus to Haiku kept the Fast edit, so the saved
profile carried fast_mode that no control draws and an old daemon's
provider rejects at create. The profile editor now drops a key an earlier
answer drew as a control and the latest one does not, the rule the draft
already follows; options no control ever drew are never touched.
* fix(client): resetting an option on an old daemon writes the default it can take
setAgentOptions({ service_tier: null }) sent a feature write with value
null, which the old Codex setter rejects, leaving the paid Fast tier on.
A toggle now resets to off and a choice to its default; an option with
no control to name a default fails with the host-update message before
anything is written.
* fix(client): applying a config with a reset option on an old daemon writes the default it can take
applyAgentConfig({ options: { service_tier: null } }) reached an old
daemon as featureValues with null, which its Codex setter rejects. The
config now takes the same translation as setAgentOptions: a toggle goes
off, a choice goes to its default, and an option with no named default
fails with the host-update message before anything is sent.
* feat(plugin): provider plugins declare option controls and receive option changes
A provider plugin reports `controls` and the `providerOptions` it runs in
`session.config`, and receives running-session changes as
`session.configure` `changes.providerOptions` (null returns a key to its
default), under the same apply-or-throw contract as built-in providers.
A control may declare a `defaultValue`; a value a plugin reports at that
default for a key nobody set stays unset instead of being pinned to the agent.
A plugin that still reports `settings` keeps working: the daemon reads each
setting as a control plus an option and sends changes in both shapes
(COMPAT(plugin-provider-settings)).
The app resolves a control's icon as a Lucide name when it is not one of the
built-in kebab-case names, so a plugin's icon shows live and in drafts.
Public docs and the provider-direct example use only `providerOptions` and
controls; the migration doc gains the rows.
* fix(server): a plugin provider's reported options are stored like any provider's
A control's `defaultValue` only says what the control shows for a key with no
value. Opening and applying store every option the session reports, defaults
and values nobody set included, as built-in providers do; the special case that
left a value at the default unset is removed.
* fix(server): a plugin decides which options it applies live
A plugin reporting `controls` and `providerOptions` receives every key a live
change touches, with or without a control, and applies them or rejects the
whole request. The daemon's live-key guard now runs only for a plugin still
reporting `settings`, which cannot refuse a key.
* fix(server): a rewound plugin session reopens with its options as they stand
The reopen merged the plugin's reported options over the launch record, so a
key the plugin reset by leaving it out came back at its launch value. The
session now keeps the committed options, removals included. The provider-direct
example also refuses a combined request before changing anything.
* fix(server): a plugin that stops reporting an option has reset it
The adapter only visited keys present in a new report, so a key the plugin
dropped to return to its default kept its stored value and its rewind value.
A previously reported key that disappears is now reported as removed.
* fix(server): an apply reports the options the plugin reset along the way
Applying and unsolicited reports now share one conversion from the plugin's
report to a patch, so a key the plugin stops reporting is a removal on both.
* fix(server): an apply never reads a requested option as one the plugin dropped
A plugin may run an option it never reports. Only a key it reported before can be
reported as removed, and a rewind reopens with the accepted request plus what the
plugin reported back.
* test(server): an apply to a plugin that reports controls sends its changes as provider options
* feat(plugins): agents and workspaces carry plugin metadata
Plugins had nowhere to keep their own data on an agent or workspace, so
Phase 5 and 6 surfaces would each have invented a side store. Both now
hold an opaque Record<string, JsonValue> that Paseo stores, snapshots and
hands to hooks without reading a key.
Set at creation or changed with agent.metadata.update.request /
workspace.metadata.update.request (top-level keys replace, null removes).
Persisted on the agent and workspace records, so it survives a restart.
Reaches agent.create.before (which may add to it), agent.open.before
(read-only), agent.create.after and workspace.create.before/after.
Gated once on server_info.features.entityMetadata; the client throws
"Update the host" instead of sending to a host without it.
* fix(plugins): an agent.open.before result may omit metadata
The result schema required metadata, so a plugin written before metadata
existed that returns its own object instead of spreading the request
failed validation and the session open failed, even for agents with no
metadata. An omitted metadata now means unchanged and is filled back in
from the request so later plugins in the chain still see it; a present
value that differs is still rejected.
The restart test now sets metadata through the PaseoApi handles and
checks that agent.open.before sees the patched value after the restart.
* fix(plugins): omitted metadata in a hook result means unchanged
An agent.create.before hook that returned its own { config, env } dropped
metadata that an earlier hook in the chain had added, because only the
caller's original metadata was restored at the end. The omitted field is
now carried forward at each hook handoff, as agent.open.before already
does.
The agent.open.before result type also required metadata, so an older
TypeScript plugin that returned its own object no longer compiled even
though the daemon accepted it. The result type now makes it optional.
* fix(plugins): omitted metadata in a workspace.create.before result means unchanged
Same handoff as agent.create.before: a legacy hook that returned only
{ source, ... } dropped the caller's metadata and anything an earlier
hook added, and workspace provisioning then defaulted it to {}. The
omitted field is carried forward at each handoff; an explicit {} still
replaces.
* test(server): fixtures for agents and workspaces carry metadata
Agent snapshots and workspace records now always carry metadata, so the
hand-built fixtures and expectations in three existing suites name it.
* docs(plugins): the hook result comment names metadata as the field that stays
* fix(server): reopening a closed agent keeps metadata saved just before
The loader reads the agent record, then queues a resume. A setMetadata
queued ahead of that resume saves a newer record, and the open hooks saw
it, but registration took metadata from the loader's older read and wrote
it back over the saved patch. Registration now uses the metadata read
inside the lifecycle lane.
* docs(server): name the metadata gap when a handle-less agent is recreated on load
* docs(server): the handle-less recreate gap spans the agent.create.before hook
* docs(plugins): an agent.open.before hook starts writes to its own agent without awaiting
An agent.open.before hook that awaits setMetadata (or any write) on the
agent being opened deadlocks: the write joins the agent's lifecycle lane
behind the open, and the open waits on the hook until it times out. The
hooks reference now says so and shows the supported shape, a write
started without awaiting, which runs right after the open.
Deferred, not in this stack: reject such a write immediately instead of
letting the hook time out. The host knows which plugin hook it is
awaiting for the agent and which channel the write arrives on, so the
rejection can be keyed on "this plugin is inside an open hook for this
agent" without cross-process tagging, and an unrelated client's write
would still wait as it does today.
* feat(plugins): composer items and controls in all three composers
A plugin registers a component with addComposerItem and reads and changes the
composer through useComposer(): provider, model, mode, thinking, options,
prompt, attachments and agent metadata. The agent, draft and New workspace
composers provide the same ComposerInput and ComposerAgent halves, so a
command reaches the right model without the plugin knowing which composer
it sits in.
Toggle, Select and Action are drawn by one ComposerControl renderer that
the thinking select and provider option controls now use too. Controls sit in
the toolbar when there is room and become rows in the existing overflow sheet
otherwise, so on a phone they appear in the overflow. Plugin items count in
the density presence so the composer condenses to make room. Model and mode
stay specialized but share the trigger, density and overflow.
Draft metadata is kept locally, sent at creation and reset when the prompt is
sent. PluginAgentSnapshot.metadata, deferred from the metadata phase, lands
here. The unused provider badge is removed.
vitest resolves @floating-ui/react-native to its ESM build: the CJS entry
requires the real react-native, whose Flow sources do not parse.
* fix(plugins): composer commands run on current state, report failures, and reach the overflow sheet
Draft commands read the state captured at render, so a plugin's
`await setProvider(); await setModel()` acted on a stale provider. The draft
form and options are now plain stores that commands read live, and
`setProvider`/`setModel` reject an unavailable provider or a model the loaded
catalog does not list. Draft `options` include the plugin's pending edits.
Shared composer controls own the pending state and show a rejected command as
an error toast, like the built-in controls. Callback payloads are objects
(`{ value }`, `onPress(event)`) so they can grow without breaking plugins.
The overflow sheet's content portals to the app root and lost the composer's
agent, input, toast and query contexts, so plugin items drew nothing in it.
`useComposerSheetBridge` carries them across.
* test(plugins): composer commands tested on the models, overflow sheet in Playwright
The draft composer's command rules (unavailable provider, unlisted model,
edits belonging to the provider the form has now) moved from the React hooks
into the form store and the draft options model. They take the host's catalog
as a plain input, so the command tests run on the real models with no module
mocks and no renderer.
The overflow-sheet test faked the portal it was meant to verify. It is
replaced by a Playwright spec that installs the composer-controls example and
checks its rows in the narrow composer's Features sheet; it fails when the
sheet's context bridge is removed.
* test(plugins): lock draft option ownership without a subscription, name the overflow-sheet steps
The draft option test subscribed the options model to the form, which in the
app happens in a render effect, so it passed without the model observing the
form's provider itself. A case with no subscription now fails when that
observation is removed.
The overflow-sheet spec reads as steps through a helper.
* fix(plugins): composer option resets and model checks match the documented commands
A draft kept `null` as an option value, so `setOptions({ key: null })` sent the null to the
provider and creation failed. A null edit now removes the key from every draft layer, as the patch
does on a running agent. A running agent's `setModel` accepted ids its catalog does not list; it
now rejects them the way a draft does.
The composer controls imported the toast host through the toast context, which pulled the host's
animation module into every module that loads plugins. They use the API-only context instead.
The migration guide's registration table gets the `addComposerItem` row its test requires.
* fix(plugins): draft metadata survives a remount, plugin commands follow the composer lock
Draft metadata was component state while the prompt lived in the draft store, so a composer that
remounted across the compact breakpoint kept its text and lost the plugin's choices. It now lives
in the draft store beside the prompt, is cleared when the draft is sent, and is not cleared when
the prompt is emptied. It is not persisted across app restarts.
While the New workspace composer is creating the agent, the built-in controls are disabled but
plugin controls and commands were not. They read the composer's own lock: Paseo controls render
disabled, and setPrompt, insertText and the agent commands refuse.
The options and metadata a plugin reads are frozen copies, through the same helper the timeline
projection uses. The composer control test mounted components in JSDOM, which the testing rules
ban; it now runs in the browser project.
* fix(plugins): a kept command honors the composer lock, and useAgent metadata is frozen
A plugin action that awaits an RPC holds the composer it was created with. The lock was captured
at that render, so a submit that started meanwhile did not stop its insertText. Commands now ask
the composer whether it is locked when they run.
The agent snapshot a plugin gets from useAgent froze only the outer metadata record. It now uses
the same deep frozen copy as the composer.
* fix(plugins): attachment commands honor the composer lock
The creation request captures the attachments when it starts, so an attachment a plugin added or
removed afterwards was lost or left in the request. They refuse like the prompt commands.
* fix(plugins): a built-in select in the compact sheet keeps its action name
The shared control named every icon-only select by its value ("Speed: Fast"),
including in the compact sheet, where the row already shows the value. The sheet
row is named by its action again ("Select speed"), as before the shared renderer.
* test(app): composer item test installation carries the subscribe main added to InstalledPlugin
Main's plugin streaming (#8527) made subscribe a required field of InstalledPlugin; the
composer item model test builds its own installation and predates it.
* feat(plugins): New workspace items set the branch and workspace metadata
addNewWorkspaceItem mounts a plugin field on the New workspace screen, and
useNewWorkspace() exposes host, project, isolation, base ref, branch and
metadata with commands that follow the composer item rules: they act on the
screen's state when they run, resolve once applied, reject while the screen
is locked, and take JSON payloads. Host and project are read-only because a
command that switched them would unmount the item that called it.
The screen's pickable state (base selection, manual isolation, branch,
metadata) moves into one plain store so pickers and plugin commands write to
the same place; the screen reports derived facts to it on each render. The
branch is sent as source.branchName only for a branch-off worktree, and
metadata on workspace.create.
Items render through ComposerControl with a new "field" surface (badge on
desktop, full-width field when compact). Combobox ships here: Paseo owns the
loading and error states, keeps previous results, and drops superseded
searches.
* fix(plugins): New workspace commands work from mount effects, and branch and base ref match what creation uses
A command called from a New workspace item's mount effect rejected because the
setup model only received its inputs and the pickers' handlers from a passive
effect on the screen, which runs after a child's. The model now takes its
inputs at construction, picks the base and isolation itself, and is refreshed
in a layout effect; the screen supplies the side effects of a pick (the PR
attachment, the remembered isolation) as listeners.
The branch a plugin sets was dropped when no base was resolved, such as on a
detached HEAD, because it was sent only for an explicit branch-off. It now
applies whenever the worktree cuts a new branch, decided in one place that
mirrors the daemon.
baseRef.name was the display-shortened branch name, so setting back what was
read based the worktree on the local branch instead of the origin one. It is
now the exact ref creation uses, and host and baseRef are frozen like project
and metadata.
* fix(plugins): New workspace commands have the same effects as a pick, even from a mount effect
The screen attached the pick side effects (remembering the isolation, syncing the picker's PR
attachment) as listeners in its own useEffect, which runs after a child item's mount effect. A
mount-time setIsolation("worktree") switched the screen but was not saved, and setBaseRef skipped
the attachment sync. The effects are now injected when the setup model is built, so a command and
a pick run the same code from the first render. Closing the picker stays in the picker's handler.
* fix(plugins): a Combobox shows a failed refresh instead of the old results
The query keeps its cached data when a reopened list fails to refresh, so the old items stayed
selectable and the error never showed. A failed search now clears the list so its error shows.
The migration guide maps addNewWorkspaceItem, which its registration-coverage test requires, and
the superseded-search test waits on a later search instead of sleeping.
* fix(plugins): a branch and metadata chosen for one project are not sent for another
The setup model outlives the project and host it was chosen for, so an issue picked for project A
was sent on workspace.create after the user switched to project B. The model now drops the branch
and metadata when the project changes (project ids are host-scoped, so a host switch counts). A
project that is still resolving is not a switch. The base selection is left alone: the built-in
picker keeps a branch pick across a project switch, and that behavior is unchanged.
* test(plugins): the project-switch spec expects the other project's metadata to be empty
* fix(plugins): a host switch drops the branch and metadata even when the project id repeats
A remote repository keeps the same project id on every host, so comparing the project id alone
missed a switch from host A to host B for the same repository. The target is now the host and the
project id together.
* fix(server): a branch the caller named is not renamed from the first agent context
The daemon marked every new branch-off worktree for automatic naming, recording the chosen branch
as the placeholder, so the first agent's prompt could rename a branch a plugin or client had
named through branchName. Only a branch the daemon derived from the worktree slug is a
placeholder now.
* fix(plugins): New workspace items remount when the project or host changes
An item stayed mounted across a project switch, and its Combobox kept the previous project's
results (same query key, cached data) selectable until the next search returned, so an issue from
the old project could set the branch and metadata on the new one. A Combobox cannot detect that
its inline search now means another project, so the item host keys the items by host and project
id. Items and their search results start fresh for the new target, and a mount effect sets up
again after the model clears the branch and metadata.
* docs(plugins): a layout-effect command on mount also races the reset on a project switch
* fix(plugins): a New workspace item's commands stop working once its project or host is left
Every item received the same command functions, which act on the current screen state. A lookup
that finished after the user switched projects could still call setBranch or setMetadata and
overwrite the new project's choices; the reset on a switch only clears what was already written.
The setup model now hands out commands per target with commandsFor({ serverId, projectId }).
They reject with a "moved to another project or host" error when the screen is on another target,
like the lock rejects, so the promise only resolves when the change took effect.
* feat(plugins): reach a running agent's composer by reference
useComposers(selector) in any plugin component and composers(selector) on every command context return the composer of a running agent, named by id or by focus, whether or not its tab is open.
A running agent now has one store-backed composer model apart from any mounted tab. The mounted composer plugs its cursor-aware insert in while mounted; otherwise insertText and setPrompt write the agent's saved prompt. model and thinking resolve through the provider catalog with the picker's own function, so a read equals what the picker shows.
The host reads one ComposerModel from context and no longer knows whether it is in an agent, a draft or New workspace. The daemon rejects an unlisted model or thinking option for a running agent, replacing the app-side guard.
* fix(plugins): composers by reference follow the focused agent and reach retained composers
- `{ focused: true }` is the agent Command Center hands to agent items, published by its
registration through plugins/focused-agent.ts and empty outside a workspace. The composer source
reads that module instead of the remembered workspace selection, the navigation store and the
layout store, which also removes the require cycles those imports added.
- A composer the app retains in a workspace the user left has no live input. `insertText` and
`setPrompt` now store the text with `replaceDraftText`, which asks the mounted input to show it;
before, the text reached the store but the retained input never displayed it.
- `model` and `thinking` read exactly what the picker resolves before the catalog loads (no
models), then re-resolve when it lands, instead of reading null.
- The daemon validates a thinking option against the model the agent runs: the runtime model when
the provider lists it, else the configured one.
- The composer sources subscribe to query-cache updates only. A query another component creates
during its render is announced as `added`, and a subscriber that updates then made React warn
about updating a component while rendering a different one. A catalog read no longer starts its
load synchronously either.
* test(plugins): a Command Center agent item reaches the composer through the real client state
The command-path test now reads workspace and agent through
createPluginClientStateSource over seeded session stores, so an empty
lookup, which makes the Command Center return before calling the item,
would fail it. Also records at the focused-agent publisher that it
publishes in an effect.
* fix(plugins): a running agent's composer validates and refreshes against the agent's own catalog
setAgentModel and setAgentThinkingOption checked the provider's global catalog,
while the picker reads the catalog of the agent's directory, so a model only
the project lists was rejected. The validation input now carries the directory.
The composer read the catalog once with no observer, so reconnect repair, which
refetches only active queries, never refreshed it and a failed load never
retried. While anything watches, the composer now observes the catalog query.
The shared composer map kept every agent's composer for the session. It is now
dropped when nothing watches it and no composer is mounted.
* fix(plugins): useComposers holds the composer it names, and a composer lives as long as its agent
PluginComposersSource.subscribe watched the stores directly and never the
composer model, so a component using useComposers did not keep the model's
catalog observer alive and reconnect repair skipped it. subscribe now takes the
selector and holds the model it names, following focus.
Dropping a composer when it went idle left a command a plugin had saved calling
a dead model after the tab reopened. A composer is now dropped when its agent
leaves the session and nothing watches it or has one mounted.
* fix(plugins): a composer refreshes a stale catalog on resumed demand, shares its store watches, and is released with its last holder
Watching again, or reading once, after the catalog was invalidated or failed
while nothing watched now starts one deduplicated refresh; the observer
refetches on mount. Reconnect repair only refetches watched queries, so an
unwatched catalog stayed stale.
Each subscribe installed its own session and draft store callbacks, so N
watchers received N squared notifications, including for other agents' drafts.
One pair of subscriptions now serves all listeners, and the draft callback
ignores drafts of other agents.
A composer whose agent was removed while it was held was only released on the
next session change. The last holder letting go now checks too, and an archived
agent's idle composer is releasable.
* test(server): live model and thinking switches use values the provider lists
The daemon rejects a model or thinking option its provider does not list, so the live-preferences
e2e can no longer switch to a made-up id. A provider that lists nothing to switch to skips the case.
* test(server): the test providers list two models with thinking options, and the live switches run
The fake providers listed one model and no thinking options, so once the daemon validated
selections against the catalog the live model and thinking switches had nothing valid to switch to
and skipped. Every fake provider now lists two models with thinking options, and the switches run
for all three providers without depending on installed binaries.
* fix(plugins): a retained composer that is not showing takes plugin writes through the draft
On native a retained composer keeps its input mounted under display none, so the ref alone said
the input was live and a plugin insertText replaced the stale selection instead of appending to
the draft. The input is live only while its retained panel is showing.
* test(plugins): the retained input liveness test runs in real Chromium
* fix(plugins): a model or thinking change, an unreported option key, and a draft model reach the provider unchecked
Paseo no longer checks these in front of the provider. A running agent's model or thinking
change no longer waits for the catalog or checks it, a plugin provider is sent every option
key and refuses what it cannot apply, and a draft setModel keeps the id as the model picker
does.
* fix(plugins): a plugin reporting settings is sent every key of an options patch
* Run option, model, thinking and mode changes in the agent's lifecycle lane
The agent manager owns all sequencing for one agent: applyOptions, setModel,
setThinkingOption and setMode now run in the same lane as open, close, archive,
labels and metadata, so a provider session never sees two of them at once.
Provider-side serialization (the applying chains in the ACP, plugin and OpenCode
sessions, and Claude's apply-over-current-options handling) is deleted.
* Reopen a handleless agent inside its lifecycle lane
A stored agent with no resumable session is now reopened by
AgentManager.createAgentFromRecord, which rereads the record inside the agent's
lane before creating the first session. A metadata or label write queued ahead
is in the record it reads; one queued behind applies to the live agent. The
loader no longer passes a record it read outside the lane. createAgent itself
also runs in the lane, like resume.
* Return the draft's options from AgentClient.listControls
listControls now answers { controls, options } like AgentManager.listDraftControls:
the options a session opened from the draft would run with, with config
defaults merged in and anything the provider resolves natively for the ones
nobody set. ACP fills unset feature selects from the probe session's
currentValue, so a fresh Cursor draft shows the Fast selection the agent opens
with instead of no selection. The legacy features response and the MCP draft
listing already project from the manager's result, so they follow.
* Send a profile's options on every write, empty included
The profile form always sends options. The daemon reads a write without them as
one from an app that predates options and folds featureValues over the stored
ones; a write that carries them, empty included, replaces them. A profile left
with no options is stored without the key, so removing the last one persists.
* Read a remembered Codex fast_mode as its service tier
The create-agent preferences convert the legacy key on read, keyed on the
built-in codex provider id, so the Codex draft shows the Fast selection the user
last used. Tagged COMPAT(featureValues) with the other legacy-option shims.
* Document one option change at a time per agent
The agent manager runs option, model, thinking and mode changes in the agent's
lifecycle lane; providers do no sequencing. The hook deadlock warning now covers
agent.create.before and option writes.
* Flatten the profile fold's base selection and drop an unused import
* Carry the New workspace context through portal hosts and the draft's metadata through retry
* Convert legacy options when resolving a stored agent's effective options
* Keep stored-record reads tolerant of a legacy option the conversion rejects
* Keep plugin attachment items separate from the draft's own
* Hand plugin command inputs to the host as its own copies
2539 lines
163 KiB
Markdown
2539 lines
163 KiB
Markdown
---
|
||
title: 插件参考
|
||
description: 本地插件文件、客户端和服务端运行时、平台限制、贡献、RPC、生命周期、宿主环境和 CLI 命令。
|
||
nav: 参考
|
||
---
|
||
|
||
# 插件参考 {#plugin-reference}
|
||
|
||
从[插件快速上手](/docs/plugins)开始创建你的第一个插件。
|
||
|
||
要迁移已有的插件?请按照独立的[运行时入口迁移指南](/docs/plugins/migration)操作。
|
||
|
||
本地插件是安装到某个 Paseo daemon 中的目录来源。插件可以贡献:
|
||
|
||
- 为 Paseo 客户端添加 React Native 界面,以及侧边栏顶部和底部项;
|
||
- 作为工作区标签页打开的工作区面板和 Agent 面板;
|
||
- 用于查看或编辑匹配文件的[文件面板](#file-panels);
|
||
- Command Center 中的全局、工作区和 Agent 操作;
|
||
- 消息 Composer 中的斜杠命令;
|
||
- 经过转换的、以及由 daemon 推送的 Agent 时间线行;
|
||
- **设置 → 外观** 中的浅色和深色主题;
|
||
- 在 daemon 旁运行、经过 schema 校验的 RPC 处理函数;
|
||
- 通过 TypeScript SDK 执行的普通 Paseo 操作;
|
||
- 消息 Composer 中可搜索的外部资源。
|
||
|
||
插件代码是受信任的,没有沙盒。客户端界面在 Paseo 应用中运行。后端贡献在一个子进程中运行,可以访问 daemon 所在的机器,包括它的文件、进程、凭据和网络。
|
||
|
||
## 已连接的主机 {#connected-hosts}
|
||
|
||
客户端界面中的 `usePaseo().hosts` 与服务端处理函数或生命周期钩子中的 `context.paseo.hosts`
|
||
使用相同的 [SDK 主机 API](/sdk/reference#clienthosts)。它们描述所选 daemon 保存的连接。
|
||
现有的 `useHosts()` 和 `getPaseoClient(serverId)` 仍描述应用已知的主机。
|
||
|
||
```ts
|
||
const remote = await context.paseo.hosts.client("srv_build_box");
|
||
try {
|
||
return await remote.workspaces.list();
|
||
} finally {
|
||
await remote.dispose();
|
||
}
|
||
```
|
||
|
||
返回的 API 拥有自己的观察。销毁插件 API 时也会释放由它派生的作用域。
|
||
远程调用应留在拥有它的处理函数或生命周期作用域内。调用 B 不会把 B 的插件生命周期钩子转发到 A。
|
||
通过路由插件创建的注册项保留其来源身份,其中包含经过认证的链路和发起调用的插件。
|
||
|
||
## 项目文件 {#project-files}
|
||
|
||
`paseo plugin init /absolute/path/to/my-plugin` 会创建:
|
||
|
||
```text
|
||
my-plugin/
|
||
paseo-plugin.json
|
||
index.client.tsx
|
||
index.server.ts
|
||
client/greeting.tsx
|
||
server/greeting.ts
|
||
shared/greeting.ts
|
||
package.json
|
||
tsconfig.json
|
||
```
|
||
|
||
必需的根 manifest 是 `paseo-plugin.json`:
|
||
|
||
```json
|
||
{
|
||
"id": "my-plugin",
|
||
"description": "Reviews changes before merge",
|
||
"requirements": { "paseo": ">=0.8.0" }
|
||
}
|
||
```
|
||
|
||
| 字段 | 是否必填 | 行为 |
|
||
| -------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
|
||
| `id` | 是 | 默认的安装 ID。 |
|
||
| `name` | 否 | 注册表、网站、**设置 → 插件** 中的插件行以及插件设置页标题使用的非空显示名称。没有 `name` 时显示插件 ID。 |
|
||
| `icon` | 否 | 插件包内 PNG 文件的相对路径。 |
|
||
| `media` | 否 | 包内图片或视频路径的数组,或 HTTPS URL。空数组是有效的。 |
|
||
| `description` | 否 | 在 **设置 → 插件** 中显示在插件名称下方的非空摘要。 |
|
||
| `translations` | 否 | 其他语言的展示文本,由注册表读取,Paseo 会忽略它。见[其他语言](/docs/plugins/publishing#other-languages)。 |
|
||
| `requirements` | 否 | 支持的 Paseo 版本,见下文。 |
|
||
| `build` | 否 | 准备命令,见 CLI 参考。 |
|
||
|
||
路径相对于 `paseo-plugin.json`,使用正斜杠,并且不能包含 `..` 片段。即使 `media` 在运行时 manifest 中是可选的,可视化插件的注册表展示页仍然需要一张界面图片。接受的格式和展示页要求见[图标和截图](/docs/plugins/publishing#icons-and-screenshots)。Paseo 会校验这些引用,但不会打开本地资源或获取 URL。
|
||
|
||
未知的顶层字段会被忽略。已知字段仍然会被校验,`requirements` 中的未知键会被拒绝,以免拼写错误的约束悄悄跳过兼容性检查。使用 `name`、`icon` 或 `media` 的 manifest 需要 Paseo 0.11.0 或更高版本;较旧的 daemon 会在安装时拒绝这些字段。
|
||
|
||
### 版本要求 {#requirements}
|
||
|
||
`requirements` 是一个可选对象。它目前支持的键 `paseo` 接受一个 npm semver 范围。省略 `requirements.paseo` 表示 `<0.8.0`:即该插件早于第一个破坏性的插件版本。Paseo 0.8 及更高版本会拒绝它,并附上[迁移指南](migration)的链接。空字符串、无效的范围以及未知的 manifest 要求键都会被拒绝。
|
||
|
||
| 范围 | 兼容的版本 |
|
||
| ---------------- | ---------------------------------------------------- |
|
||
| `>=0.8.0` | 0.8.0 及之后的版本,包括预发布版本和将来的破坏性版本 |
|
||
| `^0.8.0` | 0.8.x 版本,包括预发布版本 |
|
||
| `>=0.8.3 <0.9.0` | 0.8.3 到最后一个 0.8 补丁版本,包括预发布版本 |
|
||
|
||
预发布的 Paseo 版本也满足其稳定核心版本(`major.minor.patch`)所满足的范围,因此 `0.8.0-beta.1` 满足 `>=0.8.0`,但不满足 `<0.8.0`。
|
||
|
||
`>=0.12.0-beta.4` 这样的预发布最低版本也会通过稳定核心 `0.12.0` 接受 `0.12.0-beta.3`。若要排除同一核心的较早构建,请使用不同的稳定核心版本。
|
||
|
||
`paseo plugin init` 会写入 `>=` 加当前 CLI 版本,并为类型检查固定匹配的 SDK。采用更新的 API 时,请提高最低版本。当某个之后的版本不兼容时,请添加上限;只有下限并不能保证免受将来破坏性变更的影响。
|
||
|
||
daemon 会在安装、运行准备命令或加载插件之前检查自己的版本,并在启动、启用和重新加载时再次检查。被拒绝的更新会保留已安装的修订。每个已连接的应用在执行客户端代码之前会检查自己的版本,并在 **设置 → 插件** 中显示不兼容。兼容的 daemon 并不能让较旧的应用变得兼容。没有客户端入口的插件不要求已连接的应用匹配。
|
||
|
||
例如:`Plugin "review" requires Paseo >=0.8.0. Your daemon is 0.7.2.` 请使用兼容的插件修订,或更新提示中的运行时。0.8 之前的版本不理解这个 manifest 字段,也无法显示这条新的诊断信息。
|
||
|
||
### 运行时入口 {#runtime-entries}
|
||
|
||
| 入口 | 运行时 | 接收 | 何时需要 |
|
||
| ------------------ | ---------------------- | --------------------- | ------------------------------------------------ |
|
||
| `index.client.tsx` | Paseo 应用,每个客户端 | `PluginClientContext` | 插件有任何 UI、回调、主题或附件来源时 |
|
||
| `index.server.ts` | daemon 子进程 | `PluginServerContext` | 插件贡献处理函数、钩子、设置持久化或 Provider 时 |
|
||
|
||
至少需要一个入口;两者都接受 `.ts` 或 `.tsx`。仍然只有旧 `index.ts` 的目录会加载失败,并指向[迁移指南](/docs/plugins/migration)。
|
||
|
||
插件、界面、侧边栏项、工作区面板、Command Center 项、附件来源和斜杠命令的 ID 以小写字母开头,只包含小写字母、数字或连字符。
|
||
|
||
生成的 `package.json` 把 `@getpaseo/plugin` 和其他宿主模块安装为开发依赖,用于本地类型检查和测试。它们的运行时实例由 Paseo 提供。使用者在添加插件时不会安装它们。
|
||
|
||
其他每个模块都位于以下三个目录之一。在其中嵌套没有问题;位于插件根目录的模块会导致编译错误。
|
||
|
||
| 目录 | 编译进 | 用于 |
|
||
| --------- | -------------- | --------------------------------------------------- |
|
||
| `client/` | 仅应用的包 | React、React Native、hook、样式、界面、面板和回调。 |
|
||
| `server/` | 仅 daemon 的包 | Node API、本地资源、凭据和 RPC 处理函数。 |
|
||
| `shared/` | 两者 | Zod RPC 契约以及两个运行时都会导入的普通值。 |
|
||
|
||
## 运行时翻译 {#runtime-translations}
|
||
|
||
**尚未发布的 API。** 发布插件前,请将 `requirements.paseo` 设置为首次包含这些 API 的版本。[翻译示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/translations) 暂用 `>=0.12.1`,实际引入版本尚未确定。
|
||
|
||
将 JSON 资源随客户端代码一起打包。必须提供英语(`en`);其他语言使用规范语言标签,例如 `zh-CN`。资源由嵌套对象和字符串叶节点组成。其他语言可省略键,缺失时回退到英语。定义翻译时会拒绝未知键、无效资源、非规范语言标签,以及与英语不一致的插值占位符。
|
||
|
||
```ts
|
||
// client/messages.ts
|
||
import { defineTranslations } from "@getpaseo/plugin/client";
|
||
import en from "./locales/en.json";
|
||
import zhCN from "./locales/zh-CN.json";
|
||
|
||
export const messages = defineTranslations({ resources: { en, "zh-CN": zhCN } });
|
||
```
|
||
|
||
在插件的 `tsconfig.json` 中启用 `resolveJsonModule`。Paseo 在本地打包这些导入,不通过网络加载。资源对示例:
|
||
|
||
`client/locales/en.json`:
|
||
|
||
```json
|
||
{
|
||
"title": "Workspace notes",
|
||
"add": "Add note",
|
||
"notes_one": "{{count}} note",
|
||
"notes_other": "{{count}} notes"
|
||
}
|
||
```
|
||
|
||
`client/locales/zh-CN.json`:
|
||
|
||
```json
|
||
{
|
||
"title": "工作区笔记",
|
||
"add": "添加笔记",
|
||
"notes_other": "{{count}} 条笔记"
|
||
}
|
||
```
|
||
|
||
在 React UI 中使用插件的 hook:
|
||
|
||
```tsx
|
||
import { useTranslation } from "@getpaseo/plugin/client";
|
||
import { Text } from "react-native";
|
||
import { messages } from "./messages";
|
||
|
||
function NoteCount({ count }: { count: number }) {
|
||
const { t, locale } = useTranslation(messages);
|
||
return <Text accessibilityLanguage={locale}>{t("notes", { count })}</Text>;
|
||
}
|
||
```
|
||
|
||
对于由宿主持续渲染的标签,使用 `messages.message(key, options?)`:
|
||
|
||
```tsx
|
||
client.addScreen({
|
||
id: "notes",
|
||
title: messages.message("title"),
|
||
Component: Notes,
|
||
});
|
||
```
|
||
|
||
`PluginText` 为 `string | PluginMessage`。消息引用支持界面标题(包括标题函数)、设置与侧边栏标题、工作区与文件面板标题、Command Center 标题、斜杠命令描述与参数提示、按钮标题与标签、嵌套菜单标题、附件来源标题/选择器标题/搜索占位文本,以及主题名称。现有的字符串仍然有效。保持 ID、斜杠命令名称、图标、URL 和命令搜索关键词不变。
|
||
|
||
描述符类型保留现有的字符串默认值。为翻译描述符显式标注类型时,传入文本类型参数,例如 `PluginScreenContribution<PluginText>`。内联注册会自动推导类型。
|
||
|
||
回调中的临时文本使用 `client.i18n.t(messages, "add")`,它读取调用时的应用语言。`client.i18n.locale` 和 hook 的 `locale` 返回解析后的应用语言,即使某条消息回退到英语。不要将 `t` 的返回值用作持续注册的标签。
|
||
|
||
键由英语资源推导。`notes` 等复数基础键要求数字类型的 `count`;i18next 根据语言选择 `_zero`、`_one`、`_two`、`_few`、`_many` 或 `_other` 形式。命名插值使用 `{{name}}`,调用时传入 `{ name: value }`。TypeScript 将导入 JSON 的字符串值拓宽为 `string`,因此 JSON 插值名称在运行时检查;内联字面量资源还能推导必需的插值名称。`{{amount, number}}` 等 i18next 格式化语法使用解析后语言的 `Intl` 格式化器。
|
||
|
||
显式选择的应用语言优先于设备语言;“跟随系统”使用应用支持的语言解析规则。连接到同一 daemon 的不同应用各自保留语言设置。切换语言会更新已挂载的 UI 和宿主标签,不重新加载插件、不重置组件状态,也不替换 RPC/查询缓存。网页和原生的模态框与弹出层均收到相同的安装上下文。
|
||
|
||
RPC 返回与语言无关的结构化数据,在客户端翻译呈现内容。保持用户草稿、agent 输出、原始 daemon 错误、日志与终端文本不变。manifest 列表翻译和 `OVERVIEW.<locale>.md` 是独立的发布元数据,参见[你的列表页面](publishing#your-listing-page)。
|
||
|
||
## 运行时模块 {#runtime-modules}
|
||
|
||
Paseo 从对应的入口构建每个包。把 `client/` 导入 daemon 的包、把 `server/` 导入应用的包,或在应用的包中任何位置导入 Node 模块,都会导致编译错误。服务端导入 React、React Native 或客户端 SDK 入口也会失败。共享代码只能导入共享代码:不能导入 Node、React、特定运行时的 SDK 入口或特定运行时的类型。
|
||
|
||
SDK 根入口(`@getpaseo/plugin`)只包含共享数据、schema 和与运行时无关的辅助函数。从 `/client` 导入客户端 context 和 hook,从 `/server` 导入服务端 context 和生命周期契约,从 `/client/ui` 导入 UI。这些规则同样适用于类型导入和传递依赖。`/client/host` 是应用宿主的私有模块;插件不能导入它。
|
||
|
||
### 客户端运行时 {#client-runtime}
|
||
|
||
Paseo 为客户端代码提供以下模块:
|
||
|
||
| 模块 | 用于 |
|
||
| ---------------------------- | -------------------------------------------------------------------------------------------- |
|
||
| `@getpaseo/plugin` | 共享数据、`defineRpc`、`defineSettings`、`defineAttachmentSource`、`RpcInput` 和 `RpcOutput` |
|
||
| `@getpaseo/plugin/client/ui` | Paseo UI 组件和 UI hook |
|
||
| `@getpaseo/plugin/client` | 客户端贡献 context、`usePaseo`、`useRpc`、`useSettings` 和数据 hook |
|
||
| `@tanstack/react-query` | 请求状态和缓存 |
|
||
| `react` | 组件和 hook |
|
||
| `react/jsx-runtime` | 编译后的 JSX |
|
||
| `react-native` | 跨平台 UI |
|
||
| `zod` | 共享 schema |
|
||
|
||
宿主拥有与之配套的 React 和渲染器版本。SDK 的 React peer 范围允许补丁版本差异,以便工具链和 Node 使用者使用;它不会改变应用固定的 React 版本,也不保证与其他宿主的渲染器兼容。
|
||
|
||
这些确切的模块标识符使用宿主的运行时实例。请求其他宿主模块的客户端包会以 `Module "<name>" is not available in plugin client code` 失败。
|
||
|
||
不要导入 `lucide-react-native`、`react-native-svg` 或 DOM 库。把贡献的 `icon` 字段设为一个 [Lucide 图标名称](https://lucide.dev/icons/)、内联 SVG 或组件;Paseo 会校验图标并渲染它。参见[图标](#icons)。
|
||
|
||
### 跨平台规则 {#cross-platform-rules}
|
||
|
||
客户端代码通过 React Native Web 在 iOS、Android 和浏览器中运行。在浏览器中正常、在手机上崩溃的组件是最常见的插件 bug。规则如下:
|
||
|
||
| 应该 | 不应该 |
|
||
| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||
| 使用 `react-native` 中的 `View`、`Text`、`Pressable`、`ScrollView`、`TextInput` | 使用 `<div>`、`<span>`、`<button>` 或任何 HTML 元素 |
|
||
| 使用由 `theme.colors` 和 `layout.compact` 构建的 `style` 对象 | 使用 `className`、CSS 字符串或写死的颜色 |
|
||
| 使用 `onPress` | 使用 `onClick`、`onMouseEnter` 或其他 DOM 处理函数 |
|
||
| 使用 `Linking`、`Clipboard` 之类的 React Native API | 在组件中使用 `window`、`document`、`localStorage`、`navigator`、`location` |
|
||
|
||
脚手架的 `tsconfig.json` 省略了 DOM 库。请让跨平台组件远离 DOM 全局对象;不要添加 `/// <reference lib="dom" />`,也不要在 `lib` 中加入 `"DOM"`。`layout.platform` 携带与 React Native 的 `Platform.OS` 相同的值,用于渲染决策。
|
||
|
||
### 播放音频 {#play-audio}
|
||
|
||
调用 `client.playAudio({ base64, mimeType }): Promise<void>` 在当前客户端(浏览器、Electron、iOS 或 Android)上播放音频文件。传入插件 RPC 返回的 base64 文件即可;不需要浏览器全局对象或平台检查。
|
||
|
||
```ts
|
||
const audio = await client.rpc(renderSpeech, { text: "Your agent needs you" });
|
||
// renderSpeech returns { base64: string, mimeType: "audio/wav" }.
|
||
await client.playAudio(audio);
|
||
```
|
||
|
||
promise 在播放结束时 resolve,在文件无效、播放失败或插件卸载时 reject。调用共享 Paseo 的语音播放队列,并按顺序播放。卸载插件会取消它正在播放和排队中的音频。语音播放控件也可以中断这个共享队列。播放不会请求麦克风权限。
|
||
|
||
为了可移植性,请使用 PCM WAV 或 MP3 文件。其他编解码格式取决于客户端的解码器。接受 MIME 参数。请传入完整的音频文件;不支持原始 PCM 采样。浏览器要求用户交互后才允许播放声音;请处理 reject 并提供一个播放按钮。该函数在运行插件客户端的设备上播放,而不是在 daemon 上,并且不保证应用被挂起或关闭时仍能播放。
|
||
|
||
### 外部链接和工作区浏览器 {#external-links-and-workspace-browsers}
|
||
|
||
使用 `ExternalLink` 在 Paseo 之外打开文档:
|
||
|
||
```tsx
|
||
import { ExternalLink } from "@getpaseo/plugin/client/ui";
|
||
|
||
export function DocumentationLink() {
|
||
return <ExternalLink href="https://paseo.sh/docs">Open documentation</ExternalLink>;
|
||
}
|
||
```
|
||
|
||
该组件具有无障碍的链接语义,并使用与 `openExternalUrl(url: string): Promise<void>` 相同的打开方式:
|
||
|
||
```ts
|
||
import { openExternalUrl } from "@getpaseo/plugin/client";
|
||
|
||
export async function openDocumentation() {
|
||
await openExternalUrl("https://paseo.sh/docs");
|
||
}
|
||
```
|
||
|
||
请直接在用户交互中调用该函数,以便浏览器允许打开新标签页。
|
||
|
||
| 平台 | 外部链接 | `navigation.openBrowser` |
|
||
| ------------- | ----------------------------------- | ------------------------------------ |
|
||
| Electron | 系统浏览器 | 可用;创建一个本地工作区浏览器标签页 |
|
||
| 浏览器 Web | 带 `noopener,noreferrer` 的新标签页 | `undefined` |
|
||
| iOS / Android | 操作系统的 URL 处理程序 | `undefined` |
|
||
|
||
#### ExternalLink 属性 {#externallink-props}
|
||
|
||
| 属性 | 是否必填 | 行为 / 默认值 |
|
||
| ------------------------------- | -------- | ---------------------------------- |
|
||
| `href: string` | 是 | 绝对的 HTTP(S) 目标地址 |
|
||
| `children: ReactNode` | 是 | 链接文本或内联的 React Native 内容 |
|
||
| `accessibilityLabel: string` | 否 | 覆盖从内容推导出的无障碍名称 |
|
||
| `testID: string` | 否 | 测试标识符;默认不设置 |
|
||
| `onError(error: unknown): void` | 否 | 接收打开时的错误;默认记录到日志 |
|
||
|
||
#### 打开工作区浏览器 {#open-a-workspace-browser}
|
||
|
||
在界面或面板中使用 `navigation.openBrowser`。渲染该操作之前先检查它是否可用。下面这个工作区面板在其他平台上改用外部链接:
|
||
|
||
```tsx
|
||
import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
|
||
import { ExternalLink } from "@getpaseo/plugin/client/ui";
|
||
import { Pressable, Text } from "react-native";
|
||
|
||
export function DocumentationPanel({ navigation, workspaceId, theme }: PluginWorkspacePanelProps) {
|
||
const openBrowser = navigation?.openBrowser;
|
||
const url = "https://paseo.sh/docs";
|
||
|
||
if (!openBrowser) {
|
||
return <ExternalLink href={url}>Open documentation</ExternalLink>;
|
||
}
|
||
|
||
return (
|
||
<Pressable accessibilityRole="button" onPress={() => openBrowser({ url, workspaceId })}>
|
||
<Text style={{ color: theme.colors.foreground }}>Open in workspace browser</Text>
|
||
</Pressable>
|
||
);
|
||
}
|
||
```
|
||
|
||
`navigation.openBrowser({ url, workspaceId, serverId? }): void` 会创建并聚焦一个新标签页。它从不会自动退回到外部打开。
|
||
|
||
| 选项 | 是否必填 | 行为 / 默认值 |
|
||
| --------------------- | -------- | ---------------------------------------- |
|
||
| `url: string` | 是 | 绝对的 HTTP(S) 目标地址 |
|
||
| `workspaceId: string` | 是 | 已存在于目标主机应用工作区列表中的工作区 |
|
||
| `serverId: string` | 否 | 默认为界面或面板所选的主机 |
|
||
|
||
要以另一台主机为目标,请传入它的 ID 以及该主机的工作区 ID:
|
||
|
||
```ts
|
||
openBrowser({ url, workspaceId: remoteWorkspaceId, serverId: remoteServerId });
|
||
```
|
||
|
||
`serverId` 选择工作区的所有者。页面运行在你本地的桌面上,即使是远程工作区也是如此;`localhost` URL 指的是该桌面。
|
||
|
||
#### 错误与拒绝 {#errors-and-refusal}
|
||
|
||
| 条件 | 结果 |
|
||
| ------------------------------------------------ | ---------------------------------------------------------------------- |
|
||
| 外部 URL 格式错误或使用非 HTTP(S) 协议 | 被忽略;`openExternalUrl` resolve,但不会打开任何内容 |
|
||
| 操作系统打开失败 | `openExternalUrl` reject;`ExternalLink` 调用 `onError` 或记录错误 |
|
||
| 浏览器拦截了新的外部标签页 | 无法与成功的 `noopener` 打开区分 |
|
||
| 应用内浏览器的 URL 格式错误或使用非 HTTP(S) 协议 | 在创建标签页之前抛出 `Only absolute HTTP(S) URLs are supported.` |
|
||
| 应用内浏览器的工作区 ID 为空 | 在创建标签页之前抛出 `workspaceId is required.` |
|
||
| 目标主机/工作区未知,或其工作区列表尚未加载 | 在创建标签页之前抛出 `Workspace is unavailable on the requested host.` |
|
||
|
||
跨客户端的、带类型的主机范围持久化请使用[设置 API](#settings-screens)。打开你自己注册的贡献请使用 `openSettings`、`openScreen` 和 `openPanel`。
|
||
|
||
### 服务端运行时 {#server-runtime}
|
||
|
||
Paseo 为服务端代码提供 `@getpaseo/plugin`、`@getpaseo/plugin/server`、`@getpaseo/plugin/server/provider`、`@getpaseo/plugin/server/acp` 和 `zod`。后端贡献在 daemon 子进程中运行,可以通过 Node 访问宿主机器。请把文件系统、进程、凭据以及其他本机工作放在 `server/` 下。没有 `index.server.ts` 的插件不会启动子进程。
|
||
|
||
### Provider {#providers}
|
||
|
||
直接实现和 ACP 实现、会话生命周期、选项控件、时间线渲染器、测试和分发,见[构建 Provider 插件](/docs/plugins/providers)。
|
||
|
||
使用来自 `@getpaseo/plugin/server/provider` 的 `ProviderRegistration` 调用 `server.registerProvider()`。它的连接通过 `send()` 接收输入,并通过 `onEvent()` 发出完整的状态快照。`send()` 只报告是否接受;提示词的处理结果、轮次、配置、持久化、权限和失败都是事件。
|
||
|
||
消息、结构化命令、引导和命令副作用都使用同一个 `session.prompt` 输入。在实时的用户时间线条目上重复 `clientMessageId`,并恰好发布一个与之匹配的 `session.prompt_result`。把 Provider 创建的子会话发布为带有 `parentSessionId` 的会话。
|
||
|
||
Provider 选项是一个记录,即 `session.open` 中的 `ProviderSessionConfig.providerOptions`。它包含配置的默认值和针对单个 Agent 的覆盖值;请在你的 Provider 中校验并应用它。在 `session.config` 中为需要在 Composer 里显示开关或选择器的顶层键报告 `controls`,并在 `providerOptions` 下报告会话正在使用的值。运行中会话的修改会以 `session.configure` 的形式到达,内容在 `changes.providerOptions` 中。配置和合并语义见 [Provider 选项](/docs/sdk/provider-options)。宿主工具以 MCP 服务器的形式出现在完整的会话配置中。
|
||
|
||
Paseo 刷新 Agent 的方式是关闭它当前的 Provider 会话,再用当前的配置和持久化值打开它。Provider 在 `session.open` 期间重新读取外部状态。
|
||
|
||
使用 `@getpaseo/plugin/server/acp` 中的 `runAcpProvider()` 适配一个基于命令的 ACP。只针对厂商在发现、配置、通知或工具调用方面的差异添加转换器钩子。
|
||
|
||
`ProviderRegistration.icon` 是相对于插件目录的文件路径,例如 `icon.svg`。它必须解析到该目录内一个遵循 [SVG 图标规则](#svg-rules)的普通文件。Paseo 在插件启动时读取并校验该文件;这个字符串从不是内联 SVG 或 URL。
|
||
|
||
### 用量来源 {#usage-sources}
|
||
|
||
**需要 Paseo 0.11**。在服务端入口中用 `server.registerUsageSource()` 注册一个来源。从 `@getpaseo/plugin/server/usage` 导入它的契约和辅助函数。
|
||
|
||
一个来源实现两个调用:
|
||
|
||
```ts
|
||
interface UsageAccount {
|
||
key: string;
|
||
label?: string;
|
||
harness?: string;
|
||
input: JsonValue;
|
||
}
|
||
|
||
type UsageScope =
|
||
| { kind: "global" }
|
||
| { kind: "session"; provider: string; model?: string; env: Record<string, string> };
|
||
|
||
interface UsageSourceRegistration {
|
||
id: string;
|
||
label: string;
|
||
icon?: string;
|
||
input: ZodType;
|
||
discover(scope: UsageScope): Promise<UsageAccount[]>;
|
||
fetch(input: unknown): Promise<UsageReport>;
|
||
}
|
||
|
||
type UsageReport =
|
||
| {
|
||
status: "available";
|
||
planLabel?: string;
|
||
windows: UsageWindow[];
|
||
balances?: UsageBalance[];
|
||
details?: UsageDetail[];
|
||
}
|
||
| { status: "unavailable"; problem: UsageProblem }
|
||
| { status: "error"; error: string };
|
||
|
||
type UsageProblem =
|
||
| { kind: "expired"; expiresAt: string; refreshedBy?: string }
|
||
| { kind: "rejected"; status: number; refreshedBy?: string }
|
||
| { kind: "no_quota"; detail: string };
|
||
```
|
||
|
||
会话发现只返回该 Agent 运行时使用的那个登录。Agent 弹出框只使用这些输入;它从不尝试来自全局发现或其他 Agent 的登录。对于其他任何 Provider,返回 `[]`。全局发现会列出每个账号,包括已过期的登录。
|
||
|
||
从会话的启动环境中解析会话账号。没有登录时返回 `[]`。不要在会话发现中扫描默认存储,也不要在全局发现中扫描 Agent。发现是一种查询,没有 Agent 生命周期钩子。已关闭的 Agent 在恢复之前没有会话范围。
|
||
|
||
输入指定凭据存储的位置;不要把凭据放进输入或报告中。Paseo 在调用 `fetch()` 之前会按你的 schema 校验每个输入。在任何范围内,相同的键就是同一份报告;只有当有序的登录输入一致时,获取才会共享五分钟的缓存。每次登录获取有 20 秒的截止时间。
|
||
|
||
内置的会话路由:
|
||
|
||
| 来源 | 会话 | 登录存储 |
|
||
| ------------ | ---------------------------------------------- | ---------------------------------------------------------------------------- |
|
||
| Claude | `claude` | `CLAUDE_CONFIG_DIR` 或默认位置;在 macOS 上,该目录对应的钥匙串条目优先 |
|
||
| Claude | 使用 `anthropic/…` 模型的 `pi`、`omp` | 该运行环境的 Anthropic 登录存储 |
|
||
| Codex | `codex` | `CODEX_HOME/auth.json` 或默认位置 |
|
||
| Codex | 使用 `openai/…` 模型的 `pi`、`opencode`、`omp` | 该运行环境的 OpenAI 登录存储 |
|
||
| Cursor | `cursor` | 该会话的 `XDG_CONFIG_HOME`、`APPDATA` 或主目录下 cursor-agent 的 `auth.json` |
|
||
| Muse | `muse` | 该会话自己的 Muse 启动配置 |
|
||
| 其他内置来源 | 任意 | 无会话发现 |
|
||
|
||
Claude 会排除 Bedrock、Vertex 以及使用其他 `ANTHROPIC_BASE_URL` 的会话。Codex 会排除设置了 `OPENAI_BASE_URL` 的会话。Cursor 会排除通过 `CURSOR_API_KEY` 或 `CURSOR_AUTH_TOKEN` 验证的会话,以及在 macOS 上把登录保存在钥匙串中的会话(除非设置了 `AGENT_CLI_CREDENTIAL_STORE=file`,否则这是默认行为)。
|
||
|
||
使用稳定的账号键:由 `[A-Za-z0-9._-]` 中的字符组成,长度为 1–128。它标识被计量配额的账号或组织,并且在 token 轮换后保持不变。不要把凭据或原始邮箱地址用作键。对于敏感的稳定身份,请使用 `hashAccountKey()`;当账号元数据不可用时,使用存储位置标识。标签可以为账号命名,但不会成为账号的身份。
|
||
|
||
按优先顺序返回登录,并把 `harness` 设为所属运行环境的显示名称,例如 Codex、OpenCode、Pi 或 OMP。daemon 把输入视为不透明的值,从不从中推导标签。
|
||
|
||
主机范围的 Usage 弹窗会把键相同的登录归为一张账号卡片,并发尝试它们,在选择结果时优先考虑发现的顺序。如果任一登录返回 `available`,就显示用量且没有错误。如果全部失败,就把每个登录的错误分行显示,并附上它的运行环境标签和各自的解决方法。发现失败会由 daemon 记录到日志,并且不会产生卡片。
|
||
|
||
时间窗口的名称必须来自 Provider 的数据:显式的时长、具名的 API 字段(例如 `five_hour` 或 `weekly`),或 Provider 自己的周期名称。响应中的位置和重置倒计时并不能确定时长。当 Provider 省略时长时,请使用 “Rolling” 或 “Primary limit” 之类的中性名称,并在适配器旁说明缺失的元数据。
|
||
|
||
对于数值形式的时长,请使用 `@getpaseo/plugin/server/usage` 中的 `windowFromReportedDuration()`(Paseo 0.11+)。传入报告的秒数或 `null`、一个中性的 `unknown` 标识和名称,以及可选的稳定配额 `scope`。该辅助函数会一并推导出 ID、标签和短标签;它不接受对时长标签的覆盖。对于具名的 API 周期,请使用 `windowFromUsedPct()`,并采用该字段所支持的名称。
|
||
|
||
窗口 ID 标识的是配额范围和周期,而不是它在响应中的位置、使用率或重置时刻。对于特定于模型的配额,请以 Provider 稳定的功能 ID 作为范围;没有 ID 时退回到报告的限额名称。在位置移动和显示名称变化时保持 ID 不变。不要把旧的、有歧义的 ID 映射到另一个周期:已保存的固定项会在所有账号中按来源和窗口 ID 匹配,因此用户必须重新选择修正后的窗口。
|
||
|
||
在用户自定义固定项之前,`summary: true` 会选择来源的默认值。应用会为卡片、侧边栏和开关计算出同一个生效的选择。第一次编辑会把这些默认值保存为快照;显式的空选择会保持为空。
|
||
|
||
请在 `fetch()` 中重新读取所选的存储,让 CLI 的 token 轮换生效。不要兑换 refresh token,也不要写入凭据存储:在别处刷新可能会使 CLI 的副本失效,而重写解析过的文件可能会丢弃你没有建模的字段。如果存储在发现之后消失了,请抛出错误;卡片会显示该错误,直到下一次发现将其移除。
|
||
|
||
使用 `unavailable(problem)` 说明为什么某个已有的登录无法提供配额。它需要一个 problem;没有不带参数的形式。`expiresAt` 是 ISO 时间戳。被拒绝的登录会携带上游的 HTTP 状态码。`refreshedBy` 是一个 CLI 名称,例如 `claude`、`codex`、`opencode`、`omp` 或 `pi`。解决方法的句子由 Paseo 负责。不要在该字段中放入说明或面向用户的句子。对于 `no_quota`,`detail` 会原样显示。
|
||
|
||
```ts
|
||
import { z } from "zod";
|
||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||
import { hashAccountKey, unavailable } from "@getpaseo/plugin/server/usage";
|
||
import { findLoginStores, readLogin, readQuota } from "./server/logins";
|
||
|
||
const input = z.object({ path: z.string() }).strict();
|
||
|
||
export default function contribute(server: PluginServerContext) {
|
||
server.registerUsageSource({
|
||
id: "example-usage",
|
||
label: "Example",
|
||
icon: "icon.svg",
|
||
input,
|
||
discover: async () =>
|
||
(await findLoginStores()).map((path) => ({
|
||
key: hashAccountKey(path),
|
||
input: { path },
|
||
})),
|
||
fetch: async (value) => {
|
||
const { path } = input.parse(value);
|
||
const login = await readLogin(path);
|
||
if (login.expiresAt <= Date.now())
|
||
return unavailable({
|
||
kind: "expired",
|
||
expiresAt: new Date(login.expiresAt).toISOString(),
|
||
refreshedBy: "example",
|
||
});
|
||
return readQuota(login);
|
||
},
|
||
});
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
内置的 Claude 发现优先使用 macOS 钥匙串中的登录,只有当钥匙串为空时才使用 Claude Code 的凭据文件。`CLAUDE_CONFIG_DIR` 选择该文件所在的目录。新的 token 使用 OAuth 资料中的账号和组织 ID;已过期或被拒绝的资料使用存储位置的哈希。Codex 依次优先使用 Codex CLI、OpenCode、Pi 和 OMP,并按保存的元数据或 JWT 声明中的 ChatGPT 账号 ID 分组。Pi 和 OMP 的登录在过期后仍可被发现。OMP 需要 `node:sqlite`。
|
||
|
||
不带 ID 调用 `usage.list_reports` 时会发现账号。带 ID 调用时,它会刷新已知的账号,而不重新发现身份。如果某个存储切换了账号,现有卡片会显示新登录的配额,直到下一次发现。报告会缓存五分钟;`forceRefresh` 会绕过缓存。条目携带 `id`、`account.label` 和 `fetchedAt`。失败的账号还会携带可选的 `loginErrors` 条目,其中包含 `harness` 和每个登录失败的 `report`。客户端通过 `server_info.features.usageSources` 判断是否启用此功能。较旧的 `provider.usage.list` RPC 会为 0.10 客户端映射同样的报告,并把问题渲染到它的 `error` 字符串中。
|
||
|
||
来源图标是一个文件路径,规则与 Provider 图标相同。
|
||
|
||
## 入口与清理 {#entry-point-and-cleanup}
|
||
|
||
每个存在的入口都默认导出一个贡献函数,并返回清理函数。客户端入口接收 `PluginClientContext`;服务端入口接收 `PluginServerContext`。客户端注册方法返回幂等的移除函数,但标题栏按钮和 Composer 标签除外,它们返回 `{ update, remove }` 句柄。入口的清理函数会在 Paseo 移除剩余的注册之前运行。
|
||
|
||
```ts
|
||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||
import { Main } from "./client/main";
|
||
|
||
export default function contribute(client: PluginClientContext) {
|
||
client.addScreen({ id: "main", title: "My plugin", Component: Main });
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
清理函数可以是异步的。请释放插件创建的计时器、监听器、套接字和其他资源。重新加载、停用和移除会销毁受影响的插件安装。停止服务端插件还会关闭它的 daemon 会话并停止其子进程。客户端安装遵循[主机与生命周期](#hosts-and-lifecycle)中的规则,包括在暂时断开连接时保留安装。
|
||
|
||
## 生命周期钩子 {#lifecycle-hooks}
|
||
|
||
使用 `server.addHook(name, handler)` 注册异步处理函数。请参阅[钩子](/docs/plugins/hooks),了解命名、载荷、顺序、错误处理和完整示例。
|
||
|
||
## 侧边栏视图 {#sidebar-views}
|
||
|
||
`addSidebarView({ id, title, description?, Component })` 注册可替换侧边栏内容区域的视图,并返回清理函数。用户在设置的侧边栏页面中选择视图;注册不会激活它。视图属性、基于引用的查询与操作、设备状态和主题令牌见[构建侧边栏视图](sidebar-views.md)。
|
||
|
||
## 界面和侧边栏项 {#screens-and-sidebar-items}
|
||
|
||
界面是一整页的插件 UI。侧边栏项是 Paseo 在侧边栏顶部或底部渲染的组件;按下它时做什么由它决定。先注册界面,再注册一个打开它的项:
|
||
|
||
`client/main.tsx`:
|
||
|
||
```tsx
|
||
import type { PluginScreenProps } from "@getpaseo/plugin/client";
|
||
import { useMemo } from "react";
|
||
import { Text, View } from "react-native";
|
||
|
||
export function Main({ theme, host, layout }: PluginScreenProps) {
|
||
const styles = useMemo(
|
||
() => ({
|
||
screen: {
|
||
flex: 1,
|
||
padding: layout.compact ? 16 : 24,
|
||
backgroundColor: theme.colors.surface0,
|
||
},
|
||
title: { color: theme.colors.foreground },
|
||
detail: { color: theme.colors.foregroundMuted },
|
||
}),
|
||
[theme, layout.compact],
|
||
);
|
||
return (
|
||
<View style={styles.screen}>
|
||
<Text style={styles.title}>{host.label}</Text>
|
||
<Text style={styles.detail}>{layout.platform}</Text>
|
||
</View>
|
||
);
|
||
}
|
||
```
|
||
|
||
`index.client.tsx`:
|
||
|
||
```tsx
|
||
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
||
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
||
import { Main } from "./client/main";
|
||
|
||
function MainItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
|
||
return (
|
||
<SidebarRow
|
||
icon="Blocks"
|
||
active={currentScreen?.screenId === "main"}
|
||
onPress={() => openScreen({ screenId: "main" })}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export default function contribute(client: PluginClientContext) {
|
||
client.addScreen({ id: "main", title: "My plugin", Component: Main });
|
||
client.addSidebarHeaderItem({ id: "main", title: "My plugin", Component: MainItem });
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
`PluginScreenProps` 包含:
|
||
|
||
| 字段 | 含义 |
|
||
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `theme` | 当前 Paseo 主题的带类型 `PluginTheme` 颜色令牌。 |
|
||
| `host` | 所选主机的 `id` 和显示用的 `label`。 |
|
||
| `layout` | `compact` 以及 `ios`、`android` 或 `web` 平台。 |
|
||
| `params` | 打开界面时传入的参数,键和值都是字符串。没有参数时为 `{}`。 |
|
||
| `navigation` | 可选的客户端导航。`openAgent({ agentId, serverId? })` 和 `openWorkspace({ workspaceId, serverId? })` 在 `serverId` 上打开目标,省略时在所选主机上打开。`openBrowser({ url, workspaceId, serverId? })` 仅在 Electron 上可用;见[链接和浏览器](#external-links-and-workspace-browsers)。 |
|
||
|
||
路由、标题栏、关闭操作、主机选择器、错误边界和 query client 由 Paseo 负责。界面主体由插件负责。
|
||
|
||
`addScreen({ id, title, Component })` 注册一个界面。`title` 是界面标题栏的[显示文本](#runtime-translations),也可以是接收界面参数并返回显示文本的函数。参数或语言变化时,该函数会再次运行。无效标题和空字符串标题会被拒绝。如果标题函数抛出错误或解析为空字符串,会改为显示界面 ID;界面仍然会渲染。
|
||
|
||
`openScreen({ screenId, params })` 以参数打开一个界面,例如机器人界面要显示的那个机器人。参数保存在界面 URL 的查询字符串中,因此重新加载、前进后退以及指向该界面的链接都会保留它们。任何字符串键都可以;值必须是字符串,其他类型会抛出错误。用其他参数打开同一个界面会显示新的参数。
|
||
|
||
### 侧边栏项 {#sidebar-items}
|
||
|
||
`addSidebarHeaderItem` 在侧边栏顶部的列表中添加一行。`addSidebarFooterItem` 在底部添加一行,位于 **添加 project** 和底部图标行之间。图标行是固定的;插件不能向其中添加内容。两者都接受 `{ id, title, Component }`。`title` 是该项在 **设置 → 侧边栏** 中的标签(用户在那里重新排序和隐藏各项),也是 `SidebarRow` 的默认标签和无障碍标签。设置页面为这些项显示一个通用的插件图标;来自已弃用的 `addSidebarItem` 的行保留其注册时的图标。
|
||
|
||
当插件安装在多台主机上时,侧边栏项按应用当前显示的界面所属的主机渲染。不在任何主机的界面上时,它使用最近一次在该插件的某个界面中选择的主机,或最近一次从它的某个项打开的主机,否则使用第一台主机。
|
||
|
||
`Component` 接收 `PluginSidebarItemProps`:
|
||
|
||
| 字段 | 含义 |
|
||
| ------------------------- | -------------------------------------------------------------------------------- |
|
||
| `theme`, `host`, `layout` | 与 `PluginScreenProps` 中相同。 |
|
||
| `currentScreen` | 在该项所属主机上打开的、此插件的界面的 `{ screenId, params }`,没有时为 `null`。 |
|
||
| `openScreen(input)` | 打开此插件的某个界面:`{ screenId, params? }`。未知的界面 ID 会抛出错误。 |
|
||
| `openPopover(Content)` | 在宽布局中把 `Content` 锚定在被按下的行旁边打开,在紧凑布局中以底部面板打开。 |
|
||
|
||
`Content` 接收 `theme`、`host`、`layout`、`close()` 和 `openScreen(input)`。打开界面会关闭弹出框。
|
||
|
||
用 `@getpaseo/plugin/client/ui` 中的侧边栏组件渲染该项:
|
||
|
||
| 组件 | 属性与行为 |
|
||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||
| `SidebarRow` | 必填 `onPress`;可选 `icon`(任意一种[图标](#icons)形式)、`label`、`active`、`trailing`、`id`。占满整行宽度。`trailing` 渲染在行的可按区域旁边,因此其中的按钮可以单独按下;按下其中其他位置则相当于按下该行。`id` 用于在测试 ID 中区分同一项的多行。 |
|
||
| `SidebarSeparator` | 没有属性。侧边栏的分隔线,放在各组行之间。 |
|
||
|
||
弹出框锚定在被按下的那一行,无论按下的是行本身还是它的 `trailing` 内容,因此每行的“More”按钮会在它自己的行旁边打开弹出框。
|
||
|
||
```tsx
|
||
import type { PluginSidebarItemProps, PluginPopoverProps } from "@getpaseo/plugin/client";
|
||
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
||
import { Pressable, Text } from "react-native";
|
||
|
||
function SyncDetails({ theme, close, openScreen }: PluginPopoverProps) {
|
||
return (
|
||
<Pressable onPress={() => openScreen({ screenId: "sync" })}>
|
||
<Text style={{ color: theme.colors.foreground }}>Open sync history</Text>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
function SyncItem({ openPopover }: PluginSidebarItemProps) {
|
||
return <SidebarRow icon="RefreshCw" onPress={() => openPopover(SyncDetails)} />;
|
||
}
|
||
|
||
client.addSidebarFooterItem({ id: "sync", title: "Sync", Component: SyncItem });
|
||
```
|
||
|
||
抛出错误的项不会渲染任何内容;侧边栏的其余部分继续正常工作。
|
||
|
||
#### 一个项中的多行 {#several-rows-in-one-item}
|
||
|
||
项的 `Component` 可以返回 fragment、数组或 `null`,因此一个项可以渲染一个在运行时变化的列表。设置中只列出该项一次,它作为一个整体移动和隐藏。下面这个项为每个机器人显示一行,并以机器人 ID 作为参数打开该机器人的界面:
|
||
|
||
```tsx
|
||
import type {
|
||
PluginClientContext,
|
||
PluginScreenProps,
|
||
PluginSidebarItemProps,
|
||
} from "@getpaseo/plugin/client";
|
||
import { SidebarRow, SidebarSeparator } from "@getpaseo/plugin/client/ui";
|
||
import { Text } from "react-native";
|
||
import { botName, useBots } from "./client/bots";
|
||
|
||
function BotScreen({ theme, params }: PluginScreenProps) {
|
||
return <Text style={{ color: theme.colors.foreground }}>Bot {params.botId}</Text>;
|
||
}
|
||
|
||
function BotsItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
|
||
const bots = useBots();
|
||
const openBotId = currentScreen?.screenId === "bot" ? currentScreen.params.botId : null;
|
||
return (
|
||
<>
|
||
<SidebarSeparator />
|
||
{bots.map((bot) => (
|
||
<SidebarRow
|
||
key={bot.id}
|
||
id={bot.id}
|
||
icon="Bot"
|
||
label={bot.name}
|
||
active={bot.id === openBotId}
|
||
onPress={() => openScreen({ screenId: "bot", params: { botId: bot.id } })}
|
||
/>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function contribute(client: PluginClientContext) {
|
||
client.addScreen({
|
||
id: "bot",
|
||
title: (params) => botName(params.botId) ?? "Bot",
|
||
Component: BotScreen,
|
||
});
|
||
client.addSidebarHeaderItem({ id: "bots", title: "Bots", Component: BotsItem });
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
#### 在运行时添加和移除项 {#add-and-remove-items-at-runtime}
|
||
|
||
`addSidebarHeaderItem` 和 `addSidebarFooterItem` 在入口返回之后仍然可用。它们各自返回一个移除函数;项会在不重新加载的情况下出现和消失:
|
||
|
||
```tsx
|
||
let removeAlerts: (() => void) | null = null;
|
||
|
||
function showAlerts(client: PluginClientContext) {
|
||
removeAlerts ??= client.addSidebarFooterItem({
|
||
id: "alerts",
|
||
title: "Alerts",
|
||
Component: AlertsItem,
|
||
});
|
||
}
|
||
|
||
function hideAlerts() {
|
||
removeAlerts?.();
|
||
removeAlerts = null;
|
||
}
|
||
```
|
||
|
||
## 宿主 UI {#host-ui}
|
||
|
||
在客户端代码中从 `@getpaseo/plugin/client/ui` 导入由 Paseo 提供的 UI。下面的示例会打开一个受控的弹窗、渲染一个宿主图标,并用 toast 确认操作:
|
||
|
||
```tsx
|
||
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
|
||
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
|
||
import { useState } from "react";
|
||
import { Pressable, Text, View } from "react-native";
|
||
|
||
export function IssueActions({ theme }: PluginSurfaceProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const toast = useToast();
|
||
|
||
function saveIssue() {
|
||
toast.show("Issue saved", { variant: "success" });
|
||
setOpen(false);
|
||
}
|
||
|
||
return (
|
||
<View>
|
||
<Pressable accessibilityRole="button" onPress={() => setOpen(true)}>
|
||
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||
<Icon name="Pencil" size={18} color={theme.colors.foreground} />
|
||
<Text style={{ color: theme.colors.foreground }}>Edit issue</Text>
|
||
</View>
|
||
</Pressable>
|
||
|
||
<Modal
|
||
title="Edit issue"
|
||
icon={<Icon name="Pencil" size={18} color={theme.colors.foreground} />}
|
||
open={open}
|
||
onOpenChange={setOpen}
|
||
>
|
||
<Modal.Content>
|
||
<Pressable accessibilityRole="button" onPress={saveIssue}>
|
||
<Text style={{ color: theme.colors.foreground }}>Save</Text>
|
||
</Pressable>
|
||
</Modal.Content>
|
||
</Modal>
|
||
</View>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Markdown {#markdown}
|
||
|
||
从 `@getpaseo/plugin/client/ui` 导入 `Markdown`,即可在网页、iOS 和 Android 上
|
||
使用 Paseo 的 Markdown 样式渲染字符串:
|
||
|
||
```tsx
|
||
import { Markdown } from "@getpaseo/plugin/client/ui";
|
||
|
||
<Markdown>{"## Review\n\nThe checks **passed**."}</Markdown>;
|
||
```
|
||
|
||
`children` 是唯一的属性,必须为字符串。更新它会立即渲染新内容;空字符串不渲染任何内容。
|
||
宿主提供主题、文本选择、代码块、表格、图表和链接处理。
|
||
当所在界面提供工作区上下文时,工作区文件链接和图片会使用该上下文。
|
||
|
||
组件不添加页面内边距或滚动容器。需要时请在外层使用布局或滚动视图。
|
||
内容获取以及加载和错误状态由插件负责。
|
||
|
||
### Modal {#modal}
|
||
|
||
`Modal` 在紧凑布局中使用底部面板,否则使用居中的对话框。`open` 状态由插件负责。
|
||
|
||
| 属性 | 类型 | 是否必填 | 行为 |
|
||
| -------------- | ------------------------- | -------- | -------------------------------- |
|
||
| `title` | `string` | 是 | 为弹窗及其可见的标题栏提供标签。 |
|
||
| `icon` | `ReactNode` | 否 | 渲染在标题栏中标题的前面。 |
|
||
| `open` | `boolean` | 是 | 为 `true` 时显示弹窗内容。 |
|
||
| `onOpenChange` | `(open: boolean) => void` | 是 | 用户关闭弹窗时收到 `false`。 |
|
||
| `children` | `ReactNode` | 是 | 包含 `Modal.Content`。 |
|
||
|
||
`Modal.Content` 负责宿主渲染的标题栏下方的主体:
|
||
|
||
| 属性 | 类型 | 默认值 | 行为 |
|
||
| ----------------------- | ---------------------- | ------------------ | ------------------------------------------------------------------------ |
|
||
| `children` | `ReactNode` | 必填 | 标题栏下方的主体内容。 |
|
||
| `style` | `StyleProp<ViewStyle>` | — | 为整个主体视口(包括空白区域)设置样式。用于背景。 |
|
||
| `contentContainerStyle` | `StyleProp<ViewStyle>` | 内边距 24,间距 16 | 覆盖内容布局。要让行贴边显示,请设置 `padding: 0, gap: 0`。 |
|
||
| `scrollable` | `boolean` | `true` | 由宿主滚动主体。如果主体高度受限并使用你自己的滚动容器,请设为 `false`。 |
|
||
|
||
普通的 `Modal.Content` 已经带有内边距并且可以滚动。除非你想要再缩进一层,否则不要再添加一个带内边距的包装。主体样式不会影响宿主的标题栏、拖动手柄和关闭控件。在紧凑的原生布局中,宿主会在底部预留安全区域空间;把内容的内边距设为零只会去掉装饰性的缩进,而不会去掉这部分空间。键盘的避让是单独处理的。
|
||
|
||
使用 `scrollable={false}` 时,主体会填满底部面板的可用高度,居中对话框则使用可用高度的 85%。请在你的列表上使用 `flex: 1, minHeight: 0`。默认可滚动的对话框在宽布局中保持与内容一样的大小。呈现方式取决于窗口尺寸,包括较窄的桌面窗口和较宽的平板。
|
||
|
||
关闭按钮、背景遮罩、平台的返回操作、Web 上的 Escape 键以及紧凑布局下底部面板的手势都会关闭弹窗。关闭时会调用 `onOpenChange(false)`;插件必须更新 `open` 才能关闭它。
|
||
|
||
弹窗的子元素保留插件的运行时 context。`usePaseo`、`useRpc`、`useWorkspace` 和 `useAgent` 在其中都可以使用。
|
||
|
||
### 滚动 {#scrolling}
|
||
|
||
当内容可能出现在 Paseo 弹窗中时,请从 `@getpaseo/plugin/client/ui` 导入 `ScrollView` 和 `FlatList`。它们接受 React Native 的属性和 ref,并与底部面板的手势集成。在底部面板之外,它们使用普通的 React Native 滚动。不要直接导入底部面板库。
|
||
|
||
只使用一个垂直滚动的所有者:要么是默认的弹窗主体,要么是你自己的、配合 `scrollable={false}` 使用的列表。在默认可滚动主体中嵌套一个固定高度的垂直列表,可能会在 Android 上与底部面板争夺手势。水平滚动可以与宿主的垂直主体共存。
|
||
|
||
默认主体和 SDK 列表都共享原生底部面板的手势:先向上拖动展开,再开始滚动;在列表顶部向下拖动会收起或关闭面板。`scrollable={false}` 会移除宿主的滚动容器,但不会改变这些手势。在使用 `scrollToEnd` 等列表方法之前,请先展开底部面板;在面板达到最大高度之前,它会锁定列表的偏移量。
|
||
|
||
```tsx
|
||
import { FlatList, Modal } from "@getpaseo/plugin/client/ui";
|
||
import { Text } from "react-native";
|
||
|
||
// Inside your controlled Modal:
|
||
<Modal.Content
|
||
scrollable={false}
|
||
style={{ backgroundColor: theme.colors.surface1 }}
|
||
contentContainerStyle={{ padding: 0, gap: 0 }}
|
||
>
|
||
<FlatList
|
||
style={{ flex: 1, minHeight: 0 }}
|
||
data={items}
|
||
keyExtractor={(item) => item.id}
|
||
renderItem={({ item }) => (
|
||
<Text style={{ padding: 16, color: theme.colors.foreground }}>{item.title}</Text>
|
||
)}
|
||
/>
|
||
</Modal.Content>;
|
||
```
|
||
|
||
对于水平标签页,请把 `<ScrollView horizontal style={{ flexGrow: 0 }}>…</ScrollView>` 放在默认的 `Modal.Content` 中。让内容的垂直滚动仍由宿主负责。
|
||
|
||
### 复制和粘贴 {#copy-and-paste}
|
||
|
||
`copyText(text): Promise<void>` 写入运行应用的设备的剪贴板。请在用户操作中调用它,并在报告成功之前 await 它。如果平台拒绝复制或剪贴板不可用,它会 reject;浏览器权限和安全上下文要求仍然适用。
|
||
|
||
```tsx
|
||
import { copyText, useToast } from "@getpaseo/plugin/client/ui";
|
||
|
||
// Inside your component:
|
||
const toast = useToast();
|
||
async function copyResult() {
|
||
try {
|
||
await copyText(result);
|
||
toast.show("Copied", { variant: "success" });
|
||
} catch {
|
||
toast.error("Could not copy. Select the text and use Copy.");
|
||
}
|
||
}
|
||
```
|
||
|
||
以编程方式复制和原生文本选择是两种不同的交互。使用 `<Text selectable>` 支持长按选择和系统的复制。弹窗表单请从 `@getpaseo/plugin/client/ui` 导入 `TextInput`。它接受 React Native 的输入属性和 ref,支持系统粘贴,并向原生底部面板注册焦点,以便键盘能把表单顶上来。在底部面板之外,它使用普通的输入框。普通的 React Native 输入框也支持粘贴,但不会向底部面板注册焦点;键盘可能会遮住它。系统粘贴不需要任何读取剪贴板的 API。不要在原生插件中使用 DOM 剪贴板代码,也不要使用 `react-native` 中已弃用的 `Clipboard` 导出。
|
||
|
||
可运行的[弹窗 UI 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/modal-ui)包含一个带内边距的表单、全宽的行、一个虚拟化列表、水平标签页和一个支持复制/粘贴的输入框。
|
||
|
||
### Toast {#toasts}
|
||
|
||
`useToast()` 返回两个方法:
|
||
|
||
| 方法 | 行为 |
|
||
| ------------------------- | ------------------------------------------------------------ |
|
||
| `show(message, options?)` | 显示一个 toast,除非提供了 `durationMs`,否则持续 2,200 ms。 |
|
||
| `error(message)` | 显示一个错误 toast,持续 3,200 ms。 |
|
||
|
||
`show` 接受以下选项:
|
||
|
||
| 选项 | 类型 | 默认值 |
|
||
| ------------ | ---------------------------------------------------------- | ----------- |
|
||
| `variant` | `"default" \| "info" \| "success" \| "warning" \| "error"` | `"default"` |
|
||
| `durationMs` | `number` | `2200` |
|
||
|
||
显示另一个 toast 会替换当前可见的 toast。空消息会被忽略。
|
||
|
||
### 图标 {#icons}
|
||
|
||
每个贡献的 `icon` 字段都接受以下三种形式之一,类型为 `@getpaseo/plugin/client` 中的 `PluginIcon`:
|
||
|
||
| 形式 | 示例 | 适用场景 |
|
||
| ----------- | --------------------------- | ----------------------------------------------- |
|
||
| Lucide 名称 | `icon: "Scan"` | 任意 [Lucide 图标](https://lucide.dev/icons/)。 |
|
||
| 内联 SVG | `icon: { svg: reviewIcon }` | 品牌标志,或 Lucide 中没有的图标。 |
|
||
| 组件 | `icon: ReviewBadge` | 随状态变化的图标,例如计数器。 |
|
||
|
||
附件来源接受 Lucide 名称或内联 SVG(`PluginSerializableIcon`),因为每个附加的条目都会保存其来源的图标。
|
||
|
||
Paseo 在贡献注册时校验图标。未知的 Lucide 名称或违反规则的 SVG 会导致插件加载失败,并给出原因:
|
||
|
||
```text
|
||
Workspace panel review has an invalid icon: script elements are not allowed
|
||
```
|
||
|
||
Paseo 按界面的图标尺寸绘制内联 SVG,并把界面的图标颜色作为 `currentColor` 传入。用 `currentColor` 绘制即可跟随主题;固定的填充色会保持原样。
|
||
|
||
组件会收到 `PluginIconProps`,即 `{ size, color }`,并在该尺寸内渲染。头部按钮和 Composer 胶囊传入的是 [`PluginButtonIconProps`](#custom-icons-and-popover-content)。
|
||
|
||
#### SVG 规则 {#svg-rules}
|
||
|
||
内联 SVG、Provider 图标和用量来源图标遵循相同的规则:
|
||
|
||
- 只有一个 `<svg>` 文档,大小不超过 64 KiB。
|
||
- 不含 `<script>`、`<style>` 或 `<foreignObject>` 元素。
|
||
- 不含 `onclick` 这类事件处理属性,也不含 `javascript:` URL。
|
||
- `href` 和 `xlink:href` 只能指向同一文档中的片段,例如 `#mark`。
|
||
|
||
#### 提供 SVG 标记 {#ship-svg-markup}
|
||
|
||
把标记放在一个客户端模块中:
|
||
|
||
```ts
|
||
// client/icons.ts
|
||
export const reviewIcon = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M4 4h16v16H4z" />
|
||
</svg>`;
|
||
```
|
||
|
||
或者把一个 `.svg` 文件放进 `client/` 并导入它。插件编译器会把文件的标记打包为字符串。声明一次该模块,让 TypeScript 接受这个导入:
|
||
|
||
```ts
|
||
// client/svg.d.ts
|
||
declare module "*.svg" {
|
||
const markup: string;
|
||
export default markup;
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// index.client.tsx
|
||
import reviewIcon from "./client/review.svg";
|
||
|
||
client.addWorkspacePanel({ id: "review", title: "Review", icon: { svg: reviewIcon } /* … */ });
|
||
```
|
||
|
||
#### 渲染图标 {#render-an-icon}
|
||
|
||
`@getpaseo/plugin/client/ui` 中的 `Icon` 用于在你自己的 UI 中绘制图标。插件包不导入 `lucide-react-native` 或 `react-native-svg`。
|
||
|
||
| 属性 | 类型 | 是否必填 | 行为 |
|
||
| ------- | ------------ | ----------------------- | ---------------------------------------------------------- |
|
||
| `name` | `string` | `name` 或 `icon` 二选一 | Lucide 图标名称。 |
|
||
| `icon` | `PluginIcon` | `name` 或 `icon` 二选一 | 上文任意一种图标形式。 |
|
||
| `size` | `number` | 否 | 图标的宽度和高度。默认为 24。 |
|
||
| `color` | `string` | 否 | 图标颜色,也是 SVG 的 `currentColor`。请使用插件主题令牌。 |
|
||
|
||
未知的名称或违反规则的 SVG 不会渲染任何内容。
|
||
|
||
## 时间线条目 {#timeline-items}
|
||
|
||
插件可以用自己的数据和 React Native 渲染器替换 Agent 时间线中的条目。两项注册都是客户端贡献。Paseo 在构建渲染模型时应用转换器,包括每一次实时的流式更新。
|
||
|
||
```tsx
|
||
import type { PluginClientContext, PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
||
import { Text } from "react-native";
|
||
import { z } from "zod";
|
||
|
||
const schema = z.object({ label: z.string() });
|
||
|
||
function Card({ item, theme }: PluginTimelineItemProps<z.output<typeof schema>>) {
|
||
return <Text style={{ color: theme.colors.foreground }}>{item.data.label}</Text>;
|
||
}
|
||
|
||
export default function contribute(client: PluginClientContext) {
|
||
client.addTimelineTransformer({
|
||
id: "command-card",
|
||
query: { itemType: "tool_call" },
|
||
transform({ item, phase }) {
|
||
return {
|
||
items: [
|
||
{
|
||
type: "plugin",
|
||
kind: "command-card",
|
||
version: 1,
|
||
data: { label: item.name, phase },
|
||
},
|
||
],
|
||
};
|
||
},
|
||
});
|
||
client.addTimelineRenderer({
|
||
kind: "command-card",
|
||
version: 1,
|
||
schema,
|
||
Component: Card,
|
||
});
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
`query.itemType` 是稳定的、粗粒度的选择器。请在 `transform` 中检查所选的条目,以识别特定的 Provider 或工具。返回 `undefined` 会保留原条目。返回 `items` 会替换它;空数组会移除它。条目的 `data` 必须兼容 JSON。对于实时的助手消息、运行中的工具调用和加载中的推理,`phase` 输入为 `"streaming"`;对于已提交或已获取的消息以及已完成的工具或推理,它为 `"complete"`。助手和推理回调在每次更新时都会收到完整的累计文本,包括段落分隔符。Paseo 会在拆分原生 Markdown 或在 Overview 中对工具分组之前调用转换器。被认领的助手消息在整个流式过程中始终是同一个来源条目;如果最初的文本不足以识别它,请在可以识别之前返回 `undefined`。每个替换项都可以设置一个可选的、插件内部的 `id`;否则 Paseo 使用它在该来源条目输出中的索引。
|
||
|
||
渲染器接收 `agentId`、`item`、`timestamp`、`theme`、`host` 和 `layout`。Paseo 在渲染之前用注册的 schema 校验 `item.data`。请让转换器保持同步且确定。Paseo 按来源条目的引用缓存结果,并从来源行推导替换项的标识,因此对某个流式条目的更新不会重新挂载它的渲染器。要让纯文本像 Paseo 的助手行那样逐步显示,请渲染 `@getpaseo/plugin/client/ui` 中的 `<StreamedText text={item.data.text} phase={item.data.phase} />`。它接受 React Native `Text` 的属性(包括 `style`),默认使用助手消息的字体排版和前景色。完成后会立即显示全文;流式输出期间由宿主控制显示节奏。需要 Paseo 0.13 或更高版本。
|
||
|
||
当查询同时匹配原始消息时,聊天查找(Ctrl 或 Cmd+F)会在替换行中查找并高亮文本。宿主会在原始提示词文本或去掉 Markdown 语法的助手回复中搜索,然后由应用在你的渲染器绘制的文本中统计匹配。只由你的渲染器添加的文本不会被单独索引。匹配不会跨越渲染出的块,查找会跳过按钮、图标以及任何带有 `aria-hidden="true"` 的内容。查找只在 Web 和桌面应用中可用。
|
||
|
||
### 从 daemon 追加时间线行 {#append-a-timeline-row-from-the-daemon}
|
||
|
||
服务端处理函数可以向标准历史记录中添加一行由插件拥有的内容:
|
||
|
||
```ts
|
||
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
||
|
||
async function publishReview(agentId: string, { paseo }: PluginHandlerContext) {
|
||
await paseo.agents.ref(agentId).timeline.append({
|
||
type: "plugin",
|
||
id: "review",
|
||
kind: "review-result",
|
||
version: 1,
|
||
data: { verdict: "ready" },
|
||
});
|
||
}
|
||
```
|
||
|
||
| 字段 | 类型 | 是否必填 | 行为 |
|
||
| --------- | ---------- | -------- | -------------------------------------------- |
|
||
| `type` | `"plugin"` | 是 | 选择插件时间线变体。 |
|
||
| `id` | `string` | 是 | 稳定的、插件内部的标识。复用它会替换旧的行。 |
|
||
| `kind` | `string` | 是 | 选择已注册的渲染器。 |
|
||
| `version` | 正整数 | 是 | 选择渲染器契约的版本。 |
|
||
| `data` | 兼容 JSON | 是 | 渲染器负载,JSON 序列化后最多 64 KiB。 |
|
||
|
||
daemon 会根据发起调用的插件会话写入 `pluginId`,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 `id` 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。daemon 会拒绝超过限制的 `data`,而不是截断它。支持此操作的 daemon 会声明 `server_info.features.pluginTimelineItems`。
|
||
|
||
## 主题与布局 {#theme-and-layout}
|
||
|
||
插件 UI 运行在桌面、浏览器、iOS 和 Android 上,适用于每一个 Paseo 主题。`theme` 是由当前宿主主题映射而来的带类型 `PluginTheme`。颜色和间距必须来自这些属性。写死的颜色和没有样式的 `Text` 会在宿主主题变化时出问题。
|
||
|
||
当 `theme` 或 `layout.compact` 变化时,请重新创建样式。
|
||
|
||
| 键 | 必须用于 | 用于 |
|
||
| ------------------------------- | ------------------ | ------------------------------- |
|
||
| `theme.colors.foreground` | 每个主要的 `Text` | 标题和正文 |
|
||
| `theme.colors.foregroundMuted` | 次要的 `Text` | 标签和辅助文字 |
|
||
| `theme.colors.surface0` | 根视图 | 面板背景 |
|
||
| `theme.colors.surface1` | 凸起的表面 | 卡片和面板 |
|
||
| `theme.colors.surface2` | 控件表面 | 输入框和次要控件 |
|
||
| `theme.colors.border` | 表面边界 | 边框和分隔线 |
|
||
| `theme.colors.accent` | 主要操作的填充 | 按钮和选中状态 |
|
||
| `theme.colors.accentForeground` | 强调色填充上的文字 | 按钮标签 |
|
||
| `theme.colors.statusSuccess` | 成功反馈 | 成功消息和指示器 |
|
||
| `theme.colors.statusWarning` | 警告反馈 | 警告消息和指示器 |
|
||
| `theme.colors.statusDanger` | 失败文字 | 错误消息和破坏性操作的文字 |
|
||
| `layout.compact` | 内边距和堆叠 | 在移动端和较窄的窗口中为 `true` |
|
||
| `layout.platform` | 特定于平台的行为 | `ios`、`android` 或 `web` |
|
||
|
||
不要写死 `#000`、`#fff` 或 React Native 的默认文字颜色。主要文字使用 `foreground`。标签使用 `foregroundMuted`。当 `layout.compact` 为 true 时,收紧内边距。
|
||
|
||
工作区面板和 Agent 面板接收同样的 `theme`、`layout` 以及可选的 `navigation` 字段。
|
||
|
||
## 贡献主题 {#contribute-a-theme}
|
||
|
||
`addTheme` 向 **设置 → 外观** 添加一个浅色或深色主题,按其 `name` 列在内置主题之下。主题是数据,因此不需要组件文件:
|
||
|
||
```ts
|
||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||
|
||
export default function contribute(client: PluginClientContext) {
|
||
client.addTheme({
|
||
id: "mocha",
|
||
name: "Catppuccin Mocha",
|
||
appearance: "dark",
|
||
colors: {
|
||
background: "#1e1e2e",
|
||
foreground: "#cdd6f4",
|
||
raised: "#313244",
|
||
control: "#45475a",
|
||
border: "#45475a",
|
||
accent: "#cba6f7",
|
||
mutedForeground: "#a6adc8",
|
||
ring: "#6c7086",
|
||
},
|
||
});
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
每种颜色都是十六进制字符串;其他格式会导致加载失败。Paseo 会把调色板扩展为内置深色主题所使用的完整令牌集合,因此贡献的主题无需逐一列出,就能覆盖面板、菜单、diff、状态颜色和终端。
|
||
|
||
| 颜色 | 用作 |
|
||
| ----------------- | ------------------------------------------------- |
|
||
| `background` | 应用、工作区和终端的背景 |
|
||
| `foreground` | 主要文字、终端前景色和光标 |
|
||
| `raised` | 卡片、弹出框和悬停的行 |
|
||
| `control` | 输入框、次要填充以及浅色主题的侧边栏 |
|
||
| `border` | 边框以及最高层凸起表面的色调 |
|
||
| `accent` | 按钮、选中和焦点。可选;省略时使用 `foreground`。 |
|
||
| `mutedForeground` | 次要文字 |
|
||
| `ring` | 焦点环、滚动条以及终端的亮黑色 |
|
||
|
||
`appearance` 为 `"light"` 或 `"dark"`。Paseo 用它选择对应的表面、状态、diff、语法高亮、终端和阴影的推导方式。
|
||
|
||
同一时间只有一个贡献的主题处于启用状态。选择一个主题会保存这项选择;如果之后插件被停用或移除,Paseo 会退回到默认主题,而不会让应用失去配色。
|
||
|
||
主题需要一个支持它们的宿主。在 `addTheme` 之前发布的客户端无法执行该客户端入口,并会报告 `client.addTheme is not a function`。请更新客户端。
|
||
|
||
## 设置界面 {#settings-screens}
|
||
|
||
在 `index.client.tsx` 中用 `client.addSettingsScreen({ id, title, icon, Component })` 注册一个组件。之后,该主机 **设置 → 插件** 中你的插件行会显示一个设置按钮(齿轮),点击后打开你的第一个界面。注册多个界面时,设置页面会按注册顺序为每个界面显示一个标签页,使用其 `title` 和 `icon`;只有一个界面时不显示标签页。页面标题显示 manifest 中的 `name`;没有 `name` 时显示插件 ID,且从不包含界面标题。`id` 在同一次安装中唯一;`icon` 可以是任意一种[图标](#icons)形式。注册返回一个幂等的移除函数,插件销毁时会移除剩余的界面。
|
||
|
||
调用 `client.openSettings(id)` 或 Command Center 回调中的 `openSettings(id)` 可以打开你自己的某个界面。即使多台主机安装了同一个插件,每次安装也有各自的值和路由。
|
||
|
||
该组件接收 `PluginSurfaceProps`。标题栏、返回导航、安全区域、滚动以及居中的设置栏由 Paseo 负责。紧凑的窗口会推入一个全屏的详情页;较宽的窗口会保留设置侧边栏。请在这个框架内用 React Native 组件渲染内容。被停用或移除的插件会留下一个“不可用”界面,其返回导航仍然可用。
|
||
|
||
### 具名 UI 组件 {#named-ui-components}
|
||
|
||
从 `@getpaseo/plugin/client/ui` 导入设置组件。它们可以配合你自己的状态和 RPC 使用;不需要表单包装或存储绑定。
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import { SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui";
|
||
|
||
export function DisplaySettings() {
|
||
const [visible, setVisible] = useState(true);
|
||
return (
|
||
<SettingsSection title="Display">
|
||
<SettingsCard>
|
||
<SettingsSwitch label="Show metadata" value={visible} onValueChange={setVisible} />
|
||
</SettingsCard>
|
||
</SettingsSection>
|
||
);
|
||
}
|
||
```
|
||
|
||
| 组件 | 属性与行为 |
|
||
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||
| `SettingsSection` | 必填 `title`、`children`;可选 `info` 提示、`trailing` 内容、`testID`。负责分区的间距和标题。 |
|
||
| `SettingsCard` | `children`,可选 `testID`。负责卡片表面以及直接子元素之间的分隔线。请为映射生成的行提供稳定的 React key。 |
|
||
| `SettingsRow` | 必填 `label`;可选 `hint`、`error`、`children`、`testID`。用于包裹任何自定义控件或内容。 |
|
||
| `SettingsSwitch` | 行属性,外加必填的 `value: boolean`、`onValueChange`;可选 `disabled`。 |
|
||
| `SettingsSelect` | 行属性,外加必填的字符串 `value`、`options: { label, value }[]`、`onValueChange`;可选 `disabled`。使用 Paseo 的自适应菜单。 |
|
||
| `SettingsInput` | 行属性,外加必填的 `onChangeText`;可选 `initialValue`、`placeholder`、`disabled`、`secureTextEntry`、`ref`。 |
|
||
| `SettingsAction` | 行属性,外加必填的 `actionLabel`、`onPress`;可选 `disabled`。 |
|
||
|
||
`SettingsInput` 负责正在编辑中的文本。`initialValue` 在挂载时为它提供初始值。它的 ref 提供 `focus()`、`blur()`、`getText()` 和 `replaceText(text)`,用于显式的编程式修改。在用户保存之前,请把草稿文本保留在持久化值之外。自定义的预览和控件可以放在这些组件旁边或内部。
|
||
|
||
### 持久化的值 {#persisted-values}
|
||
|
||
在 `shared/` 中定义一个设置文档:
|
||
|
||
```ts
|
||
import { defineSettings } from "@getpaseo/plugin";
|
||
import { z } from "zod";
|
||
|
||
export const preferences = defineSettings({
|
||
id: "display",
|
||
scope: "host",
|
||
version: 1,
|
||
schema: z.object({ showMetadata: z.boolean().default(true) }),
|
||
});
|
||
```
|
||
|
||
在 `index.server.ts` 中、返回清理函数之前注册它。返回的句柄让服务端代码可以读取该文档并响应变化。使用自己数据的界面可以只保留在客户端。
|
||
|
||
```ts
|
||
export default function contribute(server: PluginServerContext) {
|
||
const settings = server.registerSettings(preferences);
|
||
|
||
settings.subscribe((next) => {
|
||
if (next.status === "ready") {
|
||
console.log("Settings changed", next.revision);
|
||
}
|
||
});
|
||
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
| 定义字段 | 契约 |
|
||
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
|
||
| `id` | 匹配 `[a-z][a-z0-9_-]*` 的小写标识符;在同一次安装中唯一。 |
|
||
| `scope` | 必填 `"host"` 或 `"device"`。主机文档由授权客户端共享。设备文档以插件安装和设置 ID 为键保存在当前设备上,用于布局偏好。 |
|
||
| `version` | 必填的正整数,描述 schema 的版本,与写入修订号无关。 |
|
||
| `schema` | JSON 值的 Zod schema。请提供默认值,使解析 `{}` 能得到一份完整的文档。 |
|
||
| `migrate(values, fromVersion)` | 可选的同步或异步函数,从较旧的存储版本转换。它的输出必须通过当前的 schema。 |
|
||
|
||
在任何贡献的组件中调用 `useSettings(preferences)`。它返回一个可区分的状态:
|
||
|
||
| `status` | 可用的数据 |
|
||
| --------- | ---------------------------------------------- |
|
||
| `loading` | 读取尚未完成;不要把默认值当作已保存的值渲染。 |
|
||
| `ready` | 带类型的 `values` 和一个不透明的 `revision`。 |
|
||
| `invalid` | `error` 和 `revision`;存储的数据会被保留。 |
|
||
| `error` | 读取或连接产生的 `error`。 |
|
||
|
||
服务端句柄提供 `read()` 和 `subscribe()`。`read()` 返回与客户端 hook 相同的 `ready` 或
|
||
`invalid` 状态,包括不透明的修订号。`subscribe()` 返回一个清理函数,并在每次成功保存、重置或迁移之后
|
||
收到新的 `ready` 状态。无效的写入和修订冲突不会通知订阅者。监听器的失败只会被记录,不会把已提交的写入
|
||
变成保存失败。
|
||
|
||
```ts
|
||
const current = await settings.read();
|
||
if (current.status === "ready") {
|
||
// Use current.values and current.revision.
|
||
}
|
||
```
|
||
|
||
每种状态还提供 `saving`、`saveError` 以及以下操作:
|
||
|
||
- `save(values, revision): Promise<boolean>` 校验并保存一份完整的文档。校验失败、冲突或传输失败时,
|
||
它返回 `false` 并设置 `saveError`;它不会抛出错误。
|
||
- `reset(): Promise<boolean>` 使用 hook 加载的修订号,显式地把文档替换为 schema 默认值。它可以用来
|
||
从无效的存储数据中恢复。
|
||
- `reload(): Promise<void>` 清除保存错误并重新读取。草稿由你的组件负责;重新加载不会自动丢弃这份草稿。
|
||
|
||
对于即时生效的开关,把 `{ ...settings.values, showMetadata }` 和 `settings.revision` 传给 `save`。
|
||
对于草稿编辑器,在打开时同时记下值和修订号。在保存成功或用户放弃草稿之前,一直保留这个修订号。过期的
|
||
修订号会让保存被拒绝,草稿和更新后的已保存值都会被保留。
|
||
|
||
写入是原子的,并在主机上校验。已连接的客户端无需重新加载插件就能收到更新。这些值在 daemon 重启、插件
|
||
重新加载、停用和更新之后依然保留。移除一次安装会删除它的设置。重新安装同一个 ID 会从默认值开始。
|
||
|
||
缺失的文档使用 schema 默认值。无效的数据、失败的迁移以及不受支持的更新版本会产生 `invalid`,而不会
|
||
悄悄重置文件。成功的迁移只会持久化一次新版本。这些文档是普通的主机端 JSON,而不是凭据保险库。
|
||
设置相关的 RPC 使用现有的 `daemon.manage` 权限来执行插件。
|
||
|
||
完整的[设置示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/settings)
|
||
包含即时生效的控件、带校验的草稿编辑器、自定义内容以及 Command Center 导航。
|
||
|
||
## 工作区面板 {#workspace-panels}
|
||
|
||
使用 `addWorkspacePanel` 注册工作区面板,使用 `addAgentPanel` 注册 Agent 面板。
|
||
两者与[文件面板](#file-panels)使用相同的面板契约。
|
||
|
||
`client/review.tsx`:
|
||
|
||
```tsx
|
||
import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@getpaseo/plugin/client";
|
||
import { useMemo } from "react";
|
||
import { Text, View } from "react-native";
|
||
|
||
export function ReviewPanel({ theme, layout, workspaceId, params }: PluginAgentPanelProps) {
|
||
const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name);
|
||
const agent = useAgent(params.agentId, ({ id, title }) => ({ id, title }));
|
||
const styles = useMemo(
|
||
() => ({
|
||
screen: {
|
||
flex: 1,
|
||
padding: layout.compact ? 16 : 24,
|
||
backgroundColor: theme.colors.surface0,
|
||
},
|
||
title: { color: theme.colors.foreground },
|
||
detail: { color: theme.colors.foregroundMuted },
|
||
}),
|
||
[theme, layout.compact],
|
||
);
|
||
return (
|
||
<View style={styles.screen}>
|
||
<Text style={styles.title}>{workspaceName}</Text>
|
||
<Text style={styles.detail}>{agent?.title ?? agent?.id}</Text>
|
||
</View>
|
||
);
|
||
}
|
||
```
|
||
|
||
`index.client.tsx`:
|
||
|
||
```ts
|
||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||
import { ReviewPanel } from "./client/review";
|
||
|
||
export default function contribute(client: PluginClientContext) {
|
||
client.addAgentPanel({
|
||
id: "review",
|
||
title: "Review",
|
||
icon: "Scan",
|
||
locations: ["workspace", "explorer"],
|
||
Component: ReviewPanel,
|
||
});
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
`addWorkspacePanel` 和 `addAgentPanel` 的字段:
|
||
|
||
| 字段 | 是否必填 | 含义 |
|
||
| ----------- | -------- | --------------------------------------------------- |
|
||
| `id` | 是 | 插件内部的面板 ID。 |
|
||
| `title` | 是 | 工作区标签页的标题。 |
|
||
| `icon` | 是 | Lucide 名称、`{ svg }` 或组件。参见[图标](#icons)。 |
|
||
| `locations` | 否 | `workspace` 和/或 `explorer`。默认为 `workspace`。 |
|
||
| `Component` | 是 | 与所选上下文的属性相匹配的 React Native 组件。 |
|
||
|
||
每个面板都接收 `PluginPanelProps<Params>`,包含 `theme`、`host`、`layout`、`navigation`、
|
||
`workspaceId`、只读的 `params`、`revision` 和 `tab`。工作区面板使用空的
|
||
`PluginWorkspacePanelParams`;Agent 面板的 `PluginAgentPanelParams` 包含 `agentId`。
|
||
每次向同一标签页重新发送 params 时,`revision` 都会递增,即使请求与上次相同。
|
||
|
||
`tab.setModified(boolean)` 标记未保存的更改,并在关闭前询问。`tab.state` 是由面板定义的持久化
|
||
JSON,初始为 `undefined`;`tab.setState(value)` 替换整个值。每个标签页拥有独立状态。
|
||
Paseo 只保存状态,不解释其内容。用 `useTabState(tab, schema, defaultState)` 验证它:
|
||
|
||
```tsx
|
||
const schema = z.object({ view: z.enum(["preview", "source"]) });
|
||
const [state, setState] = useTabState(tab, schema, { view: "preview" });
|
||
// Missing or invalid persisted state returns the default.
|
||
// In an event handler, call setState({ view: "source" }).
|
||
```
|
||
|
||
从 `@getpaseo/plugin/client` 导入 `useTabState`,从 `zod` 导入 `z`。setter 替换整个 JSON 值。
|
||
标签页恢复和应用重新加载后,状态仍然保留。
|
||
|
||
统一属性、`addAgentPanel` 和 `useTabState` 需要 Paseo 0.13.0 或更高版本。
|
||
|
||
**已弃用,将在 2027-04-23 之后移除**:0.12 面板的 `context` 和顶层 `agentId` 属性、
|
||
`addWorkspacePanel({ context: "workspace" })` 以及 `addWorkspacePanel({ context: "agent" })`
|
||
仍然有效。省略工作区的 `context`,用 `addAgentPanel` 注册 Agent 面板,并读取 `params.agentId`。
|
||
|
||
用 `useWorkspace(workspaceId, selector)` 和 `useAgent(params.agentId, selector)` 读取缓存的状态。selector 是必填的。Paseo 对它的结果做浅比较,因此选择 `{ name, status }` 时,无关字段的变化不会触发重新渲染。在一次调用中选择组件渲染的所有字段;不要选择整个快照。
|
||
|
||
当记录不可用时,两个 hook 都返回 `null`。否则它们会针对规范化的客户端状态同步运行。快照 DTO 及其嵌套值都是深度只读的,并在运行时被冻结。不要调用插件 RPC 来获取当前的工作区或 Agent。可选的或特定于厂商的补充数据,请在组件渲染之后再获取。
|
||
|
||
工作区快照字段:
|
||
|
||
| 字段 | 类型 |
|
||
| -------------------- | ----------------------------------------------------------------- |
|
||
| `id` | `string` |
|
||
| `projectId` | `string` |
|
||
| `projectDisplayName` | `string` |
|
||
| `projectRootPath` | `string` |
|
||
| `directory` | `string` |
|
||
| `projectKind` | `"git" \| "non_git" \| "directory"` |
|
||
| `kind` | `"directory" \| "local_checkout" \| "checkout" \| "worktree"` |
|
||
| `name` | `string` |
|
||
| `title` | `string \| null` |
|
||
| `status` | `"needs_input" \| "failed" \| "running" \| "attention" \| "done"` |
|
||
| `statusEnteredAt` | ISO 时间戳或 `null` |
|
||
| `archivingAt` | ISO 时间戳或 `null` |
|
||
| `diffStat` | `{ additions: number; deletions: number } \| null` |
|
||
|
||
Agent 快照字段:
|
||
|
||
| 字段 | 类型 |
|
||
| ------------------- | -------------------------------------------------------------- |
|
||
| `id` | `string` |
|
||
| `workspaceId` | `string` |
|
||
| `provider` | `string` |
|
||
| `status` | `"initializing" \| "idle" \| "running" \| "error" \| "closed"` |
|
||
| `createdAt` | ISO 时间戳 |
|
||
| `updatedAt` | ISO 时间戳 |
|
||
| `lastActivityAt` | ISO 时间戳 |
|
||
| `title` | `string \| null` |
|
||
| `cwd` | `string` |
|
||
| `model` | `string \| null` |
|
||
| `currentModeId` | `string \| null` |
|
||
| `thinkingOptionId` | `string \| null` |
|
||
| `requiresAttention` | `boolean` |
|
||
| `attentionReason` | `"finished" \| "error" \| "permission" \| null` |
|
||
| `parentAgentId` | `string \| null` |
|
||
| `labels` | `Record<string, string>` |
|
||
|
||
标签页的焦点、拆分、关闭、持久化、查询状态、API/RPC Provider 以及渲染错误边界都由 Paseo 负责。如果恢复的标签页所对应的插件、面板、上下文、工作区或 Agent 不可用,该标签页会保持打开并显示“不可用”消息,而不会让工作区崩溃。
|
||
|
||
## 文件面板 {#file-panels}
|
||
|
||
注册一个组件,把文件渲染为文件标签页的主体。组件会收到所选主机上该文件的句柄,远程主机也一样。由组件读取文件、显示自己的加载状态,并在用户编辑时写回文件。面板报告有未保存更改时,标签页会显示一个圆点。
|
||
|
||
```tsx
|
||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||
import { PdfPanel } from "./client/pdf-panel";
|
||
|
||
export default function contribute(client: PluginClientContext) {
|
||
return client.addFilePanel({
|
||
id: "pdf",
|
||
title: "PDF",
|
||
icon: "FileText",
|
||
files: { extensions: ["pdf"] },
|
||
Component: PdfPanel,
|
||
});
|
||
}
|
||
```
|
||
|
||
`addFilePanel` 接收一个 `PluginFilePanelContribution`,并返回一个幂等的清理函数。客户端入口被卸载时,Paseo 也会移除该注册。
|
||
|
||
| 字段 | 必填 | 含义 |
|
||
| ----------- | ---- | -------------------------------------------------------------------- |
|
||
| `id` | 是 | 插件内唯一的 ID,匹配 `^[a-z][a-z0-9-]*$`。 |
|
||
| `title` | 是 | 显示在选择器、**打开方式…** 菜单和 **设置 > 文件** 中的非空标签。 |
|
||
| `icon` | 是 | Lucide 名称、`{ svg }` 或组件。参见[图标](#icons)。 |
|
||
| `files` | 是 | `{ extensions: readonly string[]; filenames?: readonly string[] }`。 |
|
||
| `Component` | 是 | 接收 `PluginFilePanelProps` 的组件。 |
|
||
|
||
扩展名不带前导点,匹配时不区分大小写。文件名按完整的 basename 匹配,区分大小写。选择器不包含路径、glob 或通配符。只按文件名选择时,请使用 `extensions: []`。至少需要一个扩展名或文件名。
|
||
|
||
### 文件面板属性 {#file-panel-props}
|
||
|
||
`PluginFilePanelProps` 包含[主题和布局](#theme-and-layout)中介绍的通用属性 `theme`、`host`、`layout` 和可选的 `navigation`,另外还有:
|
||
|
||
| 属性 | 类型 | 含义 |
|
||
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------ |
|
||
| `workspaceId` | `string` | 包含该文件标签页的工作区。 |
|
||
| `params` | `PluginFilePanelParams` | 打开请求提供的只读 `path`、`lineStart?` 和 `lineEnd?`。 |
|
||
| `revision` | `number` | 每次向该标签页重新发送 params 时递增,即使是同一行。 |
|
||
| `file` | `PluginFile` | 从 `params.path` 解析的文件能力;标签页显示该文件期间保持不变。 |
|
||
| `tab` | `PluginTab` | `setModified`、持久化的自定义 `state` 和 `setState`。参见[工作区面板](#workspace-panels)。 |
|
||
|
||
Paseo 在知道文件存在及其大小后就会挂载你的面板,不会先读取文件。`file` 对象保持不变,它的 stat 字段保存 Paseo 已知的最新版本。请在渲染时读取这些字段;它们变化时不会重新渲染你的面板。面板要通过 `watch` 监听器,或 `useFileText`、`useFileBytes`、`useFileUrl` 引起的重新渲染,得知它们已变化:
|
||
|
||
| 成员 | 含义 |
|
||
| -------------------------------- | ----------------------------------------------------------------------------- |
|
||
| `path`、`name`、`extension` | 主机上的绝对路径、basename,以及不带点的小写扩展名(没有时为 `""`)。 |
|
||
| `size`、`modifiedAt`、`mimeType` | 以字节计的大小、修改时间戳,以及主机报告的 MIME 类型。 |
|
||
| `readText()` | 以 UTF-8 读取文件。字节顺序标记(BOM)会被去掉,并由 `writeText` 写回。 |
|
||
| `readBytes()` | 读取文件的字节。 |
|
||
| `url()` | 当前字节的本地 `blob:`(Web)或 `file:`(原生)URL,供 `DocumentFrame` 使用。 |
|
||
| `writeText(text, options?)` | 如果文件在磁盘上自该句柄上次读取或写入后没有变化,则以 UTF-8 写入文本。 |
|
||
| `writeBytes(bytes, options?)` | 与上面相同,但写入字节。 |
|
||
| `watch(listener)` | 用 `PluginFileEvent` 调用 `listener(event)`;返回取消订阅函数。 |
|
||
|
||
`PluginFileEvent` 是 `{ type: "changed" }` 或 `{ type: "deleted" }`。请根据 `type` 分支处理;以后的版本可能会增加字段。文件被删除时,你的面板保持挂载。`watch` 会在一次读取确认文件已不存在后报告 `{ type: "deleted" }`,因此某个程序删除文件后又写回时,只会报告 `{ type: "changed" }`。被删除的文件重新出现时,会报告 `{ type: "changed" }`。
|
||
|
||
如果文件在该句柄上次读取或写入后在磁盘上发生了变化,写入会以 `PluginFileConflictError` reject。传入 `{ force: true }` 可以强制写入。文件已不存在时,无论是否传入 `force`,写入都会以 `PluginFileDeletedError` reject。句柄自己的写入不会通知它的监听器。`url()` 返回的 URL 由 Paseo 管理:在更新版本的 URL 解析完成或标签页关闭之前,它一直有效。不要用 `file.path` 拼接 daemon URL;daemon 在远程时,本地 URL 同样可用。
|
||
|
||
### 读取文件 {#read-the-file}
|
||
|
||
大多数面板通过 hook 读取文件。每个 hook 在挂载时读取,在文件于磁盘上变化时重新读取,并在重新读取期间保留当前值:
|
||
|
||
| Hook | 就绪状态 |
|
||
| -------------------- | ---------------------------------------- |
|
||
| `useFileText(file)` | `{ status: "ready", text: string }` |
|
||
| `useFileBytes(file)` | `{ status: "ready", bytes: Uint8Array }` |
|
||
| `useFileUrl(file)` | `{ status: "ready", url: string }` |
|
||
|
||
在第一次读取之前,每个 hook 还会返回 `{ status: "loading" }`;读取失败时返回 `{ status: "error", error }`。文件已删除时,`error` 是 `PluginFileDeletedError`。在内容显示到屏幕上之前,请显示 `@getpaseo/plugin/client/ui` 中的 `Loading`;它会填满面板,所以可以把它渲染在仍在布局的内容之上。
|
||
|
||
```tsx
|
||
import { type PluginFilePanelProps, useFileUrl } from "@getpaseo/plugin/client";
|
||
import { DocumentFrame, Loading } from "@getpaseo/plugin/client/ui";
|
||
import { Text } from "react-native";
|
||
|
||
export function PdfPanel({ file, theme }: PluginFilePanelProps) {
|
||
const url = useFileUrl(file);
|
||
if (url.status === "loading") return <Loading />;
|
||
if (url.status === "error") {
|
||
return <Text style={{ color: theme.colors.statusDanger }}>{url.error.message}</Text>;
|
||
}
|
||
return <DocumentFrame url={url.url} testID="pdf-panel" />;
|
||
}
|
||
```
|
||
|
||
### 编辑文件 {#edit-the-file}
|
||
|
||
hook 的重新读取会采用磁盘上的版本,因此下一次写入会覆盖那里发生的任何变化。持有未保存编辑的面板应只用 `readText()` 读取一次,然后自己处理 `watch`:
|
||
|
||
1. 用 `file.readText()` 读取并显示文本。
|
||
2. 每次编辑时,调用 `tab.setModified(true)`,并以防抖方式用 `file.writeText(text)` 写入。
|
||
3. 写入完成且没有更新的编辑等待写入时,调用 `tab.setModified(false)`。
|
||
4. 收到 `{ type: "changed" }` 且没有未保存内容时,重新读取。有未保存的编辑时,显示冲突,让用户重新加载或用 `{ force: true }` 覆盖。
|
||
5. 遇到 `PluginFileConflictError` 时,显示同样的冲突。
|
||
6. 收到 `{ type: "deleted" }` 或遇到 `PluginFileDeletedError` 时,告诉用户文件已不存在。
|
||
|
||
请在组件卸载之前写入待保存的编辑:用其他面板打开标签页或关闭标签页都会卸载它。如果面板报告有未保存的更改,Paseo 会在关闭标签页之前询问。[Markdown 面板](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel)示例实现了这些步骤。
|
||
|
||
### 选择与打开方式 {#selection-and-open-with}
|
||
|
||
插件面板是其选择器匹配的每个文件的候选面板,无论是文本还是二进制文件。内置面板要求主机报告的文件类型:Markdown、HTML 和 **源代码** 打开文本文件,**图片** 打开图片。Markdown 和 HTML 面板各自有文件的预览视图和源代码视图,在面板的工具栏中切换。**源代码** 是 Paseo 的编辑器,是没有其他内置面板认领的文本文件的内置面板;它从不与其他内置面板一起提供。候选面板的顺序是:认领该文件的内置面板,然后按主机目录顺序排列的插件面板,最后是没有其他内置面板认领该文件时的源代码。
|
||
|
||
记住的关联在其面板于当前主机上可用时选择该面板。否则 Paseo 打开第一个候选面板。当插件面板是多个候选面板之一且没有适用的关联时,Paseo 每次打开标签页时询问一次,询问显示在它已经打开的面板上方。只有一个候选面板的文件从不询问。安装了一个 Markdown 插件后,选择器会提供 **Markdown — Paseo** 和 **Rich Markdown — markdown-panel**。认领某个没有内置面板认领的文本扩展名的插件会与源代码竞争,所以第一次打开时会询问;单独认领某个二进制文件的插件会直接打开,不询问。
|
||
|
||
**不再询问** 会在此设备上为该扩展名记住这个选择。当某个候选面板匹配了确切的文件名时,该选择改为应用于那个 basename。不可用的关联在该主机上会被忽略,并为安装了其面板的主机保留。
|
||
|
||
用户有三种方式选择面板:第一次打开时的选择器、标签页菜单中的 **打开方式…**,以及 **设置 > 文件**。打开方式会切换当前标签页,并且对于这个标签页优先于记住的关联,直到标签页被重新定向;其中的 **始终用于** 会记住当前显示的面板。从链接或搜索结果在某一行打开文件时,标签页会切换到它的内置面板,并在源代码视图中显示该行。没有候选面板的文件显示 **二进制预览不可用**;二进制文件没有源代码面板。
|
||
|
||
### DocumentFrame {#documentframe}
|
||
|
||
从 `@getpaseo/plugin/client/ui` 导入 `DocumentFrame`,用来显示 `file.url()` 或 `useFileUrl` 返回的本地文件 URL:
|
||
|
||
| 属性 | 必填 | 含义 |
|
||
| -------- | ---- | -------------------------------------------------------------------- |
|
||
| `url` | 是 | 本地文件 URL;Web 上为 `blob:`,原生上为 `file:`。其他协议会被拒绝。 |
|
||
| `testID` | 否 | 该框架的测试标识。 |
|
||
|
||
在 Web 上,挂载 iframe 之前会先检查本地 URL 的媒体类型。PDF 使用浏览器的 PDF 查看器,不加沙箱;其他所有类型,包括 HTML 和未知类型,都保持完整沙箱,不允许脚本或同源访问。原生平台使用禁用了 JavaScript、存储和后续导航的 WebView。在平台无法内联显示文档的地方,例如 Android 上的 PDF,`DocumentFrame` 会改为显示一条说明文件类型的消息,因此请在所有平台上渲染它。
|
||
|
||
内联 PDF 渲染需要 Paseo 0.13.0 或更高版本的 daemon;更早的 daemon 会把 PDF 报告为 `application/octet-stream`。
|
||
|
||
### 版本要求与示例 {#version-requirement-and-examples}
|
||
|
||
文件面板、`DocumentFrame` 和 `Loading` 需要 Paseo 0.13.0 或更高版本。请在 `paseo-plugin.json` 中声明 `"requirements": { "paseo": ">=0.13.0" }`。daemon 和应用都会检查[版本要求](#requirements);更早的应用会在执行客户端包之前跳过它。用 `writeBytes` 写入需要 Paseo 0.13.0 或更高版本的 daemon。
|
||
|
||
完整的目录示例:
|
||
|
||
- [PDF 面板](https://github.com/getpaseo/paseo/tree/main/plugin-examples/pdf-panel):`useFileUrl` 搭配 `Loading` 和 `DocumentFrame`。
|
||
- [Markdown 面板](https://github.com/getpaseo/paseo/tree/main/plugin-examples/markdown-panel):打包的 CodeMirror 编辑器,只读取一次、用 `writeText` 保存并处理冲突,在原生平台上显示只读文本。
|
||
|
||
## Command Center 条目 {#command-center-items}
|
||
|
||
在 macOS 上按 **⌘K**,在 Windows 和 Linux 上按 **Ctrl+K** 打开 Command Center,然后搜索条目标题。
|
||
|
||
注册一个操作,并在回调中打开一个面板:
|
||
|
||
```tsx
|
||
import { defineRpc } from "@getpaseo/plugin";
|
||
import { z } from "zod";
|
||
|
||
const refreshReview = defineRpc({
|
||
name: "review.refresh",
|
||
input: z.object({ agentId: z.string(), scope: z.string().optional() }),
|
||
output: z.object({ refreshed: z.boolean() }),
|
||
});
|
||
|
||
client.addCommandCenterItem({
|
||
id: "open-review",
|
||
title: "Open review",
|
||
icon: "Scan",
|
||
keywords: ["inspect"],
|
||
context: "agent",
|
||
async onSelect({ paseo, rpc, workspace, agent, openPanel }) {
|
||
await paseo.workspaces.ref(workspace.id).setTitle(`Review ${agent.id}`);
|
||
await rpc(refreshReview, { agentId: agent.id });
|
||
openPanel("review");
|
||
},
|
||
});
|
||
```
|
||
|
||
`addCommandCenterItem` 的字段:
|
||
|
||
| 字段 | 是否必填 | 含义 |
|
||
| ---------- | -------- | --------------------------------------------------- |
|
||
| `id` | 是 | 插件内部的条目 ID。 |
|
||
| `title` | 是 | 搜索结果的标题。 |
|
||
| `icon` | 是 | Lucide 名称、`{ svg }` 或组件。参见[图标](#icons)。 |
|
||
| `keywords` | 否 | 额外的 Command Center 搜索词。 |
|
||
| `context` | 是 | `global`、`workspace` 或 `agent`。 |
|
||
| `onSelect` | 是 | 对应上下文的客户端回调。 |
|
||
|
||
全局条目出现在该安装所选择的主机上。只有当该主机有一个处于活动状态的缓存工作区时,工作区条目才会出现。只有当获得焦点的工作区标签页是一个 Agent,或者是一个缓存记录属于该工作区的 Agent 上下文插件面板时,Agent 条目才会出现。缺少上下文时条目会被移除,而不是调用插件去获取上下文。
|
||
|
||
每个回调都会收到:
|
||
|
||
| 字段 | 上下文 | 含义 |
|
||
| ------------------------- | -------------- | ----------------------------------------------------------------------------------------------- |
|
||
| `context` | 全部 | 对应的判别字段。 |
|
||
| `paseo` | 全部 | 所选主机现有的 `PaseoApi`。 |
|
||
| `rpc(contract, input)` | 全部 | 对此安装的 daemon 端插件处理函数的带类型调用。 |
|
||
| `openScreen(input)` | 全部 | 打开此插件注册的某个界面:`{ screenId, params? }`。 |
|
||
| `workspace` | 工作区和 Agent | 同步的工作区快照。 |
|
||
| `agent` | Agent | 同步的、与之匹配的 Agent 快照。 |
|
||
| `openPanel(id, options?)` | 工作区和 Agent | 在回调当前的上下文中打开一个已注册的面板。传入 `{ location: "explorer" }` 可以打开到 Explorer。 |
|
||
|
||
Agent 回调可以打开 Agent 面板或工作区面板。工作区回调只能打开工作区面板。未知的界面和面板 ID 会明确报错。普通的工作区、Agent、Provider 和 daemon 配置操作请使用 `paseo`。插件特有的文件系统、凭据、厂商或 daemon 本地工作请使用 `rpc`。
|
||
|
||
## 斜杠命令 {#slash-commands}
|
||
|
||
注册一个命令,当用户在消息 Composer 中提交 `/name args` 时,它会在 Paseo 客户端中运行。这段文本
|
||
永远不会发送给 Agent:
|
||
|
||
```ts
|
||
client.addSlashCommand({
|
||
name: "review",
|
||
description: "Run the review bot",
|
||
argumentHint: "[scope]",
|
||
context: "agent",
|
||
async onSubmit({ args, agent, rpc, openPanel }) {
|
||
await rpc(refreshReview, { agentId: agent.id, scope: args });
|
||
openPanel("review");
|
||
},
|
||
});
|
||
```
|
||
|
||
| 字段 | 是否必填 | 含义 |
|
||
| -------------- | -------- | ---------------------------------- |
|
||
| `name` | 是 | 不带前导斜杠的命令名称。 |
|
||
| `description` | 是 | Composer 自动补全中的描述。 |
|
||
| `argumentHint` | 是 | 显示在命令名称之后的简短用法提示。 |
|
||
| `context` | 是 | `"workspace"` 或 `"agent"`。 |
|
||
| `onSubmit` | 是 | 对应上下文的客户端回调。 |
|
||
|
||
`onSubmit` 接收对应的 Command Center 回调上下文以及 `args`。对于 `/review src`,`args` 是
|
||
`"src"`;Paseo 只去掉剩余部分首尾的空白,解析留给插件。自动补全行、清空输入框和错误 toast 由
|
||
Paseo 负责。它不会等待 `onSubmit`,也不会显示进行中的状态;如需显示,请使用 Composer 胶囊或面板。
|
||
|
||
优先级依次是内置客户端命令、插件命令、Provider 命令。优先级较低的冲突命令会被省略。内置别名也会占用
|
||
它们的名称。插件之间发生冲突时,按稳定的目录顺序排在前面的插件胜出。Composer 中有附件时,命令不会运行。
|
||
|
||
## 标题栏按钮 {#header-buttons}
|
||
|
||
试试[按钮示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/buttons),它在标题栏和
|
||
Composer 中演示了操作、菜单、自定义图标和内容以及可见性更新。它会在几种模式之间切换一个标题栏按钮;
|
||
其他操作放在一个名为 Tools 的菜单中。
|
||
|
||
`client.addHeaderButton({ id, workspaceId, button })` 在工作区标题栏右侧的内置操作之前添加一个按钮。
|
||
它返回一个带有 `update(patch)` 和 `remove()` 的注册对象。
|
||
|
||
```tsx
|
||
const review = client.addHeaderButton({
|
||
id: "review",
|
||
workspaceId,
|
||
button: {
|
||
title: "Open review",
|
||
icon: "Scan",
|
||
label: "Review",
|
||
behavior: {
|
||
kind: "action",
|
||
onPress() {
|
||
client.openPanel("review", { workspaceId });
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
review.update({ label: "Review · 3" });
|
||
review.update({ visible: false });
|
||
review.update({ visible: true });
|
||
review.remove();
|
||
```
|
||
|
||
省略 `label` 即为只有图标的标题栏按钮。在宽布局中,菜单和弹出框会显示一个 V 形箭头。Paseo 会把多出来的
|
||
贡献移入一个共享的溢出菜单。在手机和较窄的窗口中,标题栏没有空间放置插件按钮,因此每个按钮都会成为工作区
|
||
菜单(`···`)中的一行,显示它的图标和标题;它的菜单和弹出框以该底部面板中的页面打开。位置和溢出由宿主决定。
|
||
|
||
## Composer 项 {#composer-items}
|
||
|
||
`client.addComposerItem({ id, Component })` 把一个组件放进 composer:Agent composer、为新 Agent 准备的草稿 composer,以及新建工作区界面上的 composer。`useComposer()` 读取并修改 composer 所配置的内容,`@getpaseo/plugin/client/ui` 中的 `Toggle`、`Select` 和 `Action` 负责绘制控件。
|
||
|
||
```tsx
|
||
import { useComposer, type PluginClientContext } from "@getpaseo/plugin/client";
|
||
import { Action, Select, Toggle } from "@getpaseo/plugin/client/ui";
|
||
|
||
function ReviewFlag() {
|
||
const composer = useComposer();
|
||
return (
|
||
<Toggle
|
||
title="Needs review"
|
||
icon="Flag"
|
||
value={composer.agent.metadata["review.needsReview"] === true}
|
||
onValueChange={({ value }) =>
|
||
composer.setMetadata({ "review.needsReview": value ? true : null })
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function Checklist() {
|
||
const composer = useComposer();
|
||
return (
|
||
<Action
|
||
title="Insert checklist"
|
||
icon="ListChecks"
|
||
onPress={() => composer.insertText("- [ ] Tests\n- [ ] Docs\n")}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export default function contribute(client: PluginClientContext) {
|
||
client.addComposerItem({ id: "review-flag", Component: ReviewFlag });
|
||
client.addComposerItem({ id: "checklist", Component: Checklist });
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
`useComposer()` 返回该项所在的 composer:
|
||
|
||
| 字段或命令 | 含义 |
|
||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||
| `context` | `"agent"` 表示运行中的 Agent,`"draft"` 表示工作区中的新 Agent,`"new-workspace"` 表示新建工作区界面。 |
|
||
| `prompt`、`setPrompt(text)`、`insertText(text)` | 提示词。`insertText` 会替换选区,或在光标处插入。 |
|
||
| `attachments`、`addAttachment`、`removeAttachment` | 来自[你自己的附件来源](#add-a-composer-attachment-source)的项目。其他插件和 Paseo 自身的附件不会列出。 |
|
||
| `agent.provider`、`model`、`mode`、`thinking` | Agent 当前使用的配置,或草稿将要创建的配置。 |
|
||
| `agent.options` | 当前生效的 Provider 选项,包含默认值。在草稿中:创建 Agent 时将使用的选项,包含你所做的修改。 |
|
||
| `agent.metadata` | Agent 的[元数据](/docs/plugins/hooks#metadata)。在新建工作区中是第一个 Agent 的元数据。 |
|
||
| `setProvider`、`setModel`、`setMode`、`setThinking` | 返回 Promise。`setProvider` 会像 Provider 选择器一样重置模型。在 `context: "agent"` 下(运行中的 Agent 保持其 Provider)以及主机不提供该 Provider 时会被拒绝。`setModel` 和 `setThinking` 原样传递该值;对运行中的 Agent,Provider 拒绝时 Promise 会被拒绝。 |
|
||
| `setOptions(patch)`、`setMetadata(patch)` | 顶层键替换,`null` 删除该键。草稿中设置的元数据在创建 Agent 时发送,发送提示词后清空。 |
|
||
|
||
同一个调用在每个 composer 中行为一致,插件不需要选择 composer 的实现。在运行中的 Agent 上调用 `setMetadata` 需要支持元数据的主机;主机过旧时,Promise 会以 “Update the host to use metadata.” 拒绝。
|
||
|
||
`Toggle` 接收 `value` 和 `onValueChange({ value })`。`Select` 接收 `value`、`options`(`{ value, label, description? }`)和 `onValueChange({ value })`。`Action` 接收 `onPress(event)`。三者都接收 `title`、`icon`([Lucide 名称、SVG 或组件](#icons))和 `disabled`。回调可以返回 Promise:在它完成之前控件保持禁用,被拒绝时 Paseo 会像对待自身控件一样,以错误提示向用户显示。控件的位置由 Paseo 决定:composer 空间足够时显示在工具栏中,不够时(手机上始终如此)成为 composer 溢出面板中的一行。`Select` 会就地打开列表,而不是替换面板中的行。`Component` 如果渲染这些控件以外的内容,会在其所在位置绘制;在溢出面板中,该位置就是面板。
|
||
|
||
每个命令都按运行时 composer 的当前状态执行,而不是按创建回调的那次渲染,所以 `await composer.setProvider("codex")` 之后再 `await composer.setModel("gpt")` 会同时生效。命令的 Promise 在更改生效后完成,`composer.agent` 在下一次渲染时反映它。
|
||
|
||
新建工作区界面在创建工作区及其 Agent 期间会锁定 composer。你的控件显示为禁用,`setPrompt` 和 `insertText` 会抛出错误,Agent 命令会以 "The composer is locked while the agent is being created." 拒绝。`composer.agent.options` 和 `composer.agent.metadata` 是冻结的副本;请通过 `setOptions` 和 `setMetadata` 修改它们。
|
||
|
||
在 Paseo 知道渲染结果之前,composer 项就已计入可容纳的数量。不渲染任何内容的项仍可能在窄 composer 上留下空的溢出面板,所以请始终渲染一个控件。
|
||
|
||
[Composer 控件示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)注册了一个 `Select`、两个 `Toggle`(其中一个写入 Provider 选项)和一个 `Action`,还有一个资源管理器面板和一个 Command Center 条目,它们按引用访问 composer。
|
||
|
||
### 按引用访问 composer {#reach-a-composer-by-reference}
|
||
|
||
运行中的 Agent 无论标签页是否打开,都有自己的 composer。任意插件组件中的 `useComposers(selector)`,以及每个命令上下文中的 `composers(selector)`,都会返回与 `useComposer()` 给 composer 项的相同的 `PluginComposer`,对象可以是你指定的 Agent,也可以是当前聚焦的 Agent:
|
||
|
||
```tsx
|
||
import { useComposers, type PluginClientContext } from "@getpaseo/plugin/client";
|
||
|
||
function InsertChecklist() {
|
||
const [focused] = useComposers({ focused: true });
|
||
return (
|
||
<Pressable disabled={!focused} onPress={() => focused?.insertText("- [ ] Tests\n")}>
|
||
<Text>Insert checklist</Text>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
export default function contribute(client: PluginClientContext) {
|
||
client.addWorkspacePanel({
|
||
id: "tools",
|
||
title: "Tools",
|
||
icon: "ListChecks",
|
||
locations: ["explorer"],
|
||
Component: InsertChecklist,
|
||
});
|
||
client.addCommandCenterItem({
|
||
id: "mark-for-review",
|
||
title: "Mark for review",
|
||
icon: "Flag",
|
||
context: "agent",
|
||
async onSelect({ agent, composers }) {
|
||
for (const composer of composers({ agentId: agent.id })) {
|
||
await composer.setMetadata({ "review.needsReview": true });
|
||
}
|
||
},
|
||
});
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
| 选择器 | 结果 |
|
||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `{ agentId }` | 此主机上该 Agent 的 composer。Agent 未知或已归档时为空。 |
|
||
| `{ focused: true }` | Command Center 交给 Agent 项的那个 Agent 的 composer:屏幕上工作区中聚焦的 Agent 标签页。当前界面不是工作区(例如插件界面或新建工作区),或聚焦的标签页不是 Agent 时为空,并随用户切换标签页而变化。 |
|
||
|
||
结果始终是数组,为空或只含一个 composer,所以同一段代码可以读取任一选择器。composer 变化时 `useComposers` 会重新渲染。`composers()` 返回的 composer 是调用时的状态,要获取下一个状态请再次调用 `composers()`。
|
||
|
||
命令与 [`useComposer()`](#composer-items) 相同,从外部访问时有以下差异:
|
||
|
||
- Agent 的 composer 在屏幕上时,`insertText` 在光标处插入。不在屏幕上时(例如用户在另一个工作区),它会追加到该 Agent 已保存的提示词,用户回来时文本就在输入框里。`setPrompt` 以同样的方式替换已保存的提示词。
|
||
- 运行中 Agent 的 composer 从不锁定。
|
||
- `setProvider` 会被拒绝,与在 Agent 自己的 composer 内一样。
|
||
- `setModel` 和 `setThinking` 不加检查地把值发给 Provider。Provider 拒绝的值会以 Provider 的消息拒绝。`model` 和 `thinking` 读取的是模型选择器此刻显示的值:在 Provider 的模型目录加载完成之前,是 Agent 上报或创建时使用的 id(可能是别名),加载完成后是目录中的 id。
|
||
- 草稿和新建工作区界面不能按引用访问,只有其中的项能通过 `useComposer()` 访问其 composer。
|
||
|
||
## 新建工作区项 {#new-workspace-items}
|
||
|
||
`client.addNewWorkspaceItem({ id, Component })` 在新建工作区界面上放置一个字段,位于项目、主机和分支选择器旁边。`useNewWorkspace()` 读取并修改界面将要创建的内容,`@getpaseo/plugin/client/ui` 中的 `Combobox`、`Select`、`Toggle` 和 `Action` 负责绘制字段。桌面端的项是设置行中的徽章;手机上是整行宽度的字段。
|
||
|
||
```tsx
|
||
import { useNewWorkspace, useRpc, type PluginClientContext } from "@getpaseo/plugin/client";
|
||
import { Combobox } from "@getpaseo/plugin/client/ui";
|
||
import { searchIssuesRpc } from "../shared/issues";
|
||
|
||
function IssueField() {
|
||
const newWorkspace = useNewWorkspace();
|
||
const searchIssues = useRpc(searchIssuesRpc);
|
||
const issueId = newWorkspace.metadata["issues.id"];
|
||
return (
|
||
<Combobox
|
||
title="Issue"
|
||
icon="CircleDot"
|
||
value={typeof issueId === "string" ? { id: issueId, label: issueId } : null}
|
||
search={async ({ query }) => (await searchIssues({ query })).issues}
|
||
onSelect={async ({ item }) => {
|
||
await newWorkspace.setBranch(item.branch);
|
||
await newWorkspace.setMetadata({ "issues.id": item.id });
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export default function contribute(client: PluginClientContext) {
|
||
client.addNewWorkspaceItem({ id: "issue", Component: IssueField });
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
`useNewWorkspace()` 返回该项所在的设置:
|
||
|
||
| 字段或命令 | 含义 |
|
||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `host`、`project` | 用户的选择,分别是 `{ id, label }` 和 `{ id, name } \| null`。只读:项不能切换主机或项目。 |
|
||
| `isolation`、`setIsolation` | `"local"` 或 `"worktree"`。无法创建 worktree 的项目读取为 `"local"`,此时 `setIsolation("worktree")` 会被拒绝。 |
|
||
| `baseRef`、`setBaseRef` | 新 worktree 的起点:`{ type: "branch", name }`、`{ type: "change-request", number }`,默认值未知时为 `null`。分支的 `name` 是创建时实际使用的完整 ref,例如 `refs/remotes/origin/main`,所以 `setBaseRef(newWorkspace.baseRef)` 不会改变任何内容。`setBaseRef` 接受 Base 选择器提供的任何 ref,或一个裸分支名,Paseo 会按其他地方一贯的方式解析。未选择项目时会被拒绝。 |
|
||
| `branch`、`setBranch` | 新 worktree 所创建的分支名称。`null` 表示由 Paseo 选择名称。隔离为本地或起点为变更请求时会保留该值,它为任何新建分支的 worktree 命名,起点可以是默认基线或所选分支;检出已有分支或变更请求的 worktree 不适用。 |
|
||
| `metadata`、`setMetadata` | 所创建工作区的[元数据](/docs/plugins/hooks#metadata),而不是第一个 Agent 的元数据。顶层键替换,`null` 删除该键。主机不支持元数据时以 “Update the host to use metadata.” 拒绝。 |
|
||
|
||
命令遵循 [Composer 项](#composer-items)的规则:每个命令都按界面运行时的状态执行,所以 `await newWorkspace.setBranch(name)` 之后再调用 `newWorkspace.setMetadata(patch)` 会同时生效,命令的 Promise 在更改生效后完成。命令从组件的第一个 effect 起即可使用,包括挂载时的 effect。载荷是 JSON 值。你的项针对某一台主机上的某一个项目挂载。用户更改其中任何一个时,Paseo 会重新挂载你的项并清除 `branch` 和 `metadata`,因此上一个项目的状态和 `Combobox` 结果不会保留;在挂载 effect 中做初始化的项会针对新项目再次初始化。在其项目或主机已被离开之后才调用的命令(例如用户切换项目时被放弃的查询最终返回)会以 "The New workspace screen moved to another project or host; this item belongs to the previous one." 拒绝,并且不做任何更改;为新项目挂载的项不受影响。`host`、`project`、`baseRef` 和 `metadata` 是冻结的。界面创建工作区期间会被锁定:你的字段显示为禁用,所有命令都会以 "The New workspace screen is locked while the workspace is being created." 拒绝。返回 Promise 的回调在完成之前会让控件保持禁用,被拒绝时会以错误提示向用户显示。
|
||
|
||
`Combobox` 是带可搜索列表的字段,结果由你的代码提供:
|
||
|
||
| 属性 | 含义 |
|
||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `value` | `{ id, label, description? } \| null`,字段显示的内容。它不必出现在搜索结果中,因此可以显示之前所做的选择。 |
|
||
| `search` | `({ query }) => Promise<Item[]>`。列表打开时以 `""` 调用,之后随用户输入防抖调用。每个 `Item` 是 `{ id, label, description? }` 加上你添加的字段。 |
|
||
| `onSelect` | `({ item }) => void \| Promise<void>`。`item` 就是 `search` 返回的对象。 |
|
||
|
||
搜索进行时 Paseo 显示 “Searching…”,搜索被拒绝时显示 “Search failed” 和错误信息,没有结果时显示空状态。新搜索进行期间会保留之前的结果,并忽略已被更新搜索取代的结果,所以 `search` 不需要自己处理取消。`Combobox` 接收与其他控件相同的 `title`、`icon` 和 `disabled`。
|
||
|
||
[新建工作区 Issue 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/new-workspace-issues)通过守护进程处理器搜索一个固定的 Issue 列表,并根据所选项设置分支和元数据。
|
||
|
||
## Composer 胶囊 {#composer-pills}
|
||
|
||
`client.addComposerPill({ id, workspaceId, agentId, button })` 使用同样的[按钮描述符](#button-descriptor),
|
||
并返回同样的注册对象。它面向某个 Agent 的 Composer 栏,与 Tasks 和 Subagents 并列。Composer 胶囊始终
|
||
显示图标和 `label`(省略 `label` 时显示 `title`)。它们从不显示箭头,菜单和弹出框也一样。
|
||
|
||
```tsx
|
||
const pill = client.addComposerPill({
|
||
id: "review",
|
||
workspaceId,
|
||
agentId,
|
||
button: {
|
||
title: "Open review",
|
||
icon: "Scan",
|
||
label: "Review",
|
||
behavior: {
|
||
kind: "action",
|
||
onPress() {
|
||
client.openPanel("review", { workspaceId, agentId });
|
||
},
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
对于随 Agent 目录变化的胶囊,请使用显式的[自有列表订阅](/docs/sdk/events#follow-one-agents-status)。
|
||
[本地插件示例](https://github.com/getpaseo/paseo/blob/main/plugin-examples/local-plugin/client/main.tsx)
|
||
会在每次快照时替换注册,并在入口清理时中止观察,包括尚未完成的引导过程。
|
||
|
||
## 按钮描述符 {#button-descriptor}
|
||
|
||
这些契约从 `@getpaseo/plugin/client` 导出。
|
||
|
||
| 字段 | 是否必填 | 含义 |
|
||
| ---------- | -------- | ------------------------------------------------------------------ |
|
||
| `title` | 是 | 非空的无障碍标签、提示文字和底部面板标题。 |
|
||
| `icon` | 是 | Lucide 名称、`{ svg }` 或 `ComponentType<PluginButtonIconProps>`。 |
|
||
| `label` | 否 | 非空的显示文字。省略时使用该位置的默认值。 |
|
||
| `visible` | 否 | 默认为 `true`。为 false 时移除触发器及其占用的布局空间。 |
|
||
| `disabled` | 否 | 默认为 `false`。按钮保持可见,但无法交互。 |
|
||
| `behavior` | 是 | 下面三种形式之一。 |
|
||
|
||
```tsx
|
||
type PluginButtonBehavior =
|
||
| { kind: "action"; onPress(): void | Promise<void> }
|
||
| { kind: "menu"; items: readonly PluginButtonMenuEntry[] }
|
||
| { kind: "popover"; Content: React.ComponentType<PluginButtonContentProps> };
|
||
```
|
||
|
||
操作在客户端运行。在 promise 完成之前,Paseo 会把按钮标记为忙碌,阻止重复按下,并用 toast 显示失败。
|
||
失败的操作可以重试。普通操作请使用客户端的 `paseo`,插件特有的后端工作请使用 `rpc`。
|
||
|
||
菜单和弹出框在宽布局中以锚定的浮层打开,在紧凑布局中以底部面板打开。按下整个触发器都会打开浮层;没有
|
||
分体按钮的行为。
|
||
|
||
### 菜单项 {#menu-entries}
|
||
|
||
菜单包含条目和分隔线。ID 由小写字母、数字和连字符组成,以字母开头,并且在该菜单内唯一。
|
||
|
||
```tsx
|
||
const behavior: PluginButtonBehavior = {
|
||
kind: "menu",
|
||
items: [
|
||
{
|
||
kind: "item",
|
||
id: "refresh",
|
||
title: "Refresh review",
|
||
icon: "RefreshCw",
|
||
behavior: { kind: "action", onPress: refreshReview },
|
||
},
|
||
{ kind: "separator", id: "details-divider" },
|
||
{
|
||
kind: "item",
|
||
id: "details",
|
||
title: "Review details",
|
||
behavior: { kind: "popover", Content: ReviewDetails },
|
||
},
|
||
],
|
||
};
|
||
```
|
||
|
||
条目需要 `kind: "item"`、`id`、`title` 和 `behavior`。它可选的 `icon`、`visible` 和 `disabled`
|
||
遵循按钮的规则。分隔线只包含 `kind: "separator"` 和 `id`。在过滤掉隐藏的条目之后,Paseo 会移除开头、
|
||
结尾和连续的分隔线。
|
||
|
||
条目可以使用全部三种行为。嵌套菜单在宽布局中以弹出子菜单打开,在紧凑布局中则以同一个底部面板内带返回
|
||
导航的页面打开。自定义内容页面在选中时打开,不会在悬停时打开。选择一个操作会关闭菜单;打开另一个页面
|
||
则让菜单保持打开。
|
||
|
||
### 自定义图标和弹出框内容 {#custom-icons-and-popover-content}
|
||
|
||
`PluginButtonIconProps` 包含 `theme`、`host`、`layout`、`size`、`color` 以及目标上下文。请在给定的
|
||
尺寸内渲染一个 React Native 图标或指示器。图标槽的边界和所有指针交互都由 Paseo 负责。图标组件可以使用
|
||
插件 hook。
|
||
|
||
`PluginButtonContentProps` 包含 `theme`、`host`、`layout`、目标上下文以及 `close()`。只渲染主体;锚定、
|
||
滚动、内边距和底部面板的呈现由 Paseo 负责。内容可以使用 `usePaseo`、`useRpc`、`useWorkspace`、
|
||
`useAgent` 以及该安装的 React Query 缓存。
|
||
|
||
目标上下文是以下之一:
|
||
|
||
```ts
|
||
{ context: "workspace", workspaceId: string } // Header button
|
||
{ context: "agent", workspaceId: string, agentId: string } // Composer pill
|
||
```
|
||
|
||
### 更新与生命周期 {#updates-and-lifecycle}
|
||
|
||
每个注册都属于一个插件安装、一个位置、一个工作区,以及(对于胶囊)一个 Agent。`id` 在该目标内是插件
|
||
内部的,格式与菜单 ID 相同。同一个 ID 可以用于不同的目标或位置。在同一目标中重复注册会抛出错误。
|
||
|
||
`update(patch: Partial<PluginButton>)` 原地修改描述符,保留其标识和顺序。修改 `behavior` 时,请提供
|
||
完整的新 behavior 对象。无效的更新会抛出错误,并且不会改变现有按钮。订阅你自己的模型或客户端 API,并
|
||
调用 `update` 来发布响应式的变化;修改最初的描述符不会更新 UI。
|
||
|
||
隐藏或禁用按钮会关闭它的浮层。更新它的 behavior 也会关闭浮层。隐藏会保留注册,因此再次显示时会恢复它
|
||
原来的位置。它不会取消已经在进行中的操作。
|
||
|
||
`remove()` 是幂等的。移除之后的更新不起作用。当插件安装被销毁时,Paseo 会移除尚存的按钮(参见[主机与生命周期](#hosts-and-lifecycle))。
|
||
请从客户端入口返回清理函数,用来释放你的订阅、定时器和其他资源。
|
||
|
||
## 使用 Paseo SDK {#use-the-paseo-sdk}
|
||
|
||
在界面中执行普通的 Paseo 操作请使用 `usePaseo()`。它借用所选主机现有的连接;不要再创建一个客户端。
|
||
|
||
`usePaseo()` 是你的插件唯一的 Paseo 客户端,在每个界面中都与 setup 收到的 `client.paseo` 相同。
|
||
请在清理函数中释放你订阅的内容;当插件被停用、重新加载或移除,或者它的主机消失时,Paseo 会销毁该客户端,
|
||
并结束所有仍然打开的订阅。
|
||
|
||
```tsx
|
||
import { usePaseo } from "@getpaseo/plugin/client";
|
||
import { useEffect, useState } from "react";
|
||
import { Text } from "react-native";
|
||
|
||
function ProjectChanges() {
|
||
const paseo = usePaseo();
|
||
const [changes, setChanges] = useState(0);
|
||
useEffect(() => {
|
||
const observation = paseo.observeEvents(["project.update"]);
|
||
observation.subscribe({ snapshot() {}, update: () => setChanges((count) => count + 1) });
|
||
return () => void observation.release();
|
||
}, [paseo]);
|
||
return <Text>{changes} project changes</Text>;
|
||
}
|
||
```
|
||
|
||
```tsx
|
||
import { type PluginSurfaceProps, usePaseo } from "@getpaseo/plugin/client";
|
||
import { Pressable, Text } from "react-native";
|
||
|
||
function PullRequestAction({ theme }: PluginSurfaceProps) {
|
||
const paseo = usePaseo();
|
||
|
||
async function createReviewWorkspace() {
|
||
const workspace = await paseo.workspaces.create({
|
||
title: "Review PR 42",
|
||
source: {
|
||
kind: "worktree",
|
||
cwd: "/absolute/path/to/repository",
|
||
action: "checkout",
|
||
checkoutSource: { kind: "change_request", forge: "github", number: 42 },
|
||
},
|
||
});
|
||
await workspace.agents.create({
|
||
config: { provider: "codex/gpt-5.5" },
|
||
prompt: "Review PR #42.",
|
||
});
|
||
}
|
||
|
||
return (
|
||
<Pressable accessibilityRole="button" onPress={() => void createReviewWorkspace()}>
|
||
<Text style={{ color: theme.colors.foreground }}>Create review workspace</Text>
|
||
</Pressable>
|
||
);
|
||
}
|
||
```
|
||
|
||
返回的 API 涵盖项目、工作区、Agent、终端、Provider 和 daemon 配置。其方法请参阅 [SDK API 参考](/docs/sdk/reference)。这里刻意没有连接生命周期方法,因为连接由 Paseo 负责。
|
||
|
||
如果要运行用户不需要看到的辅助任务,例如分类或摘要,请在已有工作区中把它创建为[后台 Agent](/docs/sdk/agents#run-a-helper-as-a-background-agent)。
|
||
|
||
### 发现主机并指定另一台主机 {#discover-hosts-and-target-another-host}
|
||
|
||
使用 `useHosts()` 显示已配置的主机,并在操作回调中使用 `getPaseoClient(serverId)` 对其中一台主机
|
||
执行 SDK 操作:
|
||
|
||
```tsx
|
||
import { getPaseoClient, useHosts, type PluginSurfaceProps } from "@getpaseo/plugin/client";
|
||
import { useMemo, useState, type ReactElement } from "react";
|
||
import { Pressable, Text, View } from "react-native";
|
||
|
||
export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactElement {
|
||
const hosts = useHosts();
|
||
const textStyle = useMemo(() => ({ color: theme.colors.foreground }), [theme]);
|
||
const [result, setResult] = useState("");
|
||
|
||
async function listAgents(serverId: string): Promise<void> {
|
||
try {
|
||
const { entries } = await getPaseoClient(serverId).agents.list();
|
||
setResult(`${entries.length} agents`);
|
||
} catch (error) {
|
||
setResult(error instanceof Error ? error.message : String(error));
|
||
}
|
||
}
|
||
|
||
const rows = hosts.map((host) => ({
|
||
host,
|
||
onPress() {
|
||
void listAgents(host.serverId);
|
||
},
|
||
}));
|
||
|
||
return (
|
||
<View>
|
||
{rows.map(({ host, onPress }) => (
|
||
<Pressable key={host.serverId} accessibilityRole="button" onPress={onPress}>
|
||
<Text style={textStyle}>
|
||
{host.label}: {host.status}
|
||
</Text>
|
||
</Pressable>
|
||
))}
|
||
<Text style={textStyle}>{result}</Text>
|
||
</View>
|
||
);
|
||
}
|
||
```
|
||
|
||
`useHosts(): readonly PluginHostSummary[]` 包括离线的主机,并在主机、标签或状态变化时更新。
|
||
|
||
| 摘要字段 | 类型或取值 | 含义 |
|
||
| ---------- | ------------------------------------------------------------ | --------------------------------------------- |
|
||
| `serverId` | `string` | 传给 `getPaseoClient` 的 ID。 |
|
||
| `label` | `string` | 主机的显示名称。 |
|
||
| `status` | `"idle"`、`"connecting"`、`"online"`、`"offline"`、`"error"` | 当前应用的连接状态。SDK 调用需要 `"online"`。 |
|
||
|
||
`getPaseoClient(serverId: string): PaseoApi` 借用该主机已认证的应用连接。请在客户端入口代码或回调中
|
||
调用它;它不会打开 socket,也不要求目标 daemon 上安装了该插件。在执行操作时再获取 API,以便使用当前的
|
||
连接。
|
||
| 事件或条件 | 结果与调用方的处理 |
|
||
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||
| 未知的主机 ID | 抛出 `Unknown Paseo host: <id>`;永远不会转到另一台主机。 |
|
||
| 主机不在线 | 抛出 `Paseo host is disconnected: <id>`,通过保留的 API 发起的调用也是如此。请在主机上线后重试。 |
|
||
| 同一连接重新连接 | 保留的 API 在重新连接后仍然可用;观察会自动恢复。 |
|
||
| 连接设置变化,或应用切换了连接(包括自动故障转移) | 旧的 API 被释放。请再次调用 `getPaseoClient(serverId)` 并重新创建订阅。 |
|
||
| 主机被移除 | 它的 API 被释放;被移除的 ID 变为未知。 |
|
||
| `client.dispose()` | 释放该 API 及其观察。之后再调用获取函数会返回一个基于应用连接的新 API。再次销毁旧的 API 不会影响新 API 的使用。 |
|
||
| 发起调用的插件被卸载 | 它借用的所有 API 和观察都会被释放,包括指向其他主机的那些。保留的句柄不能比该安装存活得更久。 |
|
||
| 界面的主机选择发生变化 | `usePaseo()` 跟随所选的主机。显式获取的 API 保持其原来的目标。 |
|
||
|
||
你也可以通过普通的 SDK API 释放单个订阅。
|
||
|
||
插件是受信任的应用代码;跨主机访问是有意设计的。摘要中不包含连接 URL 或凭据,借用的 API 也不提供连接
|
||
生命周期控制。请参阅[主机 Agent 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts)。
|
||
|
||
## 添加插件特有的后端行为 {#add-plugin-specific-backend-behavior}
|
||
|
||
插件 RPC 只用于不属于普通 Paseo 操作的工作:读取厂商 API、访问 daemon 本地资源,或者让凭据不经过客户端。
|
||
|
||
用 Zod 定义一个契约,在子进程中处理它,并从界面中调用它:
|
||
|
||
`shared/greeting.ts`:
|
||
|
||
```ts
|
||
import { defineRpc } from "@getpaseo/plugin";
|
||
import { z } from "zod";
|
||
|
||
export const greeting = defineRpc({
|
||
name: "greeting.create",
|
||
input: z.object({ name: z.string() }),
|
||
output: z.object({ message: z.string() }),
|
||
});
|
||
```
|
||
|
||
`client/greeting.tsx`:
|
||
|
||
```tsx
|
||
import { useRpc } from "@getpaseo/plugin/client";
|
||
import { greeting } from "../shared/greeting";
|
||
|
||
export function GreetingButton() {
|
||
const createGreeting = useRpc(greeting);
|
||
// Call createGreeting({ name: "Ada" }) from an event or query.
|
||
return null;
|
||
}
|
||
```
|
||
|
||
`server/greeting.ts`:
|
||
|
||
```ts
|
||
import type { RpcInput } from "@getpaseo/plugin";
|
||
import { greeting } from "../shared/greeting";
|
||
|
||
export function createGreeting({ name }: RpcInput<typeof greeting>) {
|
||
return { message: `Hello, ${name}` };
|
||
}
|
||
```
|
||
|
||
`index.client.tsx`:
|
||
|
||
```ts
|
||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||
import { GreetingButton } from "./client/greeting";
|
||
|
||
export default function contribute(client: PluginClientContext) {
|
||
client.addScreen({ id: "main", title: "Greeting", Component: GreetingButton });
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
`index.server.ts`:
|
||
|
||
```ts
|
||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||
import { createGreeting } from "./server/greeting";
|
||
import { greeting } from "./shared/greeting";
|
||
|
||
export default function contribute(server: PluginServerContext) {
|
||
server.handle(greeting, createGreeting);
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
输入和输出在两端都会校验。RPC 名称以小写字母开头,由小写字母、数字、点、连字符或下划线组成。`useRpc()` 返回一个带类型的异步函数。请求状态、缓存和变更请使用 TanStack Query。
|
||
|
||
后端处理函数以 `{ paseo }` 的形式收到同样的 `PaseoApi`。它们的连接属于子进程,并在插件停止时关闭。在插件代码订阅之前,它不会订阅时间线或目录事件。清理和时间线替换请遵循 [SDK 事件契约](/docs/sdk/events)。后端代码可以使用 Node API 以及安装在插件目录中的依赖。
|
||
|
||
## 从 daemon 推送事件 {#subscriptions}
|
||
|
||
订阅会在一个客户端监听期间运行 daemon 处理函数,并向该客户端发送带类型的事件。监听文件、追踪日志和实时媒体使用订阅;只需要一次应答时使用插件 RPC。
|
||
|
||
在 `shared/` 中定义契约:
|
||
|
||
```ts
|
||
// shared/diff.ts
|
||
import { defineSubscription } from "@getpaseo/plugin";
|
||
import { z } from "zod";
|
||
|
||
export const diffWatch = defineSubscription({
|
||
name: "diff.watch",
|
||
input: z.object({ cwd: z.string(), from: z.string() }),
|
||
event: z.object({ type: z.literal("changes"), files: z.array(z.string()) }),
|
||
});
|
||
```
|
||
|
||
在 `index.server.ts` 中处理它。处理函数除了 `paseo`,还会收到 `push` 和一个中止 `signal`:
|
||
|
||
```ts
|
||
server.handle(diffWatch, ({ cwd, from }, { push, signal }) => {
|
||
const watcher = watchChanges(cwd, from, (files) => void push({ type: "changes", files }));
|
||
signal.addEventListener("abort", () => watcher.close(), { once: true });
|
||
});
|
||
```
|
||
|
||
在界面中用 `useSubscription(contract, input, onEvent)` 订阅:
|
||
|
||
```tsx
|
||
const [files, setFiles] = useState<string[]>([]);
|
||
const subscription = useSubscription(diffWatch, { cwd, from: "HEAD" }, (event) => {
|
||
setFiles(event.files);
|
||
});
|
||
if (subscription.status === "error") return <Text>{subscription.error.message}</Text>;
|
||
```
|
||
|
||
在 React 之外,`client.subscribe` 接受同样的参数和一个选项对象,并返回一个移除函数:
|
||
|
||
```ts
|
||
const stop = client.subscribe(diffWatch, { cwd, from: "HEAD" }, onChanges, { signal });
|
||
await stop.ready; // the daemon accepted the subscription
|
||
stop(); // or await stop.release()
|
||
```
|
||
|
||
RPC 和订阅共用一个名称空间;同一个名称同时注册为 RPC 和订阅会导致插件加载失败。
|
||
|
||
### 处理函数的生命周期 {#handler-lifetime}
|
||
|
||
| 处理函数…… | 结果 |
|
||
| ------------------------ | -------------------------------------------- |
|
||
| 同步抛出错误,或输入无效 | 订阅调用失败并返回该错误,不会发送任何事件。 |
|
||
| 不返回任何值 | 订阅保持打开,直到客户端释放它。 |
|
||
| 返回一个兑现的 Promise | 订阅关闭。 |
|
||
| 返回一个拒绝的 Promise | 订阅关闭并带上该错误。 |
|
||
|
||
当客户端释放订阅、卸载 hook、更改输入、断开连接,或者插件停止时,`signal` 会中止。中止之后的推送会被丢弃。`push` 会按契约校验事件,并在事件离开插件进程后兑现;在循环中 await 它可以控制生产者的速度。
|
||
|
||
每个订阅者运行各自的处理函数。Paseo 不会在多个客户端之间共享同一个处理函数,也不会重放错过的事件:重新连接或重新加载插件之后,已挂载的界面会重新订阅,处理函数从头开始运行。多个订阅者需要同一个昂贵的监听器时,请在插件内部共享它。
|
||
|
||
`useSubscription` 返回一个以 `status` 区分的联合类型:
|
||
|
||
| 状态 | 含义 |
|
||
| ------------ | -------------------------------------------------------- |
|
||
| `connecting` | 已发送,daemon 尚未接受。 |
|
||
| `live` | 处理函数正在运行,事件通过 `onEvent` 送达。 |
|
||
| `closed` | 处理函数已结束。 |
|
||
| `error` | 订阅失败、处理函数失败、事件不符合契约,或者插件已停止。 |
|
||
|
||
`input` 按浅比较发生变化时,hook 会重新订阅。`onEvent` 始终是最近一次渲染的回调,所以内联函数不会导致重新订阅。`client.subscribe` 接受 `{ signal, onClosed }`;`onClosed` 会收到 `{ error? }`。
|
||
|
||
### 字节 {#bytes}
|
||
|
||
顶层的 `Uint8Array` 以二进制形式传输,不经过 JSON 或 base64,其他值都是 JSON。这条规则适用于订阅事件、RPC 输出和 RPC 输入。Node 的 `Buffer` 也是 `Uint8Array`。
|
||
|
||
```ts
|
||
export const thumbnail = defineRpc({
|
||
name: "thumbnail.render",
|
||
input: z.object({ path: z.string(), width: z.number() }),
|
||
output: z.instanceof(Uint8Array),
|
||
});
|
||
|
||
// index.server.ts
|
||
server.handle(thumbnail, ({ path, width }) => renderPng(path, width));
|
||
|
||
// client
|
||
const renderThumbnail = useRpc(thumbnail);
|
||
const png = await renderThumbnail({ path, width }); // Uint8Array
|
||
```
|
||
|
||
用 `z.instanceof(Uint8Array)` 声明字节。事件可以在每次推送时选择字节或 JSON:声明一个联合类型,并用 `event instanceof Uint8Array` 分支。嵌套在对象中的 `Uint8Array` 会在发送端校验失败,错误信息会说明这条规则;把字节作为单独的事件或 RPC 值发送,元数据作为另一个 JSON 事件发送。
|
||
|
||
一个事件或 RPC 值最多 100 MiB(减去 65 KiB 的帧头)。大载荷请按几百 KiB 分块推送;每次 `push` 都会等待上一次离开插件进程。手机上传的上限更低:一次发送超过 16 MiB 时 Android 会关闭连接,所以较大的上传请拆成多个 RPC。
|
||
|
||
### 在一个订阅中传输多条流 {#several-streams-on-one-subscription}
|
||
|
||
在每个载荷前加一个头字节即可复用多条流。把布局放在 `shared/` 中,让处理函数和界面调用有名字的函数:
|
||
|
||
```ts
|
||
// shared/media-frames.ts
|
||
export const MediaOpcode = { Video: 0x01, Audio: 0x02 } as const;
|
||
|
||
export function encodeMediaFrame(opcode: number, payload: Uint8Array): Uint8Array {
|
||
const bytes = new Uint8Array(1 + payload.byteLength);
|
||
bytes[0] = opcode;
|
||
bytes.set(payload, 1);
|
||
return bytes;
|
||
}
|
||
|
||
export function decodeMediaFrame(bytes: Uint8Array) {
|
||
return { opcode: bytes[0], payload: bytes.subarray(1) }; // a view, not a copy
|
||
}
|
||
|
||
export const mediaStream = defineSubscription({
|
||
name: "media.stream",
|
||
input: z.object({ source: z.string() }),
|
||
event: z.instanceof(Uint8Array),
|
||
});
|
||
```
|
||
|
||
```ts
|
||
useSubscription(mediaStream, { source }, (bytes) => {
|
||
const frame = decodeMediaFrame(bytes);
|
||
if (frame.opcode === MediaOpcode.Audio) audio.enqueue(frame.payload);
|
||
else video.draw(frame.payload);
|
||
});
|
||
```
|
||
|
||
同一个订阅的事件按推送顺序到达,JSON 和二进制都一样。`plugin-examples/media-stream` 用这种方式传输视频和音频,并通过一个输入为 `Uint8Array` 的 RPC 把一帧上传回去;`plugin-examples/diff-watch` 监听工作树。
|
||
|
||
### 旧版主机 {#older-hosts}
|
||
|
||
订阅和二进制 RPC 值需要主机声明支持。连接旧版主机时,`useSubscription` 会报告 `error` 并提示更新主机,`client.subscribe` 的 `ready` 会以同样的信息拒绝,带有 `Uint8Array` 输入或输出的 RPC 会失败。请把插件的 `requirements.paseo` 提高到包含订阅功能的版本。
|
||
|
||
## 调试后端输出 {#debug-backend-output}
|
||
|
||
后端贡献可以用普通的 Node 日志写入 stdout 和 stderr:
|
||
|
||
```ts
|
||
console.log("Refreshing issues");
|
||
console.error("Issue refresh failed", error);
|
||
```
|
||
|
||
当插件开始加载、就绪、开始停止以及已经停止时,Paseo 会添加 `[paseo]` 条目。它把编译和加载失败记录为
|
||
stderr 条目,包括在插件子进程启动之前发生的失败。Paseo 还会捕获初始化、RPC 处理函数、清理以及进程失败
|
||
期间产生的输出。协议通信使用单独的通道,因此 `console.log()` 不会破坏插件 RPC。
|
||
|
||
打开该插件的 **设置 → 插件 → Logs**,或者通过 daemon CLI 查看同一份最近的日志尾部:
|
||
|
||
```bash
|
||
paseo plugin logs my-plugin
|
||
paseo plugin logs my-plugin --json
|
||
paseo --host <url> plugin logs my-plugin
|
||
```
|
||
|
||
该命令返回的是一份快照,而不会持续跟随实时输出。要查看更新的条目,请刷新设置视图或再次运行该命令。每个
|
||
条目都包含时间戳、stdout 或 stderr 流、序号和消息。
|
||
|
||
Paseo 在内存中为每个插件最多保留 500 个条目和 256 KiB。单行最多 16 KiB。重新加载、停用、编译失败、
|
||
初始化失败和进程失败都会保留日志尾部。移除插件会清空它,daemon 重启则会开始新的日志尾部。结构化的副本
|
||
也会写入位于 `$PASEO_HOME/daemon.log` 的 daemon 日志。
|
||
|
||
只有 daemon 端的输出会被捕获。客户端界面的日志留在应用运行时中。不要记录凭据、访问令牌或其他机密:已连接
|
||
的用户可以读取保留的日志尾部,而 daemon 日志会把它持久保存。
|
||
|
||
## 添加 Composer 附件来源 {#add-a-composer-attachment-source}
|
||
|
||
附件来源搜索外部资源,并为 Agent 提示词返回一份稳定的文本快照。请把凭据和厂商调用放在后端处理函数中。
|
||
|
||
`shared/issues.ts`:
|
||
|
||
```ts
|
||
import { defineAttachmentSource, defineRpc } from "@getpaseo/plugin";
|
||
import { z } from "zod";
|
||
|
||
export const searchIssues = defineRpc({
|
||
name: "issues.search",
|
||
input: z.object({ query: z.string() }),
|
||
output: z.object({
|
||
items: z.array(
|
||
z.object({
|
||
id: z.string(),
|
||
identifier: z.string(),
|
||
title: z.string(),
|
||
subtitle: z.string().optional(),
|
||
url: z.string().url(),
|
||
text: z.string(),
|
||
resourceType: z.string(),
|
||
}),
|
||
),
|
||
}),
|
||
});
|
||
|
||
export const issues = defineAttachmentSource({
|
||
id: "issues",
|
||
title: "Acme issue",
|
||
icon: "CircleDot",
|
||
pickerTitle: "Attach Acme issue",
|
||
searchPlaceholder: "Search by identifier or title",
|
||
search: searchIssues,
|
||
});
|
||
```
|
||
|
||
`server/issues.ts`:
|
||
|
||
```ts
|
||
import type { RpcInput } from "@getpaseo/plugin";
|
||
import { searchIssues } from "../shared/issues";
|
||
|
||
export function search({ query }: RpcInput<typeof searchIssues>) {
|
||
return searchAcmeIssues(query);
|
||
}
|
||
```
|
||
|
||
`index.client.tsx`:
|
||
|
||
```ts
|
||
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
||
import { issues } from "./shared/issues";
|
||
|
||
export default function contribute(client: PluginClientContext) {
|
||
client.addAttachmentSource(issues);
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
`index.server.ts`:
|
||
|
||
```ts
|
||
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
||
import { search } from "./server/issues";
|
||
import { searchIssues } from "./shared/issues";
|
||
|
||
export default function contribute(server: PluginServerContext) {
|
||
server.handle(searchIssues, search);
|
||
return () => {};
|
||
}
|
||
```
|
||
|
||
Composer 菜单、搜索选择器、选中后的胶囊、草稿状态和提交都由 Paseo 负责。`text` 的值就是发送给 Agent 的完整快照。`icon` 是 Lucide 名称或 `{ svg }`;参见[图标](#icons)。
|
||
|
||
## 主机与生命周期 {#hosts-and-lifecycle}
|
||
|
||
插件按 daemon 安装。当同一个贡献存在于多台已连接的主机上时,Paseo 只显示一个侧边栏项,并添加一个主机选择器。所选的主机提供插件包、Paseo API、RPC 传输以及查询缓存。当所选主机离线时,调用永远不会转到另一台主机。
|
||
|
||
附件来源仍然限定在各个 Composer 所在的主机上。
|
||
|
||
连接中断时,客户端插件的注册、已挂载界面、本地状态和查询缓存会保留。通过原有的 `DaemonClient` 对象重新连接时,未变化的安装会保留,其设置会刷新。请使用 SDK 观察快照刷新其他 daemon 状态;未变化的安装不会再次运行初始化函数。调用 daemon 仍然依赖连接。
|
||
|
||
切换连接(例如从直连切换到中继)目前会替换 `DaemonClient` 对象并重新安装客户端插件。重新加载、插件包或版本要求变化、停用、移除插件和移除主机也会销毁受影响的安装,并清除其查询状态。如果替换安装求值失败,它的贡献将不可用。
|
||
|
||
工作区面板和 Command Center 条目仍然限定在当前活动的主机和确切的缓存上下文中。已经恢复的面板
|
||
标签页会保持“不可用”状态,直到与之匹配的贡献重新出现或用户关闭它。面板的渲染失败会被限制在插件的错误
|
||
边界内。
|
||
|
||
重新加载会排在 daemon 的其他插件工作之后,例如正在运行的安装或准备命令;当插件再次运行或失败时,daemon
|
||
才会给出响应。插件的状态不能当作重新加载的结果来读取:在重新加载排队期间,`plugin ls`、设置和日志
|
||
显示的是插件之前的状态;而在 daemon 停止并启动插件期间,读取状态会得到 `failed`,因为在启动完成之前插件
|
||
不在运行时目录中。
|
||
|
||
## 插件来源 {#plugin-sources}
|
||
|
||
把以下来源标识之一粘贴到 **设置 → 插件** 中,或者传给 `paseo plugin install`。
|
||
`paseo plugin add <source>` 和 `paseo plugin install <source>` 互为别名。CLI 从你当前的工作目录解析
|
||
相对目录路径,并把 `~` 展开为你本地的主目录,同时保留任何 `:plugin/path` 后缀。使用 `--host` 时,请使用
|
||
daemon 主机上存在的绝对路径;插件文件在那台主机上读取,CLI 永远不会上传它们。在设置中输入的路径相对于
|
||
daemon 的工作目录解析,`~` 会展开为 daemon 的主目录。
|
||
|
||
| 来源 | 接受的形式 | 示例 |
|
||
| ------------------ | ----------------------------------------------------------- | --------------------------------------------- |
|
||
| 插件注册表 | `owner/slug` 或 `host/owner/slug` | `acme/review` |
|
||
| 主机目录 | daemon 主机上的绝对或相对路径 | `/srv/paseo/plugins/review` |
|
||
| GitHub 仓库 | `git:owner/repository` 或 `github:owner/repository` | `github:acme/paseo-review` |
|
||
| Git 仓库 | `git:<URL 或 SCP 来源>`;对于 URL 和 SCP 来源,前缀可以省略 | `git:https://git.example.com/acme/review.git` |
|
||
| npm 包 | `npm:<name>[@<版本、标签或范围>]`;`npm:` 可以省略 | `npm:@acme/paseo-review@^1.2.0` |
|
||
| 来源根目录下的插件 | 在目录、Git 或 npm 来源后追加 `:relative/plugin/path` | `github:acme/monorepo:plugins/review` |
|
||
|
||
Git URL 使用 `https://`、`http://`、`ssh://`、`git://` 或 `file://`。SCP 来源使用 `user@host:path`。
|
||
`file://` 选择的是 Git 获取方式,而不是目录安装。
|
||
|
||
npm 名称是小写的无作用域 `name` 或有作用域的 `@scope/name`。每个部分以字母或数字开头,之后由字母、数字、
|
||
`.`、`_` 或 `-` 组成。在包名之后,可选的 `@` 引出一个确切版本、发布标签或 npm semver 范围。省略它表示
|
||
`latest`。包含空格或比较运算符的 shell 参数请加引号。npm 别名、tarball URL 和 npm 的 `file:` 规格都不是
|
||
插件来源标识;对于这些位置,请使用目录或 Git 来源。如果一个无作用域的包同时带有选择器和子目录,请使用
|
||
`npm:` 前缀(`npm:review@1.2.0:nested`);没有前缀时,`user@host:path` 是一个 SCP Git 来源。所选的版本、
|
||
标签或范围由包注册表校验。
|
||
|
||
Paseo 按以下顺序解析标识:
|
||
|
||
1. 裸的 `owner/slug` 是注册表 ID;本地目录来源是 `.` 或 `..`,或者以 `./`、`../`、`.\`、`..\`、`/`、`~`、Windows 盘符(`C:\` 或 `C:/`)或 UNC(`\\server\share`)前缀开头。
|
||
2. 否则,在解析子目录后缀之前,先识别 `npm:`、`github:` 或 `git:`。
|
||
`git://` 是 Git URL 的 scheme。显式的前缀会选择对应类型的获取方式。
|
||
3. 只有当末尾的 `:relative/plugin/path` 后缀不包含空的、`.` 或 `..` 段时,才把它识别为后缀。单独的
|
||
`.` 选择来源根目录。`/` 和 `\` 都可以分隔后缀中的段;跨主机时请使用 `/`。URL 端口和 SCP 来源中的
|
||
分隔符仍属于来源本身。不满足这些规则的后缀仍属于标识的一部分。
|
||
4. 裸的 `owner/slug` 通过默认插件注册表解析,`host/owner/slug` 通过该注册表主机解析。Git URL 和 SCP
|
||
来源按 Git 解析。`github:` 要求 `owner/repository` 简写;`git:` 既接受简写,也接受 URL 和 SCP 来源。
|
||
5. 剩下的 npm 包名及其可选的选择器通过主机的注册表解析。其他任何内容都会被拒绝。
|
||
|
||
默认注册表是 `https://plugins.paseo.sh`。浏览[已发布的插件](https://paseo.sh/plugins),并用
|
||
`paseo plugin add owner/slug` 安装。注册表记录决定修订版本和插件路径。显式的 GitHub 简写请使用
|
||
`git:owner/repository`,或者使用完整的 Git URL。设置 `PASEO_PLUGIN_REGISTRY` 可以使用自托管的默认 URL,
|
||
包括路径前缀。私有注册表的凭据在 daemon 配置中使用
|
||
`pluginRegistries: { "host": { "authorization": "Bearer token" } }`。修改这些启动设置后请重启 daemon。
|
||
凭据只会发送给该注册表,永远不会发送给制品主机或重定向目标。当默认值变化时,已安装的插件保留它们记录的
|
||
来源和注册表 URL。
|
||
|
||
目录查找在 daemon 主机上进行。应用使用 `paseo-plugin.json` 中的 ID;CLI 接受 `--id <runtime-id>` 来覆盖它。
|
||
已存在的安装 ID 会被拒绝,并且不会改变它的启用状态或文件。
|
||
|
||
```bash
|
||
paseo plugin install /srv/paseo/plugins/review
|
||
paseo plugin install github:acme/paseo-review
|
||
paseo plugin install git:https://git.example.com:8443/acme/monorepo.git:plugins/review --ref main
|
||
paseo plugin install git@git.example.com:acme/review.git
|
||
paseo plugin install file:///srv/repos/monorepo:plugins/review
|
||
paseo plugin install npm:paseo-review@1.2.0
|
||
paseo plugin install npm:@acme/paseo-review@next
|
||
paseo plugin install 'npm:@acme/paseo-review@>=1.2.0 <2.0.0' --id review-staging
|
||
paseo plugin install npm:@acme/plugins@^1.2.0:plugins/review
|
||
```
|
||
|
||
`--ref` 只适用于 Git,接受用于这次安装的分支、标签或提交。没有它时,Paseo 安装远程的默认 HEAD。安装时的
|
||
选择器不会约束之后的更新。旧版的 `--path relative/plugin/path` 选项等同于子目录后缀,对 npm 也是如此。
|
||
|
||
### npm 安装与发布 {#npm-installation-and-publishing}
|
||
|
||
在 **daemon 主机**上安装带有 npm 的 Node.js,并让 `npm` 出现在 daemon 的 `PATH` 中。daemon 使用该主机的
|
||
npm 用户/全局配置和环境来选择注册表和进行认证,包括特定作用域的注册表。客户端不会下载包,也不会运行 npm。
|
||
加载、启用和重新加载已安装的插件都不需要 npm。
|
||
|
||
daemon 在一个隔离的目录中安装每个候选包及其生产依赖。激活时,它会保留完整的依赖树和 `package-lock.json`。
|
||
已安装的包和 lockfile 提供它的当前版本和制品完整性。版本、标签或范围只为这一次安装选择内容。
|
||
|
||
已安装的 lockfile 记录了这次安装解析出的结果。提交带有运行时依赖的 npm 插件的作者还必须附带
|
||
[发布锁](/docs/plugins/publishing#2-lock-installed-dependencies)。关于包的内容、准备和提交,请参阅
|
||
[发布插件](/docs/plugins/publishing)。
|
||
|
||
下载、依赖安装、manifest 检查、准备命令、编译或激活中的任何一步失败,都会丢弃候选包。其他已安装的插件继续
|
||
运行。移除 npm 插件会删除它的托管文件;移除目录插件则会保留你的源目录。
|
||
|
||
## CLI 参考 {#cli-reference}
|
||
|
||
```bash
|
||
paseo plugin init /absolute/path/to/plugin
|
||
paseo plugin install /absolute/path/to/plugin
|
||
paseo plugin install /absolute/path/to/plugin --id another-runtime-id
|
||
paseo plugin add owner/slug
|
||
paseo plugin add git:owner/repository
|
||
paseo plugin add https://git.example.com/owner/repository.git --ref main
|
||
paseo plugin add git:owner/monorepo:plugins/review
|
||
paseo plugin ls [id]
|
||
paseo plugin update <id>
|
||
paseo plugin update --all --check
|
||
paseo plugin update --all --yes
|
||
paseo plugin update my-plugin --version 1.2.0
|
||
paseo plugin update my-plugin --ref v2
|
||
paseo plugin reload my-plugin
|
||
paseo plugin logs my-plugin
|
||
paseo plugin disable my-plugin
|
||
paseo plugin enable my-plugin
|
||
paseo plugin remove my-plugin
|
||
```
|
||
|
||
`ls` 和设置页面显示来源标识和当前安装的修订版本,而不会联系远程。标识包含所选的子目录,不包含安装时的
|
||
选择器。
|
||
|
||
`update <id>` 检查更新,显示当前和拟更新的修订版本以及可用的审查链接,并请求批准。拒绝则保持已安装的内容
|
||
不变。
|
||
|
||
- npm 检查包的 `latest` 版本。它只会提供更新的版本;比 latest 更新的已安装版本会保持安装状态。解析由主机的
|
||
npm 注册表/认证配置决定。
|
||
- Git 检查远程当前的默认 HEAD,不论安装时选择的是哪个分支、标签或提交。
|
||
- 目录插件会被跳过;请编辑目录并使用 `reload`。
|
||
|
||
`--check` 只做预览,即使提供了显式目标或 `--yes` 也是如此。`--yes` 跳过询问。`--all` 检查每个已配置的插件,
|
||
并分别报告结果;一个失败不会阻止其他插件。这两个标志都不允许普通的 npm 降级。
|
||
|
||
`--version <version|tag|range>` 或 `--ref <branch|tag|commit>` 为某个匹配的插件选择更新内容,并在不再询问
|
||
的情况下应用它。显式的 npm 版本可以更旧。下一次普通更新会再次检查 latest。显式目标不能与 `--all` 一起使用。
|
||
JSON 模式和非交互的普通更新需要 `--yes`。
|
||
|
||
批准后会获取被审查的那个确切提交或 npm 制品。如果它不可用,或者在审查期间已安装的插件发生了变化,更新会失败
|
||
并要求你重新检查。准备或激活失败会保留之前的安装。目前还不支持在应用中手动审查更新。
|
||
|
||
当目标不是 CLI 的默认 daemon 时,请把 `--host <url>` 放在管理命令之前。`remove` 永远不会删除目录来源;对于
|
||
Git 和 npm 来源,它会删除托管文件。安装时的 `--id` 就是运行时 ID,允许同一个目录或仓库被安装多次。
|
||
|
||
> **信任你添加的每一个插件。** `paseo plugin add` 和 `paseo plugin install` 的意思是“我信任这个代码库”。服务端代码和准备命令在 daemon 主机上以 daemon 用户的权限在沙箱外运行;客户端贡献在 Paseo 内部运行。依赖和未来的更新也属于这个决定的一部分。使用全局 `--host` 选项时,命令在远程 daemon 主机上运行。
|
||
|
||
大多数插件应省略 `build`。只有当暂存的检出必须安装 Paseo 未提供的依赖、生成源代码或资源,或者执行其他必需的
|
||
准备步骤时,才使用它:
|
||
|
||
```json
|
||
{
|
||
"id": "review",
|
||
"requirements": { "paseo": ">=0.8.0" },
|
||
"build": [["npm", "ci", "--omit=dev"]]
|
||
}
|
||
```
|
||
|
||
`build` 是一个由非空 argv 数组组成的列表。在解析出确切的提交和 manifest 之后,Paseo 从暂存的插件目录中直接
|
||
运行每个可执行文件,不经过 shell。它永远不会从 lockfile 推断包管理器或命令。安装和更新都会在校验、编译、激活
|
||
或替换之前运行 `build`。失败的命令会报告其输出、丢弃候选包,并保持已安装/正在运行的版本不变。daemon 日志会
|
||
记录每条命令及其输出;使用全局 `--host` 选项时,命令在该 daemon 主机上执行。
|
||
|
||
在安装或重新加载之前运行 `npm run typecheck`。请用 CLI 或设置页面管理插件来源条目;安装语法请参阅
|
||
[插件来源](#plugin-sources)。
|
||
|
||
daemon 级别的 **启用插件** 开关位于 **设置 → 插件** 下。只有当这个开关和插件自身的启用状态都打开时,已配置的插件才会运行,否则它会保持 `disabled`。
|
||
|
||
这个开关是 `config.json` 中根级的 `pluginsEnabled` 字段。修改后请运行 `paseo reload --json`。启用会启动所有自身 `enabled` 值不为 `false` 的已配置插件;停用会拆除所有插件。不需要重启 daemon。手动编辑的插件来源条目不会被重新加载;这些请使用插件生命周期命令。
|
||
|
||
## 加载失败 {#load-failures}
|
||
|
||
使用 `paseo plugin ls` 读取当前的状态和错误。
|
||
|
||
| 症状 | 检查 |
|
||
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||
| `This plugin was made for an older version of Paseo` | 目录中只有 `index.ts` 入口。请按照[迁移指南](/docs/plugins/migration)操作。 |
|
||
| `Plugin entry points are missing` | 不存在名称完全一致的 `index.client.tsx` 或 `index.server.ts`。 |
|
||
| `server-only module cannot be imported into the plugin client bundle` | 客户端代码导入了 `server/`。把这部分工作移到 RPC 之后,并从 `shared/` 导入它的契约。 |
|
||
| `client-only module cannot be imported into the plugin server bundle` | 服务端代码导入了 `client/`。改为从 `index.client.tsx` 注册该贡献。 |
|
||
| `Node module cannot be imported into the plugin client bundle` | 客户端代码导入了 `node:*`。把该操作移到 `server/`,并通过 RPC 调用它。 |
|
||
| 侧边栏项缺失 | 确认插件处于 `running` 状态,该项没有在 **设置 → 侧边栏** 中被隐藏,它的组件没有抛出错误,并且客户端连接的是该安装所在的主机。 |
|
||
| 客户端模块不可用 | 只导入上面列出的、由宿主提供的客户端模块。 |
|
||
| RPC 被拒绝 | 检查两个 Zod schema 以及 daemon 端处理函数的错误。 |
|
||
| 修改后的代码没有生效 | 运行 `npm run typecheck`,然后运行 `paseo plugin reload <id>`。 |
|
||
| 重新加载失败 | 读取 `paseo plugin ls` 和 `paseo plugin logs <id>`,修复源代码错误后再重新加载;Paseo 不会恢复之前的插件包。 |
|
||
| 插件意外退出 | 读取 `paseo plugin logs <id>`,查看保留的初始化、清理、stderr 以及最终崩溃的输出。 |
|