groups.log logged a contract violation on every read with bot replies because
RoomActor forbade the member fields the gateway stamps. Serve retry rows now
carry member_id and execution_generation, so Retry works over SSH, URL and
Cloud connections.
Rows HR-11, HR-14.
Refs #143547
A `file:` URL holding `..%252f` passed the renderer's encoded-separator check, because only after
one decode does it read `..%2f`. The gateway then decoded the path a second time when the first
reading did not exist, turned `..%2f` into `../`, and served a file from outside the folder the
URL names: the preview or clip on screen was not the file its label showed.
The renderer now refuses a URL whose decoded path still encodes a separator (`%2f`, and `%5c` in a
Windows path) or a dot-segment, as it already refused an encoded separator. The gateway never
decodes a `file:` URL's path twice, and its one-decode rescue for a doubly encoded plain path
(#103425) no longer applies when decoding would add a `.` or `..` segment.
The browser host's change made a failed native ticket mint reject on local Electron too, where
main dials the cached URL. Only the Webapp's own socket (native_dial=browser) rejects now; that
value is one shared constant, BROWSER_NATIVE_DIAL.
Ported from BearHuddleston's #93508.
@hermes/shared gets fileUrlToNativePath, which reads a file: URL the way the URL implies: a UNC
host or a drive letter is Windows, anything else POSIX. Previews, media and Electron's media
protocol use it, so a drive-letter or UNC URL names the same file on every surface and a URL that
encodes a separator names none.
Co-authored-by: emozilla <emozilla@nousresearch.com>
Carried from BearHuddleston's #93508: the browser bridge and transport,
launch-session handling, touch and phone layout, browser previews, file
staging, the host terminal rail, Webapp settings pages and media ticket
renewal. Relay-only and fence-only code stays out.
Co-authored-by: emozilla <emozilla@nousresearch.com>
Carried from BearHuddleston's #93508 onto main. The Webapp relay and the
profile-incarnation fence stay out: carried code follows main's
named-profile rule and refuses a missing or deleted profile home.
- Webapp routes: one-use launch tickets, window handoff, and gateway
WebSocket tickets minted for the signed-in browser
- Browser surface policy and the SPA document policy
- A host terminal for the browser that survives reconnects
- Staged browser uploads and file tickets; file.attach takes a staged upload
- hermes sessions adopt
- Webapp bundle build helpers and the renderer freshness check
- Regenerated gateway contracts
Co-authored-by: emozilla <emozilla@nousresearch.com>
Review fixes (Greptile on #143980):
- Ownership is exact: `plugins.manage list` reports each package's tools by
name, including the tools its MCP servers actually registered, instead of
`mcp__<server>__` prefixes (sanitized names can collide and a clamped long
name loses its prefix). A name two packages list belongs to neither;
Desktop's longest-prefix guess is gone and `tool_prefixes` is dropped.
- Ownership follows the chat: the list is fetched from the session's own
owner route with its profile (`knownOwnerForSession` +
`requestForSessionProfile`) and cached per owner, not from the window's
active gateway.
- Stale answers never restore removed ownership: each fetch carries a request
id and only the newest request for an owner may write; a plugin-list change
clears the cache and orphans in-flight answers.
- A failed fetch retries with a bounded backoff (2 s doubling to 60 s)
instead of latching the error.
- The producer match is a whole-path match (`/out/a.glb` never matches inside
`/out/a.glb.bak` or `/x/out/a.glb`).
A Desktop plugin can now register how Desktop shows a MEDIA: attachment
produced by one of ITS OWN tools (TOOL_MEDIA_RENDERER_AREA,
'transcript.toolMedia'). Ownership is provenance, never file type: the
transcript finds the tool call in that turn whose result produced the path
(earliest call wins, so a later `ls` only mentions it), and the plugin's
component replaces the built-in player only when that plugin's package
registered the tool.
Why: the elevenlabs-sound-effects and meshy plugins each needed a custom
card for their files and had to make the model emit a ::directive (plus a
backend handshake) to get one, which leaves non-Desktop users a raw line.
With this seam the tool keeps returning a plain MEDIA: tag every surface
understands and Desktop alone upgrades it.
- Backend: `plugins.manage list` rows carry `tools` (native tools the
loaded plugin registered, minus built-in names and names two plugins
list) and `tool_prefixes` (`mcp__<server>__` for each MCP server the
loader attributed to a portable package). Contract + generated TS.
- Desktop: lib/tool-media-renderers.ts (contract, producer lookup,
ownership resolution), store/tool-media-owners.ts (lazy per-route
ownership list), components/assistant-ui/tool-media-slot.tsx wired into
MarkdownLink's MEDIA and bare-path branches. Claims on core or other
plugins' tools, or from a desktop plugin with no agent half, are refused
with a console warning. A throwing renderer, a disabled plugin or an older
backend fall back to the built-in player.
- Props: file {path, name, kind, mime, resolveUrl()} (stream route for
audio/video, works on remote backends), toolName, toolCallId, args,
result, sessionId, fallback.
- Docs: desktop-plugin-sdk.md section with a worked example; skill ref.
The canonical session.resume / session.events.since replies carried only
approval and clarify prompts. Server-to-client requests sent through
gateway/session_server_requests.py (setup_beat, sudo, secret, batch clarify,
the Desktop UI bridges) kept no params and had no open_requests, so a
renderer reload mid-onboarding showed no card while the agent blocked up to
CARD_TIMEOUT_S. The legacy sidecar replayed them (tui_gateway/server_requests.py
open_requests); the shared channel already re-delivers open_requests from any
reply.
_Pending now keeps the frame's params; open_requests(events, session_id)
lists the requests still waiting on that live stream (with a batch's locked
answers), and both replies carry it. The contracts gain the optional field.
activate_plugin_now merged the gateway's and the Desktop/dashboard
backend's missing dependencies into one list, and the hint picked its
restart line from whether the gateway answered. A backend booted before
the install beside a gateway restarted since then got "hermes gateway
restart", which leaves the backend unable to load the plugin.
The activation now records which processes reported the gap
(missing_in: gateway / backend, declared on PluginActivation) and the
hint prints one restart line per process.
Three gaps in the restart notice, found in review of #143404:
- A plugin importing its dependency at module load fails to load in a
gateway booted before the install, so it had no activation summary and
enable still printed "nothing of this plugin needs a session or
restart". The gateway's reload answer and the in-process go-live now
carry a dependency-blocked entry for it (dependency_blocked_summaries),
kept out of the on_plugin_loaded payload since nothing of it loaded.
- PluginActivation (extra="forbid") did not declare missing_dependencies,
so every plugins.manage reply carrying it violated the wire contract.
Declared + regenerated the shared contract.
- The presence probe ignored versions: a running process holding x 1.2
satisfied x>=1.3 after a plugin update raised the floor. It now checks
the requirement's specifier, in this process and in the selected
generation.
The restart line also names the process that answered: the messaging
gateway, or the desktop app / dashboard when only the serve backend did.
Dependency activation is a boot decision: a gateway or dashboard running when
`hermes plugins enable` commits a new generation keeps the one it booted with.
Enable still nudged that gateway to reload, then reported "Gateway reloaded
plugins; nothing of this plugin needs a session or restart" while the plugin
failed to import its dependency there (a terminal backend then fails with
"No module named '<dep>'"), and discovery told the user to run
`hermes pm repair`, which changes nothing.
The activation summary now carries `missing_dependencies` read in the process
that loaded the plugin (the gateway, for its reload answer); `restart_required`
is set when this process or the gateway lacks them, and the CLI hint and the
discovery warning say restart when the selected generation carries the
dependency (repair only when it does not).
The broker's capability allowlist named only the deleted browser_navigate/click/type/... tools, so a
bound extension controller silently did nothing (review P1-1); owner ruling: remove the feature.
Drops the broker, artifact store, extension router, /v1/browser-control + /v1/artifacts routes,
browser.controller.* RPCs/events (contracts regenerated) and the per-request controller identity.
The authenticated-identity helpers bot relay and session ownership still use move to
gateway/session_identity.py; persisted API admissions carrying 'browser_control' still recover.
browser_exec is the only core browser path, so there is no Browser Use /
built-in tools switch to flip and no silent downgrade to announce.
- classic CLI, Ink TUI and the gateway browser.manage RPC lose the `use`
action (contract regenerated: BrowserAction, enabled, browser_use);
- /browser status no longer reports a mode; the Chromium-fallback line for
Lightpanda and the dead "chrome" engine row go with the built-in tools;
- browser_backend_notice leaves the session snapshot, the CLI banner and
the canonical contract.
TUI locale packs and _keys.tui.json drop the five slashCmd.ops.browser
keys whose only caller was `/browser use`.
* feat(connectors): list hosted apps without creating a guest identity
Opening the desktop Connectors page used to create a Nous guest identity:
connectors.list, connectors.catalog, connectors.accounts and
connectors.policy.get each created one before answering, because the
Portal's catalog route needs a token. A user who only looked at the page
got an identity anyway.
While there is no identity yet (and one may still be created), those four
reads now answer without one: the catalog from a snapshot that ships with
Hermes, an empty connector list, no accounts and no rules. Connecting an
app still creates the identity, as before.
scripts/refresh_connector_catalog.py rewrites the snapshot from a
signed-in install. It leaves logos out: no client draws them.
* feat(free-tier): sign-in offer for guests who connected apps
Every free-tier sign-in offer keyed on the welcome trial, so a user on
their own model key who connected apps through a guest identity never saw
one.
The offer record now tracks two offers on the same schedule. `trial` is
unchanged. `connector` is for a guest without the trial that has connected
an app: each app that finishes connecting sets `has_connected_app` on the
guest state (so it dies with the guest) and counts as a finished task.
free_tier.status (still a pure local read) adds connector_nudge_due_in, and
free_tier.claim_nudge takes a `kind`.
The desktop reads the connector offer whether or not the trial is
available, re-reads the status after a turn or a page connect, and shows
connector copy instead of the free-allowance copy. After a page connect
settles it also re-reads the catalog and rules, so the bundled catalog
from before the identity existed is replaced by the account's own.
* fix(connectors): point the sign-in hint at `hermes auth upgrade`
`hermes connectors` told users to run `hermes login`, which no longer
exists. Both hints now say "Run `hermes auth upgrade` to sign in."
* feat(connectors): read the public catalog with no token; one guest rule per entry point
The bundled catalog snapshot listed apps a new guest cannot connect and
went stale whenever the catalog changed. With no token, the Connectors
page now reads the Nous Portal's public catalog route
(GET /api/v1/connectors/catalog/public), sent with no Authorization and
parsed with the existing catalog model. Once a token exists the page reads
the signed-in route, as before.
Whether an entry point may create the guest identity is now declared once
per entry point instead of checked inside handlers:
- managed_client(creates_guest=...) is required: connect (page, agent,
card retry) and the explicit lists (CLI `hermes connectors list`, agent
manage_connections status/display) pass True, unchanged.
- account RPCs declare creates_guest on _account_method: catalog,
accounts and policy.get never create one; tools and the account writes
still do.
- the page's connectors.list never creates one; connect does.
With no token the page reads give true answers: public catalog, empty
connected list, no accounts. The invented "unrestricted" policy is gone:
the desktop asks for rules only once the list has rows, which needs a
token, and policy.get without one refuses with sign-in-required.
Deleted: the bundled snapshot, its refresh script, and the no-identity
stubs.
* fix(connectors): CI and review round: older backends, outside connects, link connects, guest opt-out
- CI: test_legacy_fallback_ticket_profile_verbs faked managed_client with
no arguments and gave the secondary profile no token, so the page list
(now token-gated, creates_guest=False) never reached it. The fake takes
the new keyword and the profile holds a token.
- Older backends: a trial claim sends free_tier.claim_nudge with no
params again. An older backend rejects the unknown `kind` key and would
have dropped the trial offer; the new backend defaults to the trial.
Only the connector claim sends `kind`.
- Outside connects: while the page's list is empty (no identity), every
finished turn or connect re-reads the list and catalog. An agent connect
in a chat then moves the page to the signed-in routes and its rules.
- Link connects: an agent status read that sees a connected app on a
guest without the flag arms the connector offer, so messaging and
one-shot connects (no operation watcher) count too.
- Opt-out: the connector offer requires nous.guest to be allowed.
* fix(connectors): the Connectors page list arms the connector offer too
A guest whose apps were connected by an older Hermes has no has_connected_app flag, and only the operation watcher or the agent's status read set it. The page's connectors.list now passes its rows to note_connected_apps_seen, which owns the any-connected check for both list reads.
* fix(connectors): review round 3: accounts after an outside connect, retryable offer record
- An identity created outside the page (an agent connect in a chat) now also re-reads the accounts cached while there was none, so the new account row, rename and remove show up.
- note_app_connected records the connector task before it sets has_connected_app. The flag ends list-read sightings, so a failed schedule write no longer leaves the offer unarmed for good.
Greptile: "Old allowance stays visible" (gateway/session_usage.py:101-102).
Both usage builders (gateway/session_usage.py::tui_usage and
tui_gateway/server.py::_get_usage) always send `calls` and send
`plan_gauge` only on a window plan, so a complete usage payload without
the key means the session is off the plan (or the allowance reset).
Desktop merged every payload into the stored usage, which kept the old
gauge forever. The new `mergeUsage` (apps/shared usage-tick) clears it on
complete payloads and keeps it across partial ticks; usageFromTick forwards
a top-level frame's gauge so a canonical counters-only frame is complete
too. The wire stays byte-identical for accounts never on a window plan.
The Ink TUI never reads plan_gauge, so it has no stale copy.
Greptile: "Headings mislabel pinned current models". sectionAt assumes picks-first order,
but the composer menu prepends a current model the catalog omits, so with featured A and
pinned custom X, X got "Recommended" and A "Other models". Re-apply the picks-first
order (splitRecommended) after the pin, and state the order contract on sectionAt.
Greptile: "Normal Desktop usage omits gauges". plan_gauge was built only in
tui_gateway/server.py::_get_usage (the embedded backend), but a normal local
Desktop session reads its session.usage ticks and message.complete usage from
gateway/session_usage (usage_stats -> tui_usage), so the status-bar allowance
never showed. Move the builder to gateway.session_usage.plan_gauge, use it on
both paths (omitted off a window plan, as before), and type it on the Usage,
SessionStats and session.usage contracts (regenerated).
Resolves the desktop i18n conflicts: main moved en/ru errorCodes into
en_error_codes.ts / ru_error_codes.ts, so usage_window moves there; ar/ja/zh/
zh-hant keep both usage_window and main's workspace_unavailable. _keys.desktop.json
regenerated.
* fix(desktop): catalog card actions sit above the card-wide hit area
On compact and hero cards the action slot comes before the title button,
whose ::after stretches over the whole card and painted on top of it, so
clicks on the switch or + opened the details instead.
* feat(desktop): install catalog entries with +, switch only once installed
Install is one-way, so it is now a + button instead of a switch that also
meant enable/disable. Installed entries keep their on/off switch. Featured
cards and the detail spell it out as "+ Install". A plugin's Uninstall
moves from the header trash icon to a labelled row at the bottom of its
detail.
* feat(plugins): badge the install dialog with the catalog tier
The dialog called every catalog pick Verified. It now says Official,
Verified or Community. plugins.catalog.lookup returns the tier so
hermes://plugin/install?catalog= links badge the same as in-app picks.
* refactor(desktop): plugin Uninstall row is its own component
Keeps PackageRow under its code-health complexity cap.
* chore(i18n): regenerate desktop locale keys for catalog.install
* fix(desktop): "Install here" badges the catalog tier too
The third way into the install dialog opened it with a catalog name but no
tier, so a community package still read Verified. Read the tier from the
cached catalog by name; uncached keeps the generic badge. Also note why the
catalog path passes entry.source as the tier.
Comment and docstring edits only (no code or assertion changes): bug
history in module docstrings, narration, cross-refs and test copies of
the handshake comment go; WHY comments on ordering, locking, the launch
profile scope and the old-backend wire constraint stay.
Since the unified gateway (#106742) the Desktop attaches to the shared
gateway instead of spawning its own `hermes serve`. Onboarding, the
welcome-trial claim and free inference decide "is this the Desktop app on
this machine?" from HERMES_DESKTOP and the spawn token in the process
environment, which the gateway never has. So `onboarding.state` answered
eligible:false for every new user, who landed on the provider picker with
no free account.
The answer now comes from the connection:
- The Desktop sends `client.capabilities {desktop_local: true}` first on
every socket, as a call of its own, before the boot's first
`onboarding.state` (the backend reads frames in order). An older backend
refuses only that call and keeps the server_requests answer.
- The gateway counts it only on a native-ticket connection, so a remote or
tunnelled client never gets the trial.
- Requests on that connection run under `serving_local_desktop(True)`, and
a chat it creates records the declaration in its launch policy, so the
chat's later turns run the same way (`local_desktop_turn`).
- A trial-only user has no configured model; a canonical chat freezes its
model at creation, so the chat now freezes the free model
(`_welcome_trial_model`) instead of refusing.
Live: a --fresh-install sandbox launch minted a guest account, opened the
onboarding chat and the free model answered.
Gateway-owned group rooms only reported a room-wide `working` flag, so a
client had no way to show which bot was busy. `HostedRoomService.status`
now returns `member_activity`: the room's live tasks (queued, running,
stopping) with their member, in admission order. `working` derives from
the same list. The field is optional in the contract, so older gateways
simply omit it.
Fixes#102196.
In canonical mode the picker filtered by connection membership alone, so it
offered parked profiles, retiring siblings, and (on a profile-bound
socket) siblings the caller may not seat. groups.create then refused them
by name, which left #141178 only half fixed.
groups.capabilities now returns seatable_profiles: the roster
gateway/session_hosted_members.member_roster gives THIS caller (served
bots minus problem/retiring ones, plus usable hosted_rooms.profiles
entries; served siblings only for a host-scoped socket). It is the same
rule and the same host_scope flag the create handler validates with. The
field is optional in the contract (TS + OpenRPC regenerated), and a gateway
that predates it keeps the connection-only filter.
The Desktop picker keeps a canonical-mode bot only when its target profile
is in that set.
Tests: tests/gateway/test_hosted_group_members.py::
test_capabilities_name_exactly_the_profiles_this_caller_can_seat (base:
KeyError 'seatable_profiles'), and canonical-group-capability-gates.test.tsx
"canonical creation offers only the bots the authority says this caller can
seat" (base: the parked bot is rendered).
MCP tool calls now send _meta.progressToken, so servers that emit
notifications/progress (Meshy refine, creative-lab) report live progress.
The executor binds a per-call relay; the MCP handler forwards each
notification to tool_progress_callback as a throttled (<=1 per 500 ms,
final always) tool.progress event. Surfaces:
- gateway event stream + legacy TUI gateway: tool.progress
{tool_id, name, progress, total, percent, message} (contract registered)
- Desktop: running tool row shows '42% · <message>' beside its timer;
plugins subscribe with ctx.onEvent('tool.progress')
- CLI: the running tool spinner shows the same text
Documented in the Desktop plugin SDK guide.
Symptom: a client attaching to a session with a plan (the classic CLI's
/resume, `chat -c`, /branch) gets no todo list from the owner's
session.resume reply. The only todo source is a later todo.updated frame,
which fires only when the model calls the todo tool again.
Root cause: the canonical snapshot (AuthorityConnection.resume) never had
a todo field. The TUI-gateway reply has carried todo_state since the
revisioned snapshots (itsflownium, 2026-08-29, 393af4a310); the owner
runtime (#106742) did not carry it over.
Change: the reply adds `todo_state`, the newest paired todo result in the
transcript it already loads (tui_gateway's _todo_state_from_history,
through the server module it is bound to, as session.resume's
display projection does). This is the snapshot the session's agent
hydrates from and its todo.updated frames carried. null when the session
has none. CanonicalSnapshot gets the optional field (the existing
TodoState model); TS and OpenRPC regenerated.
Why: the gateway stays the only source of task state, and a first
resume shows the plan with its revision.
Refs #134889
Refs #139158 (package 1)
Follows up #141090
A cron session only runs its own frozen fire. Anything else admitted into it
(a message typed into the Desktop chat while the job ran) made
session_cron.execute raise admission_conflict, which _execute_claimed logged
as a traceback and settled as the opaque "The admitted turn failed." card.
session_cron.execute now settles that admission as a failed turn whose text
names the restriction and whose error_surface is
{layer: gateway, code: cron_session_read_only, retryable: false};
completion_payload forwards a captured error_surface on message.complete.
The job itself still never runs for it.
Desktop gives cron_session_read_only its own card copy and the
"Start new chat" action instead of Retry. The TUI already renders the
specific text (no `error` field, so the bare-error rewrite does not apply).
"Copy error details" stamped `time:` with the copy moment, so a stale failure
read as a fresh incident. It now uses the bubble's recorded completion time
(the terminal frame's), or the hydrated row's own timestamp, and omits the
line when neither is known.
en.ts is over its size cap: the error-card layer tables move to
en_error_layers.ts to offset the new copy.
Salvages the idea of #141369 by @Ehsan-hash (cherry-picked as the parent
commit); this trims it to the cron producer, drops the new wire timestamp
field and the per-site allow-list in completion_payload, and reuses existing
bubble timeline metadata for the diagnostics time.
A live agent's 'rejected' can follow delivery: Codex turn/steer returns
False on an ACK timeout even when the server already consumed the steer.
The TUI read every same-generation 'rejected' as "never delivered" and
re-sent the words as prompt.submit, so the correction could run twice.
correct() now marks the no-agent refusal with delivered: False (declared
on CanonicalCorrectionAck, contracts regenerated). The TUI re-admits only
that reply plus the three pre-agent error reasons; a plain 'rejected'
settles as refused with the input retained, as on main. The salvaged
gateway tests are trimmed to the no-agent case plus a live-agent refusal
that must carry no marker.
A pane whose busy belief outlived its turn (a session switch, a reclaim)
sends the next message as session.steer / session.redirect. The owner
refuses it before delivery (execution_not_ready / stale_generation), and
both Desktop correction paths (the primary redirectPrompt and the tile
steerPrompt) threw that as a failed send: the optimistic row was
discarded, "prompt failed" toasted, and the composer's keep() fallback
never ran, so the text was lost.
Those refusals are now answered as "not corrected": the action returns
false and steerDraft's keep() re-queues the frozen words as the next turn.
A refusal that may have reached the turn still surfaces as an error.
isUndeliveredCorrectionRefusal lives in @hermes/shared so the TUI uses the
same reason set.
Fixes#141644
- Dashboard: the update route answered a scan block with a bare HTTP 400,
so the browser page could not show the findings or send force. It now
passes scan_blocked through (update route only; install is unchanged),
and the page's update button runs the same consent-then-scan flow as
Desktop through web/src/lib/plugin-update.ts. AgentPluginUpdateResponse
moves to web/src/lib/api-plugins.ts (api.ts is over the size cap).
- Saved update checks now record the install they were checked against
(source, revision, pin). A result is dropped once any of those changed,
so a --ref pin or a reinstall from another source no longer shows an
Update button that update refuses. An update_url feed's version target
is offered too (the old match compared it to a commit and never hit);
the row field is update_target, a commit or a version.
- Each finding in the Desktop dialog shows its description.
- Tests: a real subdirectory install exercises the re-clone path (caution
needs force, dangerous refused even with it, refusals leave the tree and
record alone); the dashboard route returns findings and applies with
force; the saved signal drops on update, new source and pin.
- locales/_keys.desktop.json regenerated for the new Desktop strings.
Desktop's plugins.manage update refused every non-catalog install ("not a
catalog install — update it via the CLI") and kept its own third copy of
the re-pin logic. It now calls dashboard_update_user_plugin, the same
function the dashboard route uses, so catalog and git installs update the
same way on every surface.
A scan block comes back as ok=false + scan_blocked + scan_verdict +
scan_findings (the shape install already returns) with nothing changed;
the client retries a caution verdict with force. The dashboard route reads
force from its body next to accept_capabilities.
The Plugins tab can only offer Update when it knows an update exists. Git
installs had no such signal, so the row never showed a button. The gateway
already runs the update check every plugins.auto_update_check_hours, and
`hermes plugins check-updates` runs it on demand; both now save the results
next to the cadence marker. The plugin list reads that file (no network per
listing) and sets update_available + latest_sha on git rows. A saved result
is ignored once the install's revision moves past the one it compared.
featuredPicks (apps/shared) returns every curated pick starting at this
week's rotation, so slide one is still the website's pickFeatured entry.
CatalogFeaturedSlider shows one pick bare; several get scroll-snap slides
(swipe/trackpad), prev/next and dots, advancing every 7s unless hovered,
focused, hidden or reduced-motion. Off-screen slides are inert.
OFFICIAL_SKILL_SOURCES / isOfficialSkillSource live in apps/shared and
replace three hand-kept copies (desktop curation, desktop discovery, the
website skills hero). catalog-data reuses CATALOG_ORIGIN instead of its
own DOCS_ORIGIN copy. The plugin hero's official check reads the tier.
Messaging completions were all skipped for the unread dot, including a turn the
user sent from Desktop, which lit the dot before the shared gateway listed
messaging sessions in Desktop's live poll. Desktop now records the admission id
of each of its submits the owner accepted to run, and the turn's stamped
message.complete marks the session unread when its admission is one of those
and the conversation is not on screen. The busy-to-idle edge still never lights
a messaging session, so no settle path can attribute one turn's completion to
another.
Refs #134889 (RT-18)
In Ink's interrupt and steer busy modes, text typed during a running turn
goes to session.redirect / session.steer, not prompt.submit. The typed
stop-phrase check only ran in prompt.submit, so a bare "stop" in a LOCAL
voice chat reached the running agent and voice chat stayed on.
The owner's correct() now makes the same _typed_voice_stop decision (attached
submitter, LOCAL session, voice on, bare stop phrase) after authorization and
before the generation fence, and answers {voice_stopped: true}. The canonical
CanonicalCorrectionResult becomes Ack | CanonicalVoiceStopped (generated TS
and OpenRPC regenerated); the dispatch test proves it through the real
contract parse.
Every client settles that reply instead of treating it as a refusal that
re-sends the text as a prompt (which would admit "stop" as a turn):
- Ink: settleControlReply renders the stop-phrase event and settles the
item; the running turn's busy state is untouched.
- Desktop: redirectPrompt drops the optimistic bubble and reports accepted,
so the composer does not re-queue it.
- ACP: /steer reports the stop-phrase notice and does not send a prompt.
Greptile review on #141508.
Refs #134889 (TUI-26)
Ink sends prompts to the canonical prompt.submit on the shared gateway, which
had no stop-phrase check. A bare stop phrase typed during a voice chat was
admitted as a turn and reached the agent. The spoken stop and push-to-talk
worked.
AuthorityConnection.submit now checks first, for a submitter attached to
the session whose gateway /voice mode is on. It reuses the sidecar's check
(methods_prompt._consume_typed_stop_phrase, split out of
_typed_stop_phrase_response) and _end_session_voice_chat, so the owner's
per-session mode goes off and that session's capture ends. Nothing is
admitted, and the reply is {voice_stopped: true}.
The canonical contract declares that shape: the prompt.submit result is now
AdmissionStatus | CanonicalVoiceStopped, and the shared artifacts are
regenerated. On a canonical reply, Ink settles the queue item, releases busy
and renders the existing voice.transcript stop_phrase event locally, so the
notice and mode flags are the ones the sidecar path already shows (existing
gatewayMsg.voice.stopPhrase string, no new text).
Refs #134889 (TUI-26)
The web TypeScript project includes apps/shared/src and enables
erasableSyntaxOnly, so `constructor(public url: string)` failed its
build with TS1294. Use an explicit field and assignment.