The truthful per-provider readiness work (#67201) gates the desktop
Capabilities panel on _has_agent_browser, which only probes PATH and
node_modules/.bin. Now that agent-browser is no longer a root
package.json dependency (#43564), npx-only installs report needs_setup
in the panel while the browser tools themselves resolve fine at
runtime — and existing installs flip to needs_setup as soon as a
hermes update prunes node_modules.
Mirror the local-CLI tail of check_browser_requirements: resolve via
_find_agent_browser(validate=False), honor the Termux bare-npx
carve-out, and keep the old probe as the import-failure fallback.
Existing shutil.which test stubs gain the real signature so the
cascade's path= keyword calls don't break them.
Root `npm install` no longer installs agent-browser (it's not a root
package.json dependency anymore, see #43564) -- update docs that told
users to run it for that purpose, or that credited it with installing
"browser tools".
- browser.md: agent-browser resolves automatically via npx; a global
npm install -g is now presented as an optional way to skip the
one-time npx fetch, not a required step.
- browser-provider-plugin.md: fix stale comment claiming post_setup
"agent_browser" installs the npm dep -- it only ensures Chromium now.
- CONTRIBUTING.md: relabel the two optional `npm install` steps as
docs-site/workspace dependencies rather than "browser tools".
- termux.md: drop the now-pointless `npm install` from the manual
Node-dependencies step; Node.js itself is the only prerequisite,
agent-browser resolves lazily via npx same as everywhere else.
`hermes update` was pruning root-level Node dependencies (agent-browser)
because npm ci always wipes and reifies node_modules according to its
active filter -- no root-first/workspace-first ordering or flag
combination (--workspaces=false, --include-workspace-root, etc.) can
reliably keep a root-only package.json dependency from being pruned by
a subsequent workspace-scoped npm ci. Confirmed empirically and via
npm/cli source (isArboristCmd hardcodes includeWorkspaceRoot=false for
ci/install), so no amount of install-order juggling fixes this for good.
Instead of chasing install order, remove the root-only dependencies
that made the npm step fragile in the first place:
- agent-browser is no longer a root package.json dependency. It
resolves lazily via `npx agent-browser` (tools/browser_tool.py
already had this as a fallback; it's now the primary path).
warm_agent_browser_npx_cache() is called fire-and-forget from both
`hermes update` and `hermes doctor --fix` to keep npx's cache warm,
preserving the "available before any session starts" property
agent-browser had as an eager dependency without re-entangling it
with the npm workspace graph.
- @streamdown/math moves to apps/desktop/package.json, where it's
actually imported (markdown-text.tsx, katex-memo.ts) -- it was
never used anywhere else and was subject to the same pruning risk.
- _update_node_dependencies() collapses to a single
`npm ci --workspace ui-tui --workspace web` call now that root has
no dependencies of its own to protect, and keeps its original spot
ahead of `_build_web_ui()` at both call sites in update_cmd.py --
with no root-only dependencies left to protect, there's no reason
for the Node refresh and the web build to run in any particular
order relative to each other.
- hermes_cli/tools_config.py's post-setup Chromium-install path and
hermes_cli/doctor.py's agent-browser check both now resolve through
the same PATH -> Homebrew/Hermes-managed-node -> npx cascade
(_find_agent_browser / _resolve_npx_bin) instead of hand-rolling
their own node_modules/.bin lookups, so they can't diverge from what
browser tools actually invoke at runtime.
- tests-js/package-json-lazy-deps.test.ts gets a lockfile-level check
mirroring the existing camofox one, so a future regression that
reintroduces agent-browser into package-lock.json fails this test
directly instead of relying on manual review to catch it.
Fixes#43564.
Simplify-code findings:
- _authenticated_json: replace manual header construction with
self._headers(include_tenant=False) — eliminates duplication with
_headers() and includes Content-Type consistently.
- _health_requires_credentials: replace getattr(exc, 'status_code')
with _status_code_from_error(exc) for consistency with the existing
error-classification utility. Drop the fragile string-matching
fallback — _parse_response always sets status_code on
_OpenVikingHTTPError, so 401/403 check is sufficient.
- Relax test header assertions to check presence/absence of specific
headers rather than exact dict equality, so they survive the
header-construction refactor.
Hosted OpenViking (Volcengine) rejects anonymous GET /health with
AuthenticationError, which made the provider look unhealthy and silently
disabled automatic memory mirroring. Keep the anonymous probe first for
identity safety, then retry once with the configured API key only when
the server demands credentials.
Fixes#78410
Desktop plugins reach the backend only through ws JSON-RPC; image
generation existed solely as a model tool, so plugin UI (avatar pickers,
artifact panes) could not generate images. image.generate delegates to
the configured image_generate backend, supports probe:true for cheap
availability checks, and returns the result as both the backend ref and
a size-capped data URL — remote-gateway clients cannot read gateway-host
file paths and hosted URLs are often CORS-opaque, so the data URL works
identically over local and remote gateways. Pool-dispatched; missing
backend degrades to a soft {available:false} instead of an RPC error.
Adds a 'Scheduled jobs' row to the sidebar's top nav (below Artifacts,
watch codicon, wired to the existing nav.cron keybind action) so the
cron overlay opens from the same rail as New session / Capabilities /
Messaging / Artifacts.
Inside the cron overlay, the list rail now also shows the Automation
Blueprint recipes below the jobs (same search box filters both).
Clicking a recipe opens the create dialog pre-seeded to that
blueprint's typed-slot form via a new optional blueprintKey on the
create EditorState. Catalog fetch reuses the ['cron-blueprints'] query
key, so no extra request.
i18n: sidebar.nav.cron added to en/zh/zh-hant/ja (ar already had it).
OpenCode identifies clients by request headers, the same way OpenRouter
does. Our opencode-zen and opencode-go profiles never set any, so every
request went out with the OpenAI SDK default "OpenAI/Python x.y.z"
User-Agent and OpenCode had no way to tell the traffic was Hermes Agent.
Two changes:
- Add HTTP-Referer, X-Title, and a HermesAgent User-Agent to both
OpenCode profiles through profile.default_headers, the same path
Fireworks uses. This covers chat_completions, codex_responses,
auxiliary clients, model switches, and the models catalog fetch.
- Merge the same headers in build_anthropic_client for opencode.ai
base URLs. The Anthropic Messages route (Claude on Zen, MiniMax and
Qwen on Go) builds its client there and never sees profile headers.
Verified against the live Go relay with a real key. Both wire formats
return HTTP 200 and the requests now carry X-Title "Hermes Agent",
HTTP-Referer, and User-Agent HermesAgent/0.20.0.
Live verification (gpt-5.6 @ api.openai.com) proved the Responses server
renders NOTHING placed before a replayed compaction checkpoint: a fact
stated in a pre-checkpoint input item is invisible to the model, while the
same item after the checkpoint recalls perfectly. Hermes was replaying the
full pre-checkpoint transcript anyway — dead upload weight, and worse, every
plaintext user ask from before the boundary silently vanished from the
model's view, surviving only inside the opaque server summary. That is the
goal-drift failure mode reported against native compaction sessions.
Codex CLI never hits this because it rebuilds history client-side after
compaction, retaining user messages verbatim under a token budget. This
change is the wire-level equivalent: when a replayed checkpoint is present,
_chat_messages_to_responses_input restructures the input as
[newest checkpoint run] + [retained pre-checkpoint user messages,
newest-first within a 64K-token budget] + [post-checkpoint tail]
Histories without a checkpoint are returned unchanged, so non-native
sessions see a byte-identical wire.
Webhook agent runs default to the constrained hermes-webhook toolset
(web/vision/clarify) because payloads can carry untrusted third-party
content. That default is right for public webhooks but wrong for trusted
local pushes (e.g. an OOM monitor daemon that needs the agent to run
ps/free/py-spy): the only workaround was widening platform_toolsets.webhook,
which elevates EVERY webhook route at once.
This adds a 'toolsets' key on individual webhook route configs (static
routes in config.yaml and dynamic subscriptions in
webhook_subscriptions.json) that replaces the platform-level resolution
for that route only:
- BasePlatformAdapter.toolsets_for_source(): per-source override hook,
default None (no behavior change for any other platform).
- WebhookAdapter.toolsets_for_source(): maps the session chat_id
(webhook:{route}:{delivery_id}) back to its route config and returns
the route's toolsets list.
- GatewayRunner._resolve_enabled_toolsets_for_source(): shared resolver
used by both agent-run call sites; validates the override through the
SAME _get_platform_tools path as platform config, so unknown names and
platform-restricted toolsets (e.g. discord_admin) are dropped rather
than trusted.
Deliberately NOT exposed via 'hermes webhook subscribe': granting elevated
tools is a manual config edit only, so an agent-created subscription
cannot self-grant terminal at runtime.
Cache-safe: the toolset list is resolved before agent construction and is
constant for a route, so the per-session agent signature and frozen system
prompt are unaffected mid-conversation.
Follow-up to the salvaged #80489 substring-fallback removal: the
structured 401/403 branch still exited with a bare return, leaving
_running True — dead listener, healthy-looking is_connected(), gateway
never told (the zombie half of the bug, OOF-156 class). It now sets a
non-retryable mattermost_auth_error with token guidance and notifies
the gateway fatal handler.
Also: pytest.importorskip for aiohttp in the verifier probe file
(module-level import crashed collection in envs without the optional
dep), and probe fixtures updated for the escalation attributes.
Independent-verifier boundary probes for commit fdd1a11ac5, covering
cases the implementer's regression tests did not exercise:
- WSServerHandshakeError(status=403) also stops the loop (only 401 tested)
- WSServerHandshakeError(status=500) does NOT stop the loop (structured
check must not over-match on type alone)
- transient error containing the word 'unauthorized' (not digit substring)
now retries correctly
- 5 consecutive transient errors all retry, not just the first
Verified these 2nd/4th tests fail against the pre-fix baseline commit
(01a1037d1e) and pass against the fix (fdd1a11ac5), confirming they
have real signal.
The WS reconnect loop had a fallback check that looked for "401", "403",
or "unauthorized" as substrings anywhere in an exception's string form.
A transient error whose message happens to contain those digits (a proxy
body, a stack trace, anything) got treated as a permanent auth failure
and stopped reconnection for good.
I removed the substring fallback and kept only the structured check:
aiohttp.WSServerHandshakeError with status in {401, 403}. That's the only
signal that reliably means the server rejected our credentials.
Added two regression tests: one proving a transient error containing
"401" in its text still retries, and one confirming the existing
_closing early-return path is untouched by the removal.
ensureGatewayProfile narrows the Sessions sidebar to the activated
profile as a side effect, so every cross-profile open from a plugin
surface silently locked the user into that profile's session list. A
plugin-driven open is a navigation, not a scope choice: openSession now
restores the unified all-profiles view after a cross-profile activation
(keepAllProfilesScope, default true; pass false for the old narrowing).
Same-profile opens write no scope at all.
Simplify-code finding: test hardcoded exit code 75 in string
assertion while already importing GATEWAY_SERVICE_RESTART_EXIT_CODE.
Use f-string interpolation so the assertion tracks the constant.
Follow-ups to the salvaged #80032 fatal-error escalation, closing the
gaps its review thread identified plus a sibling of the same class:
1. Partial-batch loss: _check_inbox now dispatches whatever the fetch
returned BEFORE escalating a failure — the early-return dropped
already-fetched messages whose UIDs were marked seen.
2. Seen-after-fetch: UIDs enter _seen_uids only after their fetch
returns a response, so a mid-batch connection failure leaves the
remaining UIDs eligible for the next poll. Per-message processing
moved to _parse_fetched_message behind a poison guard: a message
that fails parsing/auth-verification is marked seen, logged with
its UID, and skipped once — never an eternal crash loop.
3. Reconnect mail loss: connect(is_reconnect=True) restores the
account's seen-UID baseline from a class-level snapshot instead of
re-marking the entire mailbox seen — mail that arrived during an
outage is now processed after the reconnect the escalation triggers.
7 new regression tests.
_fetch_new_messages() wrapped the whole IMAP connect/login/select/search/
fetch sequence in a bare except that logged and returned an empty list —
indistinguishable from a genuinely empty inbox. The adapter never invoked
its fatal-error handler, so the gateway's reconnect/backoff/status
machinery never learned the mailbox was unreachable; outages lasted until
a manual restart.
Track fetch failure on the adapter and, when the poll loop observes it,
set a retryable fatal error (email_imap_fetch_failed) and notify the
gateway handler so the platform enters the reconnect queue just like a
startup connection failure.
Final-diff simplify/review pass findings on #83785:
- Consent gate (confirm_truncate -> 4029) now checked BEFORE target
resolution, restoring the pre-PR precedence: an unconfirmed submit
carrying truncation params refuses without paying the durable-transcript
read or heal-stamping live history dicts, and an unconfirmed out-of-range
ordinal returns 4029 (not 4018). Malformed params still refuse first
with 4004. Regression test added (spy DB asserts zero reads pre-consent;
mutation-checked against the previous commit).
- _coerce_truncate_ordinal generalized to _coerce_truncate_int(param_name):
the row_id branch was inlining the exact bool-guard + int() -> 4004
pattern the helper had just extracted.
- Deleted the dead user_indices re-read after _resolve_truncate_row_id
(heal mutates dicts in place; the filter output is identical) and the
duplicate range check that had deadened the pre-existing guard.
- Desktop: exported isVisibleUserMessage from use-prompt-actions/utils and
used it in visibleUserOrdinal / visibleUserIndexAtOrdinal /
rebindSurvivorRowIds — one predicate for the ordinal parity all three
depend on instead of three verbatim copies.
- Docs: programmatic-integration.md documents survivor_user_row_ids.
check:lint failed on the two sort-named-imports errors (survivorRowIdsFrom
before type SurvivorUserRowIds) introduced by the rebind commit; also adds
the blank line eslint wanted in the new test helpers.
Review follow-up (StanleyStetson + egilewski on #83785/#83202): a successful
rewind's replace_messages(archive_dropped=True) re-inserts the surviving
prefix as NEW SQLite rows. Gateway memory picks up the fresh _row_id stamps
via lastrowid, but the Desktop's surviving bubbles kept their pre-rewind
ChatMessage.rowId — so a second rewind/edit/regenerate of an older surviving
turn sent a stale truncate_before_row_id and was (correctly) refused with
4018 until a transcript reload. Fail-closed stays untouched, per both
reviews; the fix is rebinding, not ordinal fallback.
Server: prompt.submit now returns survivor_user_row_ids (fresh post-rewrite
ids of surviving visible user turns, in visible-user-ordinal order) on both
the inline and compute-host paths whenever a durable truncation committed.
Desktop: runRewindSubmit surfaces the field; restore/edit/reload on both the
primary chat and session tiles rebind surviving user bubbles positionally
(same visible-user filter the ordinal math uses) and clear any rowId they
cannot rebind — a cleared id degrades to the ordinal path instead of a 4018.
Absent field (older gateway) leaves state untouched.
Tests: consecutive-rewind regression on a real SessionDB (stale id 4018s,
returned id succeeds; mutation-checked) + vitest for survivorRowIdsFrom /
rebindSurvivorRowIds (rebind, null-clear, past-end clear, hidden skip,
identity preservation).
Review cleanup on the #83202 salvage (findings from the 4-angle + 3-reviewer
passes, all verified against the diff):
- Extract _coerce_truncate_ordinal() and _reconcile_client_ordinal(): the
bool-check/int-coercion block was duplicated verbatim 3x and the 4030
ordinal-mismatch block 2x across the row-id/message-id/ordinal branches
(~90 lines of copy-paste with drift risk between the two durable branches).
- Delete target_idx (4 assignments, 0 reads — the cut uses
user_indices[ordinal]) and replace the stale inline user-indices
comprehension with the _history_user_indices helper it duplicated.
- Drop test_reproduce_row_id_truncation: a strict subset of
test_prompt_submit_truncates_by_row_id +
test_prompt_submit_refuses_ordinal_and_row_id_mismatch with weaker asserts.
- Collapse PR-introduced blank-line runs in the test file.
Behavior-preserving: error codes, messages, and log fields unchanged
(4004/4018/4029/4030 wording identical); full test_tui_gateway_server.py
suite green (549 passed).
The #83202 heal path zip-stamped _row_id onto live-memory dicts purely by
position whenever the durable and live lists had equal length, and the DB
fallback mapped durable user-ordinals onto live indices with only a bounds
check. Equal length is not proof of alignment: the durable copy is loaded
with repair_alternation=True (merges user;user pairs, collapses consecutive
assistants, drops orphan tool rows) while live memory is unrepaired and can
carry optimistic/marker rows — the two can coincide in length while
position-shifted. A misaligned stamp is sticky: it permanently attaches the
wrong durable id to a live dict and re-aims every later rewind (E2E probes
showed a wrong-content cut and a persisted alternation break).
_mem_db_pair_agrees() now gates both paths: the heal loop stamps only when
EVERY zip pair agrees on role, display-marker status, and (for addressable
user turns) content; the ordinal fallback verifies the mapped live turn
shows the durable target's content, else refuses via the existing
fail-closed 4018. Regression tests derived from the review probes (content
swap, role shift, repaired-merge ordinal shift); the misalignment guards
fail on the pre-fix code.
Surfaced during review of PR #83202 for #82959.
Address rewinds/edits via SQLite messages.id (truncate_before_row_id)
instead of shifting user ordinals. Resolve against in-memory stamps,
then durable session history when live turns drop _row_id; refuse
unknown durable targets with 4018 (no ordinal fallback) and 4030 on
ordinal/row_id mismatch. Stamp _row_id on insert, load row ids on
resume paths, send rowId from Desktop, filter renderer-synthetic ids,
and stop silently resending failed targeted edits without truncation.
Add production-shaped SessionDB tests for resolve and fail-closed paths.
Fixes#82959
A profile created through the headless ws door (profiles.create, #85093)
was born with no inference provider: create_profile() seeds a comment-only
.env, never copies auth.json, and a fresh profile has no config.yaml. Its
first message failed with 'No inference provider configured' and the flow
has no interactive setup step to recover with.
New mirror_credentials param (default true): copy the launch profile's
.env (only over the seeded stub — never clobber cloned secrets) and
auth.json (only when absent), both chmod 600, and inherit
model.provider/model.default when the caller gave no explicit pin and no
config was cloned. mirror_credentials:false preserves the old isolated
behavior byte-for-byte. Result gains a mirrored:{env,auth,model_inherited}
receipt. CLI and REST create paths untouched.
The Project-grouping flag was one global bool while the grouping beneath it
was already stored per scope (workspace vs all-profiles). Picking Project
inside a workspace therefore dragged the all-profiles view into the project
tree and vice versa — "I have to re-set grouping every time I switch."
The flag now lives per scope like its sibling grouping atoms (the flat key
keeps its historical name so existing choices survive), setSidebarGrouping
writes to the scope it just switched INTO when Profile flips the view, and
reset clears both scopes.
scrollbar-overlay opts out of the themed thin scrollbar; on Windows there
are no native overlay scrollbars, so Chromium painted the classic
always-visible gutter instead — a permanent scrollbar next to the recents
list. The themed fade bar reserves its 4px on every platform but stays
invisible until hover, and the wrapper no longer stacks a second scroller,
which is what the overlay class was originally working around.
The card prop was gated off whenever Project grouping was active, so the
Inbox style toggle silently did nothing there. It is a render variant, not
a grouping: project lanes and overview previews now render the same card
the flat list does.
Also mirrors the section's real virtualization inputs (projectOverview /
entered-project content, not the persistent agentProjectTree cache) when
deciding the wrapper's scroll classes, and stops gating SCROLL_Y on that
parallel guess — the section is the single authority on which scroller
lives, so the recents pane can no longer end up with no scroller at all
(the "no sessions under Updated grouping after toggling settings" blank).
Third draft provider: recurring phrasing in the draft ("every morning",
"daily", "each week") offers a Schedule-this pill. Click prefixes the
draft with an explicit scheduling instruction and the agent creates the
job via its cronjob tool on send — the pill never schedules anything
itself. Proper-noun guard keeps titles like "the Daily Prophet" quiet;
hyphen-as-word-char keeps "weekly-report.pdf" quiet.
Two new sources on the suggestion bus, one per provider shape:
- skill (draft): the draft names an enabled skill (whole-word,
4+ chars), so offer to lead the message with its /command. Invoke
prefixes the draft via the new 'prefix' insert mode and stands down
once the draft starts with a slash; skill_manage invalidates the
cached index alongside the slash-completion cache.
- repair (event): an mcp__ tool call failing with auth/connection-
shaped output offers a one-click reconnect for that server, fed from
the gateway tool.complete handler. Reconnect runs the shared OAuth
flow with server-side cancel and reloads live tools before claiming
success; a later successful call to the same server withdraws the
offer on its own.
The bus now keeps a session-scoped declined ledger: a pill the user
watched appear and let die three times stops re-offering for the rest
of the session. Acting on a pill clears its count, so a suggestion
that was taken can come back for the next trigger. In-memory on
purpose — a fresh session is a fresh chance.
The cherry-picked #79448 predated #85049's _classify_connect_exception,
so it added a parallel PrivilegedIntentsRequired branch ahead of the
classifier (plus its own _is_privileged_intents_required detector).
Fold the tailored guidance into the classifier's existing intents arm
instead: one classification path, one error code (discord_intents_required),
and the message now names exactly the intents Hermes requested (Message
Content always; Server Members only when username/role allowlists need it).
Wizard callout, docs corrections, and tests from #79448 kept as-is.
PrivilegedIntentsRequired is a Developer Portal config error; surface which
intents Hermes requested as a non-retryable fatal and teach setup/docs.
Co-authored-by: Cursor <cursoragent@cursor.com>
Desktop plugins reach the backend exclusively through the generic ws
JSON-RPC door (host.request), but profile enumeration/creation only
existed on the dashboard REST router, which plugins cannot reach — so
anything 'one chat per agent profile'-shaped (bot rosters, profile
pickers, team panes) was impossible to build as a plugin.
- tui_gateway/methods_profiles.py: new @method handlers
* profiles.list — profiles + optional last_session preview per profile
(mirrors session.list's kanban/tool deny-list; best-effort per-profile
state.db probe degrades to null instead of failing the call)
* profiles.create — ws twin of POST /api/profiles (clone_from/clone_all/
no_skills/description), plus optional SOUL.md content and a best-effort
model+provider pin; mirrors the CLI flow (seed skills, safe alias)
Both run on the RPC pool, not the WS reader thread (list_profiles walks
skill trees; create copies bundles).
- SDK: host.openSession(id, { profile, intent }) — open a stored session
the way core surfaces do, soft-swapping to the owning profile's backend
first (ensureGatewayProfile), and host.newChat(profile) — fresh draft in
a named profile (same door as the sidebar's per-profile '+').
- Docs: desktop-plugin-sdk.md gains both surfaces.
First consumer: a Grok Bot-style 'Bots' roster plugin (one persistent
chat per agent profile with a New Agent dialog) built on exactly these
four doors.
The pill strip from the inline-MCP work is worth more than one source, so
the MCP-specific store splits into two layers with the same UX contract
(session-scoped, capped, self-limiting, one-click with narrated
idle→working→done):
- store/composer-suggestions.ts — the bus. Draft providers register into
the existing debounced sampler; event providers push/withdraw directly.
Offerings merge (event before draft), dedupe by provider-namespaced key,
and keep reference identity on no-ops.
- store/suggestion-providers/mcp.ts — the founding provider, behavior
unchanged: directory keyword/host matching, configured-server exclusion,
one-click connect with OAuth cancel + config rollback.
- composer/suggestion-pills.tsx — the generic strip; phases and cancel
live here, action/rollback/toasts stay with the provider's invoke.
No new pills yet — this is the seam for them.
The card reads the labelled choices off the gateway request rather than
the raw tool args -- the backend applies the label there, and the card
only mounts once the request exists, so the args are a hydration-race
fallback. RECOMMENDED_LABEL and bareChoice live in the clarify store so
the component and the choice-length guard share one definition; without
the guard a long option could be dropped for length the label added.
The clarify schema now tells the model to order choices best-first, and
mark_recommended tags element 0 with "(Recommended)" at the tool layer --
the one platform-agnostic entry point -- so CLI, TUI, desktop, and every
messaging adapter inherit the label without a copy each. Each surface
already defaults its cursor to index 0, so the recommendation is the
pre-highlighted row too.
The label is presentation only: strip_recommended takes it back off
user_response, and choices_offered reports the bare list, so the agent
never reasons about (or echoes back) a string it did not write. Typed
replies on messaging platforms match with or without the suffix.
The toolset inventory and the post-hook ownership contract both
enumerate the GUI tools; the new tool joins both lists (and the
emit-once parametrization actually exercises its executor path).
A renderer-local directory of official hosted MCP remotes (URL-only,
vendor-documented endpoints — deliberately not the reviewed install
catalog) powers keyword and pasted-link suggestions: typing jira or
pasting a *.atlassian.net URL floats an 'Add Atlassian' pill in the
composer's micro-action strip. Matching is whole-word/phrase (unicode
boundaries) plus strict host-suffix on links, host hits outrank
keywords, capped at two, debounced 600ms, and excludes servers already
in mcp_servers. Pills are session-scoped like the micro-action badges
and self-limiting rather than dismissible — they exist only while a
trigger is in the draft. A click drafts the setup request; the agent's
setup_mcp card carries the consent. Brand glyphs extracted from the
mcp-tab into lib/mcp-brands (shared, monochrome marks follow the theme
so GitHub/Notion/Vercel survive dark mode).
The card follows the approval bar's consent vocabulary (primary-tinted
action + ghost decline, ⌘⏎/Esc with clarify's focus-stand-down rule) on
clarify's widget shell. Install prefers the reviewed catalog entry (env
prompts inline, background installs polled to completion) and falls back
to the desktop suggestion directory via the validated add-server POST +
OAuth; success reloads live MCP tools before unblocking the agent so it
resumes with the tools it was just promised. Esc stays live mid-flight
as cancel — the abandoned flow aborts at its next poll and a post-write
cancel rolls the config entry back. Typing while the card is pending
declines it and sends normally (skipClarifyRequest's pattern), and the
request/tool.start rows merge on the server arg so reconnects can't
double-render the card.