Skip to content

fix(title): T3 Code handoff and instruction wrappers no longer name sessions - #3

Closed
IEatCodeDaily wants to merge 10000 commits into
mainfrom
fix/title-skip-t3-handoff
Closed

IEatCodeDaily wants to merge 10000 commits into
mainfrom
fix/title-skip-t3-handoff

Conversation

@IEatCodeDaily

@IEatCodeDaily IEatCodeDaily commented Nov 12, 2026 •

Copy link
Copy Markdown
Owner

Problem. T3 Code threads driven by Hermes get titles like Context handoff (full_thread_summary):. T3 Code sends a delegated or handed-off thread's first prompt as Context handoff (...) + coverage + \n\nUser message:\n + the user's text, and wraps requests in <t3_code_instructions>…</t3_code_instructions><user_request>…</user_request>. agent/title_generator.py titled that verbatim, and acp_adapter/gateway_server.py (_rename_t3_thread) then mirrors the session title onto the T3 thread.

Fix. _summarize_user_message (the one input both the instant title and the model title use) now:

  • takes the <user_request> body when one is present, and
  • drops a leading T3 Context handoff ( header, keeping the text after the last User message: marker.

The test is in the existing tests/agent/test_title_generator.py and covers: wrapper only, handoff only, and handoff inside a wrapper.

The companion T3 Code change strips the same header on the T3 side: IEatCodeDaily/t3code#1

Base. Branched from bf0203a794, the upstream main commit the desktop install runs. Fork main is months behind upstream, so this PR also shows those upstream commits; the change itself is the single last commit (3786a91302, 2 files).

Gates. Upstream CI needs Blacksmith runners, which the fork doesn't have, so the full test suite and ruff check . run on GitHub-hosted runners from ci/title-skip-t3-handoff (this head + one workflow file). Alongside them runs a baseline on bf0203a794, so failures that already exist on the base can be told apart from ones this change causes.

Results (run 43136060375):

  • Tests: slices 2–4 pass. tests/agent/test_title_generator.py passes 99/99, including the 3 new cases.
  • Slice 1: one failure, tests/plugins/image_gen/test_openrouter_compat_provider.py::...test_catalog_models_are_offered_only_by_openrouter. The baseline run on bf0203a794 fails the same way, so it is not caused by this change.
  • ruff check .: one FURB192 finding in tests/gateway/test_native_http_auth.py:231. This PR does not touch that file.

Model: custom:9router:opus via Hermes (acpRegistry) in T3 Code

Teknium and others added 30 commits October 21, 2026 08:17
…ce lint

The rule could not tell a bundled library's own classes from patching a renderer global: it kept
failing legitimate bundles (three.js once minified to $-names) and still missed a bare $ receiver.
Prototype writes in a Desktop plugin are now read by the catalog reviewer instead; the policy in the
catalog docs stays, with a note that review checks it by hand. eval/new Function, non-SDK imports,
script injection and app-DOM reach are still linted.
…hing

A plugin whose pyproject.toml declares `dependencies = []` reached the
Python-dependency consent question with nothing to list. Non-interactive
`hermes plugins install --enable` refused it ("dependency install skipped
(non-interactive)"), and in a terminal a default answer left it installed
but disabled, so official plugins had to drop their pyproject.toml.

The gate keyed on workspace membership, which every pyproject has. Ask
only when admission would put packages in the shared environment
(`PythonDeclaration.installs_packages`): applicable dependencies, a
buildable plugin package, extras, or dependency groups (uv sync
--all-packages installs a member's default groups). The install gate,
the active-replacement veto at publication and the update re-consent
all read it. PM membership and requires-python handling are unchanged.

Consent-test fixtures that used an empty pyproject to stand for "declares
Python dependencies" now declare one; their assertions are unchanged.
…ousResearch#142565)

* fix(desktop): sync the approval-mode chip when the gateway opens

The statusbar approval chip seeds an optimistic 'smart' default and fetches
approvals.mode from a mount effect. The shell mounts before the gateway
WebSocket is open, so at every cold start the request rejects with "Hermes
gateway unavailable"; the swallow (.catch(() => undefined)) hides it and the
effect deps ([profile, requestGateway]) never change when the socket opens —
the chip sits on 'smart' for the whole session even when the configured mode
is 'off', correcting itself only when a later session event reconciles the
shared store.

Gate the sync on gatewayState === 'open' and include it in the effect deps so
it runs when the socket actually opens (and again after a reconnect) — the
same pattern useStatusSnapshot uses for its boot legs.

Test: with gatewayState="connecting" no request is issued and the chip shows
the pre-sync default; rerendering as "open" runs the sync and the chip settles
on the configured mode. Fails on base, passes with the fix.

* fix(desktop): only the active gateway's runtime sets the approval-mode chip

applyRuntimeInfo takes the owner scope of the runtime info, and the chip is
updated only when that owner is the active gateway (same profile, same
connection, a missing connection id counting as local). A bare profile owner
counts as active only while the primary or a connection-less socket is active.
All five callers pass their owner.

Salvaged from NousResearch#125174 onto current main, where the runtime side effects now
live in reportRuntimeInfoAppState; the owner is threaded through it.

Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>

* fix(desktop): address review nits on the approval-mode chip fixes

Reword the runtime-owner comment to name how it differs from the session.info
rule, and add the blank line eslint wants in the boot-sync test.

---------

Co-authored-by: Muntasim Ul Haque <106990977+muntasimulhaque@users.noreply.github.com>
Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
b1aa63b folded the pinned-input archive step into the `validate`
("Pre-build setup") job, which kept its 5-minute cap from when it only ran
admission. The step re-downloads every pinned input from R2 to verify its
sha256 (several GB: Chromium, llama.cpp CUDA). That takes ~20 s normally
(v0.22.3 rc.5), but v0.22.4 rc.1 hit slow R2 reads: both Windows candidate
chains were cancelled at 5:09 mid-verify, and every downstream job failed
"Release blocked: validate=cancelled". The standalone archive job it replaced
had 60 minutes; give the merged job 30.
…c7w-9gw2)

certifi 2026.10.19 drops the emSign roots whose intermediate CA keys
were compromised. It was published 2026-10-20, inside the 3-day
`exclude-newer` lock quarantine, and holding a CA revocation for the
quarantine keeps the compromised root trusted for those days.

CA trust stores therefore get the one per-package exemption
(`exclude-newer-package = { certifi = false }`). The packaging contract
test now allows exemptions only for CA-trust packages and only as a
full opt-out, so any other package or a dated exemption still fails.
The pm/AGENTS.md and package-management docs state the exemption.
…computer_use and browser follow into the Space; no core overrides)
…-2026.10.19

fix(deps): bump certifi 2026.5.20 → 2026.10.19 (critical, GHSA-6q48-6c7w-9gw2)
…3-v3hh)

The website override pinned js-yaml to 4.3.1; it moves to 4.3.2, which
matches the root override.
)

Also fixes GHSA-4vpr-x523-8j87. Transitive via @svgr/plugin-svgo and
postcss-svgo.
vite 8.2.4 bundles launch-editor fixes for GHSA-587h-7wmj-h5pm,
GHSA-r825-w8mm-5pxw and GHSA-698v-4r3x-3vcv. Bumped in all three
workspaces that pin it (web, apps/desktop, apps/bootstrap-installer),
leaving one hoisted copy.

Forced by vite 8.2.4's ranges:
- rolldown ~1.2.4: 1.2.1 → 1.2.13 (@oxc-project/types =0.153.0)
- postcss ^8.5.26: root override 8.5.23 → 8.5.29; website lock moved
  to the same 8.5.29

The .npmrc age-gate excludes for vite/rolldown/@rolldown/*/@oxc-project
were marked "remove once vite 8.2.0 is > 2wks old" (it is 82 days) and
let a same-day rolldown 1.2.15 into the lock, so they are removed; every
version above is past the 14-day gate. The website postcss exclude
("remove when 8.5.23 is > 2wks old") is removed for the same reason.
…6-jgq8)

Also fixes GHSA-8r9p-f939-6h3c. Transitive (axios in the root lock,
http-proxy in website); bumped in both locks.
Also fixes GHSA-73wf-gq98-2v4g. Website 4.28.6 → 4.29.3, root 4.29.0 →
4.29.3, so both locks match. Forced by 4.29.3's ranges, with the same
versions in both locks: baseline-browser-mapping 2.11.27 (also fixes
website GHSA-w5vr-8v7q-w6rv), caniuse-lite 1.0.30001815,
electron-to-chromium 1.5.450, node-releases 2.0.57 and
update-browserslist-db 1.3.3.
…-gh2g)

Also fixes GHSA-x5fp-wj9c-mxmx. express and body-parser pinned qs
~6.15.1, so they move to the first releases on ~6.16.0: express
4.22.2 → 4.22.3, body-parser 1.20.6 → 1.20.8.
3.14.4 fixes GHSA-9xw5-w5jj-pvcg, GHSA-2g87-pp6c-29x5,
GHSA-5wr8-gcqf-gqf3, GHSA-f9xw-jh8m-3mwq, GHSA-pqxq-w44r-73xw,
GHSA-6cjc-53gc-5rqm, GHSA-gx76-c99x-2c96 and GHSA-494j-54xm-rwp3.
3.14.5 is the latest patch release and is past the 3-day lock
quarantine.

All five exact pins (messaging, slack, matrix, sms and teams extras)
move together with their advisory comments. aiohttp 3.14.5
requires yarl>=1.25.1, so yarl 1.24.5 → 1.25.1 in the lock.
…_task fake

The drain test patches asyncio.create_task process-wide to delay the
/v1/runs task. aiohttp 3.14.4+ schedules its own client and connector
tasks through asyncio.create_task(..., eager_start=True), so the fake
failed with "unexpected keyword argument 'eager_start'". Accepting
**kwargs alone is not enough: the fake would then hold aiohttp's
connect task behind the same gate and client.post would never return.

The fake now delays only the run coroutine and passes every other task
(kwargs included) straight to the real create_task.
…-3.14.5

fix(deps): bump aiohttp 3.14.3 → 3.14.5 (high, GHSA-9xw5-w5jj-pvcg)
…editor-2.14.2

fix(deps): bump launch-editor 2.14.1 → 2.14.2 in website (high, GHSA-587h-7wmj-h5pm)
…-4.3.2

fix(deps): bump js-yaml 4.3.1 → 4.3.2 in website (high, GHSA-2883-xcg3-v3hh)
…2.10.0

fix(deps): bump colord 2.9.3 → 2.10.0 in website (medium, GHSA-2wm5-q62r-hmrv)
fix(deps): bump svgo 3.3.4 → 3.3.5 in website (high, GHSA-w27v-7q3p-w38r)
…redirects-1.16.1

fix(deps): bump follow-redirects 1.16.0 → 1.16.1 (high, GHSA-q3jh-wrc6-jgq8)
…slist-4.29.3

fix(deps): bump browserslist → 4.29.3 (high, GHSA-c83g-rgw3-j3cx)
fix(deps): bump qs 6.15.3 → 6.16.0 in website (medium, GHSA-4mjr-xmp4-gh2g)
…0.8.15-0.9.12

fix(deps): bump @xmldom/xmldom 0.8.13 → 0.8.15, 0.9.10 → 0.9.12 (high)
MCP servers previously saw the python SDK's default clientInfo
("mcp"/"0.1.0") instead of who is actually connecting. Send
Implementation(name="hermes-agent", version=<shipped version>) in
ClientSession kwargs so server logs, policy engines, and dashboards
attribute sessions correctly — the same identity fix GitHub Copilot
CLI shipped in v1.0.84-2 ("MCP servers now see the same client
identity ... carrying the shipped CLI version instead of 0.0.0").

Salvaged from NousResearch#70074 (yulunz); relocated from the pre-decomposition
tools/mcp_tool.py onto the transport sibling's _session_kwargs(),
the single seam all three transports (stdio, streamable HTTP, SSE)
build their ClientSession from, and gated on the SDK actually
accepting client_info.
Maciej Lisiewski and others added 28 commits October 23, 2026 00:47
…lemetry-1.45.1

chore(deps): bump opentelemetry to 1.45.1 and mistralai to 2.10.1
NousResearch#139099)

The source check offered v0.22.2 as "ready to install" to installs whose
session stores already use the gateway runtime, and `hermes update` then
refused it as a downgrade every time. The rollback test only caught a
release that is an ancestor of HEAD; a release cut beside the checkout's
history (sene1337's report on NousResearch#139099) slipped through as a plain update.

The check now asks the updater's own refusal, read-only and without lazy
fetches (update_downgrade_guard.would_refuse, sharing downgrade_refusal's
profile scan and target test). When the update would refuse, the status
says updateAvailable=False with held="downgrade":
- Desktop names the hold ("Hermes stays on this build until a newer stable
  release ships") instead of an Update now button that always fails.
- `hermes update --check` prints "Holding at this build" instead of
  "Selected release available".

channelProtocol moves to 2 so cached statuses computed without the hold are
re-checked. The refusal in `hermes update` itself is unchanged.
…untime

A stable-channel install on a post-cutover main commit could never update:
the channel's release predates the gateway runtime its state.db already
uses, so every `hermes update` hit the downgrade refusal and exited 1,
though the user never asked to go back (NousResearch#140729).

When the channel (not an explicit --branch) picked that release, keep the
install on HEAD, say it is ahead of the release and how to follow main,
and finish like an up-to-date run. An explicit --branch to a pre-runtime
commit is still refused.

(cherry picked from commit 6127cdc)
…shapes

The salvaged hold is pinned on a preview channel. These cases pin it on
the shapes NousResearch#140729 and Desktop actually reach, through the real
_cmd_update_impl against real git and a state.db with runtime ledger rows:

- an unconfigured install whose default channel is stable, on a main
  commit beside the release (neither contains the other): plain
  `hermes update --yes`, and the argv scripts/desktop-update/posix.sh
  runs (`--yes --keep-stash --channel stable`);
- an install whose channel is recorded as stable (Desktop's Settings
  selector, `--set-channel stable`) on a checkout that contains the
  release, where forward-only never applies.

Each holds HEAD, prints the ahead-of-release notice instead of the
refusal, and completes like an up-to-date run; all four fail on the base
with SystemExit(1) at the downgrade refusal. --branch to the same commit
stays refused (covered by the salvaged test).

Refs NousResearch#134889 (GW-19)
Refs NousResearch#140762
Refs NousResearch#140729
…s held

Review follow-up on NousResearch#140741, carried onto NousResearch#140778: the release can sit
beside HEAD's line rather than behind it, so "ahead of" claimed more
than the guard proves; use "newer than". The run no longer closes with
"Already up to date!" under the hold notice: _finish_already_up_to_date
takes a held message. Drop the redundant expected_sha pin (the
up-to-date path recaptures HEAD). _assert_held_at_head now checks the
new wording and the held closing line.

(cherry picked from commit 0dc55e8)
target_predates_runtime greps a release tree for _RUNTIME_SCHEMA_MARKER. If
the DDL in hermes_state*.py drifts from that literal, every later forward
release reads as pre-runtime; with the channel hold that becomes a silent
pin instead of a loud refusal, so fail CI on the drift instead.

(cherry picked from commit 0241a85)
…target

update_cmd.py sat at ~2000 lines after the hold landed. Move
_hold_channel_release_behind_runtime unchanged into update_cmd_check as
hold_channel_release_behind_runtime, beside the channel target it acts on.
Facade helpers (_m, _capture_head_sha) are read through _uc() at call
time like the rest of that module, and downgrade_refusal is still
imported per call, so no import cycle and existing patches stay
effective. No behaviour change.

Refs NousResearch#134889 (GW-19)
Refs NousResearch#140762
Refs NousResearch#140729
… not

Review follow-up for the consolidated hold (NousResearch#140215 + NousResearch#140778), closing
the three open findings on NousResearch#140215:

- `hermes update --check` asked the rollback question before the refusal,
  so a release this checkout already contains still printed "`hermes
  update` moves it back to that release" while the update itself holds.
  The refusal now comes first; the copy no longer says "move back", since
  the update holds rather than refuses.
- source_check cached its verdict for 24h with the hold baked in, so an
  offer cached before the first runtime turn stayed on offer after it.
  The hold is now re-asked on cached channel results too (a local git +
  sqlite probe, no network).
- The dashboard /update/check endpoint turned the hold's behind=0 into
  "You're on the latest version." and dropped it, and Desktop's backend
  card returned its generic latest line before looking at `held`. The
  endpoint now carries `held` + the release version, mapBackendCheck keeps
  them, and the held line wins over the backend fallback.
The hold asked the release tree, via local git, whether it defines the
gateway runtime. An install that tracks only `main` never has a release
cut beside it until `hermes update` fetches it, so `cat-file -e` failed,
the guard's "a git failure proves nothing" rule answered "offer", and
Desktop showed v0.22.2 as ready to install for exactly the installs
NousResearch#139099 is about (DavidMetcalfe's review on NousResearch#140215).

target_predates_runtime now takes an optional `remote` answer for a
target that is not local. The check surfaces (source_check, `hermes
update --check`) pass one that lists the release's root tree from the
GitHub API and reads its hermes_state*.py from raw.githubusercontent.com
(no API quota). Both backends share the marker and the module pattern
(STATE_MODULES, defines_runtime), so they cannot drift apart. The guard
stays the single place that decides.

- Only consulted when a store has runtime history and the commit is
  absent: everyone else makes no extra request.
- A definite verdict is cached per sha for good (a commit's tree never
  changes) in source-checks/release-runtime.json.
- A truncated tree, an unreadable module, or any network failure answers
  None and keeps the old behaviour (offered).
- `hermes update` itself is unchanged: it fetches the release first and
  stays git-only.

Co-authored-by: DavidMetcalfe <80915+DavidMetcalfe@users.noreply.github.com>
Check that a release's schema objects are present before trusting a local
no-match, and use the remote reader when the local probe cannot finish.
Cache release facts before applying the live store-dependent hold, so
removing the only ledgered profile makes an allowed release visible again.

Read independent schema blobs four at a time with one lookup deadline.
Daemon workers cannot hold a version command open after that deadline;
unknown verdicts expire instead of retrying GitHub on every cache hit.

Check marker drift against imported SCHEMA_SQL rather than source text,
and keep dashboard hold and rollback copy readable without a version.
Keep version-only release commits in the path-limited completeness probe
with --sparse. Git 2.53 otherwise simplifies them away, then mistakes an
unreadable schema blob for an absent runtime marker. Cover inherited
pre-runtime and runtime schemas in real blobless clones.

Clear rollback when projecting a live hold so --version cannot promise a
move the updater will not make. Extend the store/cache regression to
in-history releases and exercise the real version renderer; removing the
only ledgered store still restores the cached rollback offer.

Verified the ten affected test files on Git 2.53.0 and 2.55.0: each run
passed 218 tests with one platform skip. Both regressions failed before
their respective production fixes.
On Windows without distlib in the runtime environment, a repin of PM's Python
republishes the source launcher as a .cmd and must retire the old hermes.exe
so it cannot shadow it. stage_launcher deleted it, but the exe it has to
retire is usually the one running `hermes update` (or the launch retrying
the owed step), and Windows refuses to delete a mapped image. The publication
failed, the update owed 'launchers', so the install stamp and bootstrap
receipt were withheld at the old commit and the old exe kept booting the old
interpreter. Every retry ran from that same exe and failed the same way.

Windows does allow renaming a running image, so retire it by renaming it
aside when the delete fails (distlib's .deleteme move), and collect the aside
copy on a later publication once its process has exited.

Seen in the v0.22.5 rc.1 install E2E: installer-script -> hermes-update
(v0.22.4 -> HEAD) on Windows, where pm/lock.json moved Python 3.14.7 ->
3.14.9 (e31fa4c): "Update follow-up 'launchers' did not finish: source
launcher publication failed", then verify-stamp "pinnedCommit 4d04303
!= installed HEAD 7c0905a".
Review follow-up: the sweep of `hermes.exe.<pid>.deleteme` copies ran only
when the publication fell back to a .cmd launcher, so a later publication
with distlib available (an .exe) left the aside copy behind forever. The
sweep now runs on every successful publication. The running-launcher test
bounds its readiness wait, keeps the child's whole lifetime inside the
try/finally (kill + reap on a stuck shutdown), and republishes with distlib
restored to pin the collection.
…usResearch#143234)

* feat(auth): hand the free tier's apps to the account that replaces it

When a Nous account is saved over a free-tier identity, Hermes now moves
that identity's connected apps into the account with one call to the Nous
Portal claim endpoint, authorized by the account's own token. Nothing asks
first; the result (account email, what moved, what needs reconnecting) is
shown afterwards in `hermes auth status nous` and exposed for the desktop.

Sign-in code does not know about it. A hook at the two lowest write seams
(`_store_provider_state`, `_write_shared_nous_state`) writes a pending
record under the existing locks and does no network. The claim runs
outside every lock: after a shared-store write, when a `deferred()` block
exits (the desktop poller and `run_sign_in` save under their own lock),
after a free-tier reconcile, and on each Nous keepalive tick, which
resumes retries from an earlier process.

Records sit beside the shared Nous store, keyed by a hash of the free-tier
credential and bound to the destination account, so profiles sharing one
free-tier identity share one record and one claim. A claim lock spans
read, claim and write. A Claimed record is never reopened; a lost success
followed by an unknown-credential answer reads as "probably moved". The
account token is refreshed after the move only when the cohort carried.
The same path runs `settle_after_upgrade` once per replacement, so every
replacing writer settles the config.

The legacy promotion sign-in already moves the apps on the server, so it
records the handoff as done and sends no claim.

* fix(auth): the free-tier handoff only ever claims into its bound account

Review round 1 on the guest handoff.

P1, Bearer not proven to be the destination: the runner checked the
profile's account against the record's destination, then resolved a
Bearer separately. A sign-in landing between the two could move the apps
into that other account and mark the record Claimed. The Bearer is now
resolved first and its JWT subject is compared to the record's
destination inside the reservation, so only a token proven to belong to
the destination is ever sent.

P1, stale reservation: the destination check and the attempt increment
were separate record reads, so a sibling could rebind the destination in
between. `_reserve` now does one locked read-modify-write: still active,
due (`next_attempt_at`, so a waiting runner honours a Retry-After another
runner saved), under `MAX_ATTEMPTS` (a crash after the last send now
closes the record as Failed instead of sending again), and bound to the
Bearer's subject. A record without a destination subject is never written.

P2, config completion in the runner: `drain` no longer runs
`settle_after_upgrade`. Settling stays with the sign-in that saved the
account (run_sign_in, the desktop poller), as before this PR. This
deletes the thread-local queue and the batch's settle fallback.

P2, keepalive: a handoff failure is logged and token renewal carries on.
An unparseable records file is moved aside to .corrupt on the next write
instead of blocking every Nous account save.

P2, budget: the remaining sync budget is checked before each record,
token resolve and request, and is passed as their timeout, so a refusal
at the edge starts no refresh or second claim.

P2, status: an active move bound to an account the profile no longer
holds reads as paused, with copy that says so.

* fix(auth): free-tier handoff retries reach every profile and every long-lived process

Review round 2 on the guest handoff.

P1, queued moves had no worker: retries rode the token keepalive, which
does not start in a process that boots with a guest (a Desktop backend
before sign-in). A move left by a CLI sign-in during an outage then
waited for a restart. Retries now run on a dedicated worker that
`start_nous_auth_keepalive` starts in every long-lived Nous process,
guest or not; the keepalive tick no longer drains.

P1, named profiles never retried: the keepalive drained only the launch
profile's records. Each worker tick now runs every due record under its
owning profile's home and secret scope (the way the cron ticker binds a
profile), so a named profile's move retries from a process launched as
another profile. The bound-account check and the Bearer proof in the
reservation still decide whether anything is sent.

P2, cached guest token: the resolver's 5 s memo could still hold the
guest's token from a connector call just before the save; the
reservation then refused its subject and the move waited for a later
run. The handoff now drops that memo before reading the account token.

P2, budget through locks: the token read waited the resolver's own
15 s floors for the auth and shared locks. The handoff now takes both
locks itself within the remaining budget (same order), which makes the
resolver's acquisitions reentrant, and re-checks the budget before
reserving or sending.

* fix(auth): free-tier handoff never gives up on a transient failure before the reap

Compatibility audit, items B1, B4 and B5.

B1: a handoff that reached Failed was never retried, so the free tier's
apps were lost when the idle free-tier identity was reaped after 14
days. Transient failures (5xx, the 503 breaker with no Retry-After,
timeouts, busy, unknown codes) became final after 8 attempts, about 81
minutes. Now only codes no later attempt can change are final
(client_not_allowed, account_locked, invalid_request). Every other
outcome retries, the slowest rung (1 h) repeating, until 13 days after
the record was made. The attempt reservation then closes it as Failed
`retry_window_ended`. One predicate (`_retryable`) picks the work for
every run, so a record an older build failed on a transient code re-arms
on the next sign-in, `hermes auth status nous`, `hermes auth upgrade`, or
retry-worker tick, with no new state. The attempt cap is deleted.

B4: failure copy printed raw server codes. Final codes map to a plain
sentence, and anything else shows only the generic sentence.

B5: a claim answer without a `connectors` field (an older service) read
as "There were no free-tier apps to move", and so did Gone on a first
attempt, which follows a released ticket that already moved the apps.
The first now reads as unknown with neutral copy, and the second as
"Nothing left to move from the free tier."

* fix(auth): a free tier with no apps reads as nothing moved after sign-in

A free tier that only read its connectors gets a vendor id row on the
service, so the claim answers kind `moved` (or `swapped`) with an empty
`moved_toolkits`. The status copy keyed on the kind and printed "Your
apps moved to <email>." for an account that received nothing.

One rule: an empty moved list reads as nothing moved, for moved and
swapped alike. The service now refuses a move it could not read, so an
empty list is the truth. The promotion sign-in's record, which reports
no list at all, now carries kind `unknown` and keeps its neutral copy
instead of falling under the rule.
…h#143235)

* feat(auth): every Nous sign-in is the plain device-code login

Every Nous sign-in (desktop, CLI and TUI /login, gateway /login,
`hermes auth upgrade`) now runs one plain device-code login,
`anon_sign_in.run_sign_in`: device code, a cancel-aware token poll,
save. The free tier the account replaces hands its apps over in the
existing guest handoff step after the save, and every surface prints
what moved and what to reconnect right after "Signed in as ...".

The promotion intent/status calls, the consent page link and the
`cancel_wins_after_promotion` rule are gone, with the Declined,
Superseded, Retired and Unavailable states. A cancel is now one
`Cancelled` state and always wins: nothing is saved.

- The token poll takes a `cancelled` hook and checks it on a <= 1 s
  tick, so a desktop cancel or a newer /login stops it at once.
- An expired code maps to `TimedOut` (`reason: timeout` on the desktop).
- The account email is read from the access token's `email` claim.
- TLS goes through `_resolve_verify`, so the CA bundle settings apply.
- /login works with no free tier on disk.
- The desktop start route always uses the flow (`replace_account=True`
  keeps "connect another account" working) and the poll route returns
  the handoff lines.
- `hermes model` signs a connector-only free tier in instead of
  refusing; `_login_nous` prints the handoff result too.
- Every sign-in that saves an account settles the config itself once
  its locks are released (`run_sign_in`, `_login_nous`, `hermes auth add
  nous`, the billing step-up, the tool-setup login); the handoff runner
  only moves apps.

* fix(auth): CI round 1 on the plain sign-in

- The docs page wrote "<= 1 s", which MDX parses as a JSX tag; say
  "at least once a second" instead.
- The shared Retry-After parser test still imported the promotion
  status poller's helper, which this PR deletes; drop that leg.

* fix(auth): Greptile round 1 on the plain sign-in

- Read the Portal URL inside the profile scope: with no Nous state on
  disk, a multi-profile backend read the override after the scope had
  exited and signed in against the default Portal.
- An expired_token answer from the token endpoint is TimedOut (the
  desktop's timeout screen), the same as running out the clock.

* fix(auth): Greptile round 2 on the plain sign-in

`hermes auth add nous` and the tool-setup Nous login now capture the
free tier's handoff around their save and print what moved and what to
reconnect, the same as the terminal login does.

* fix(auth): Greptile round 3 on the plain sign-in

- The Portal is resolved through the same rule every other Nous call
  uses: the operator's override wins over a URL saved in the auth store,
  so a staging override no longer signs a desktop "connect another
  account" into production.
- Gateway /login holds its registry lock across the final cancel check
  and the save, so a replacing /login cannot land between them and the
  replaced attempt never saves.

* fix(auth): Greptile round 4 on the plain sign-in

Gateway /login no longer holds its registry lock across the save. The
final cancel check claims the save under the lock, then the save runs
outside it: the event loop takes that lock, and a save can wait on the
auth store. A replacing /login while the save runs is told a sign-in is
finishing instead of cancelling it.
…sResearch#143236)

* feat(desktop): sign-in dialog shows who signed in and what moved

The free-tier sign-in dialog now reads "Finish in your browser" with the
device code shown small ("Do not share it.") and an "Open page again"
button. Once the browser approves, the result screen sets the account
email large under "Signed in as", then says what the replaced free
tier's apps did, in the same lines every other sign-in surface prints.
A Reconnect button opens the app that has to be reconnected; a move
still retrying is a small note that updates itself.

- The sign-in poll is a long-poll (`?wait=`): the backend answers once
  the session leaves pending, capped at 25 s. The desktop never asks
  again sooner than 2 s, so an older backend that answers at once is
  not hammered. The onboarding picker uses the same loop, and a Nous
  sign-in there that moved apps opens the same result screen.
- The poll reports `handoff_status`: the handoff record as fields plus
  its copy (`guest_handoff.wire_status`).
- New `GET /api/providers/oauth/nous/handoff?since=&wait=`
  (token-protected, profile-scoped) long-polls the handoff record so a
  retrying move is followed until it settles or the dialog closes.
- The copy and failure screens for the retired transfer outcomes
  (declined, superseded, retired) are gone.

* test(auth): sign-in copy check builds Completed with its handoff_status

The every-state copy check builds each SignInState from a placeholder
per dataclass field; Completed gained handoff_status in the parent
commit, so give it a real wire_status value.

* fix(desktop): Greptile round 1 on the sign-in result screen

- Onboarding paints above every dialog, so a result from the onboarding
  provider list now waits until onboarding leaves the screen.
- The result keeps the profile the sign-in ran for, and Reconnect names
  that profile (and connection) in the Connectors link, like a chat's
  connector links do.
- A sign-in whose own run attempted no move (another process held the
  claim, or the budget ran out) reports the replaced free tier's saved
  record instead of no handoff (`guest_handoff.record_status`).
- A failed handoff read retries with backoff (4, 8, 16, 32 s) before the
  result stops following the move.
- de, es, fr and zh get the new dialog strings.
…sResearch#143248)

* 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.
With a cold desktop-inputs cache, Windows desktop preparation builds sdists
that have no cp314 Windows wheel (obstore on win32-x64, dependency-injector on
win32-arm64). uv builds each one in
<cache>\python\runtime\<toolchain id>\sdists-v10\pypi\<pkg>\<ver>\<id>\src,
about 200 characters below the deep .cache\desktop-inputs root, so MSVC
link.exe outputs passed MAX_PATH and the build failed with LNK1104.

On Windows, prepare_tools now hands uv a short job-local junction
(<work>\uv) that points at the same transported cache directory. uv keeps the
spelling it is given, so builds run under the short path while the bytes are
still restored and saved by the desktop-inputs cache. The native toolchain id
is read through the junction, and prepare_native keeps the absolute spelling
instead of resolving the junction back to the long path.

CARGO_TARGET_DIR is also owned by the preparation environment and points at
<work>\cargo, so Rust sdists link under the work root on every target even
if a tool resolves the junction.
Review follow-up: short_alias now creates the junction's parent directory,
so preparation never depends on the caller having made the work dir first
(the existing prepare_tools test failed on both Windows lanes with WinError 3).
That test now checks the invariant the Windows path keeps: UV_CACHE_DIR
reaches the cache directory, through the short work-local junction on Windows.
07ee800 relocked with newest uv, which writes `revision = 5` and a
`default-groups` attribute on the root package. The pinned uv2nix
(5a836d3, July) rejects that attribute, so `nix flake check` fails at
evaluation: "function 'anonymous lambda' called with unexpected argument
'default-groups'" (v0.22.5 rc.2 release run 39880455146).

uv2nix accepted the attribute upstream in e3dc0e4 ("lock1: accept
default-groups attribute introduced in uv 0.12.22"); pin to its follow-up
7aba73a (the regression test for it). Only the uv2nix lock node moves.
test_malformed_secondary_cannot_evict_recorded_member failed in CI with
`uv sync` "Hash mismatch for member-dep @ file://.../member_dep-1.0-py3-none-any.whl".

Mechanism: installing_member() rebuilds member_dep-1.0 into the same
wheels/ dir for the second plugin. _wheel used ZipFile.writestr(name, ...),
which stamps the wall clock (2-second zip resolution), so a rebuild that
crosses a tick has different bytes. The final sync seeds from home A's
recorded lock (pm/packages.py: seed = prior resolved_lock), and uv 0.13.5
keeps that lock's sha256 for an unchanged path-wheel version (uv 0.12.13
re-hashed it), so sync then verifies the new file against the old hash.
The test runs in ~2 s alone, so it passed whenever both builds fell in
one tick; CI load pushed it across.

Not caused by 07ee800's relock: 7c0905a (rc.1) and 07ee800
fail identically under the injection below.

Repro: a 2.1 s sleep before the second installing_member() call, with
uv 0.13.5 first on PATH, fails base on every run (3/3) and passes with
this fixture (3/3); uv 0.12.13 passes on base too.

Fix: fixed ZipInfo date_time and mode, so a same name/version wheel is
one artifact, as on an index. test_fixture_wheels pins it (clock moved
an hour between builds -> identical bytes; red on base).
…nd reports why a launch dies

Every Windows x64 MSIX chat smoke since the Blacksmith migration (91fae5f)
failed: first on the GitHub-hosted guard, and since that guard was removed
(272adc7) with Playwright's "Process failed to launch!", on Electron 40 and
44 alike. The same builds' arm64 MSIX, on a GitHub-hosted runner, chats; the
last green x64 smoke (Oct 14) ran on windows-latest-32-core. Move the x64 leg
back there, as f1b290e did for the install/update GUI legs.

The failure itself carried no evidence: when the app exits before its debugger
line, Playwright's Electron.launch leaves sibling stderr-watcher rejections
unhandled, Node dies on them, and the smoke's catch never writes its receipt or
Playwright's call log. The driver now logs those rejections (still failing the
run), so the receipt records the app's exit code and stderr, and the Windows
smoke saves Application-log errors from the run on failure.
On Windows a launch activates only the dependency environment, which has no
distlib (a test extra) and drops the store interpreter's site-packages. So
the `hermes update` that repins PM's Python found no ScriptMaker, published
bin\hermes.cmd and -- since NousResearch#143291 taught it to rename a running image
aside -- retired bin\hermes.exe. The install was left with no hermes.exe,
which Desktop's backend sweep (taskkill /IM hermes.exe), users' shortcuts and
terminal profiles, and the install E2E all name.

The installer mints the first hermes.exe through python-build-standalone's
bundled pip (pip._vendor.distlib). Every store Python ships that pip, so when
the running process cannot import distlib, mint_launcher now loads the
vendored distlib from beside the interpreter it is binding, under a private
module name so the store's pip never answers `import pip` here. The .cmd
fallback remains for an interpreter with no distlib at all. The retired-copy
sweep also collects distlib's own `hermes.exe.deleteme`, which its in-use
replacement leaves when the update's own exe is the one being replaced.

Seen in the v0.22.5 rc.2 install E2E (v0.22.0 -> a4072ce, Python
3.14.7 -> 3.14.9): test_upgrade_over_old_gateway.py "installer published no
...\hermes\bin\hermes.exe".
…essions

T3 Code delivers a thread handoff as 'Context handoff (...)' ... 'User message:' and wraps
requests in <t3_code_instructions>/<user_request>. Both were titled verbatim, and
gateway_server mirrors the title onto the T3 thread.

Model: custom:9router:opus via Hermes (acpRegistry) in T3 Code
@IEatCodeDaily

ghost commented Nov 14, 2026

Copy link
Copy Markdown
Owner Author

Closing without merge: the branch is based on upstream bf0203a, so merging would drag ~45k upstream commits into fork main. The fix (3786a91) is hand-installed on Talos; branch fix/title-skip-t3-handoff is kept as its source.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.