Skip to content

Sync upstream hermes-agent @ acc614e72 (CONFLICTS) - #334

Draft
hermes-upstream-sync[bot] wants to merge 1990 commits into
mainfrom
upstream-sync/2026-08-18-acc614e72
Draft

Sync upstream hermes-agent @ acc614e72 (CONFLICTS)#334
hermes-upstream-sync[bot] wants to merge 1990 commits into
mainfrom
upstream-sync/2026-08-18-acc614e72

Conversation

@hermes-upstream-sync

Copy link
Copy Markdown
Contributor

Conflicts detected. Resolve manually before marking ready.

Files with conflict markers:

cron/scheduler.py
gateway/platforms/base.py
hermes_cli/commands.py
hermes_cli/config_defaults.py
hermes_state.py
tests/hermes_cli/test_skills_hub.py
tools/send_message_tool.py
tools/skill_manager_tool.py

Brings in upstream commits up to acc614e72 (upstream/main).

Generated by ops/hermes-upstream-sync. Branch protection on main
enforces that the agent cannot self-merge — review and merge manually.

acc614e72 docs: add CLI symbols glossary reference page
e624e9fde chore: map contributor email for #88864 salvage
c381aef16 fix(bot-mode): fresh-room name uniquify counts every membership, not just the legacy scalar
7af25a92b test(bot-mode): favor behavior over source assertions
10d6faade test(bot-mode): verify shared-member room isolation
9bea43918 feat(bot-mode): support multiple groups per bot
7e05e9080 chore: release v0.20.4 (2026.8.18)
ecc7a354f fmt(js): `npm run fix` on merge (#88981)
d4a72b7d7 chore: map contributor email for #88807 salvage
4bb7e4913 fix(desktop): preserve cross-machine group routing
b95ec1cb5 fix(delegation): running subagents stay visible to list/steer across parent-agent rebuilds, and child-started process notifications carry delegation attribution
0bb239990 fix(tests): sibling hangup-protection tests tolerate the branch+HEAD suffix
8ce8ffd42 fix(update): 'hermes update' no longer claims success on a parked feature branch — switches back when safe, warns loudly when not
a943895f2 test(desktop): prove the install_id collapse (#88828) and boot-descriptor connectionId (salvage #88697) compose without re-appending the twin-address primary
7e156c6d2 fix(desktop): preserve primary remote connection identity
cb337d39b fix: Bot Mode cronjobs panel no longer shows two empty-state markers
e9cefa4d7 feat(desktop): pre-select glass on macOS
7a81dd9ef refactor(desktop): trim the glass surface
a1fca9b62 fix(desktop): close the glass state and perf leaks
6eb23c79d fix(desktop): clear-mode fade tracks the drag again
f3bfa9ae5 perf(desktop): stop the translucency slider thrashing the main process
b86db1410 test(desktop): the glass assertions were never running
efa15b5eb feat(desktop): frost picker, sidebar glass, full-range tint and peek
113a07f29 refactor(desktop): one surface helper for the three chat windows
0ae0641ce refactor(desktop): surfaces declare their glass role
5851f9367 refactor(desktop): one mac check, one translucency atom
d6adef699 refactor(desktop): one owner for the translucency mapping
985db37ca fix(desktop): glass cold launch — omit backgroundColor instead of alpha-0
1eb9cbdb7 fix(desktop): glass was blocked by the webContents backing and two app-shell painters
048313384 fix(desktop): even glass field via one painter; raise overlays; darken clear scrim
eb6f352be feat(desktop): matte glass option for window translucency
374779dae fix(desktop): curve window translucency so the whole lever is usable
62beeeef8 fix(desktop): finer window translucency steps
6680afba4 fix(nix): exclude test js files from src
30fcf9580 fix(bot-mode): group chats no longer lose long-running member turns, remade groups start fresh (Discord bot-mode feedback)
bdc9a810f fmt(js): `npm run fix` on merge (#88888)
898b42363 fix(desktop): force the enforced zone's tab strip visible — repairs the "only Bots shows, Sessions unreachable" regression
47eadccdc fix(desktop): SESSIONS | BOTS is always a tab strip — the Bots pane re-homes beside Sessions on every boot, no heal guards
f30ea0eb9 Revert "chore: nudge ci.yml blob to bust poisoned workflow-parse cache"
f6667e7bf chore: nudge ci.yml blob to bust poisoned workflow-parse cache
6170f844c fix(desktop): remove profile scoping from the Gateways settings page
c9ce66e25 fix(desktop): one roster row per bot even when a backend is registered under two addresses — /api/status install_id + roster collapse
d127b2730 docs(desktop): document the tabbed SESSIONS|BOTS sidebar, Bots-mode-only Cronjobs pane, per-bot Hide/Unhide, and host.paneVisibility (#88788, #88800)
e818025b4 Merge pull request #85582 from bbednarski9/codex/fix-relay-lazy-completed-streams
75bbc055a Merge pull request #85579 from bbednarski9/codex/fix-relay-canonical-operation-names
41a80d525 fmt(js): `npm run fix` on merge (#88818)
a3995f8ae fmt(js): `npm run fix` on merge (#88813)
133381508 feat(bot-mode): hide bots from the roster via right-click — header eye toggle reveals and unhides them
bc76f62c2 feat(cron): configurable media-send timeout + non-empty failure reasons
d9ec9dd3f feat(cron): make the media-send timeout configurable

kshitijk4poor and others added 30 commits August 17, 2026 13:19
…sers

Two data-loss bugs reported by users:

1. /handoff CLI→gateway race (#88234): After /handoff completed, CLI
   cleanup called finalize_session on the session the gateway just
   reopened. This set end_reason on a row the gateway was actively
   writing to, causing the handoff leg to vanish from session history
   and breaking session_search recall. Fix: add _handed_off_session_ids
   module-level set (mirrors _single_query_finalize_attempted_session_ids
   pattern). _handle_handoff_command registers the session_id on
   completion; _should_emit_cleanup_session_finalize and
   _emit_interrupted_session_end check it before firing.

2. state.db corruption silent failure (#88235): When SessionDB init
   failed at gateway startup, the error stayed in logs — messages
   flowed but nothing was persisted, with no user-visible indication.
   Fix: store _session_db_init_error on GatewayRunner, broadcast a
   recovery-guidance message to all home channels via
   _send_session_db_warning_notifications() after the gateway connects.
   Also improved the 'corrupt' persistence cause wording in
   _format_turn_completion_explanation to include the full recovery
   path (hermes doctor --fix, sqlite3 .recover, backups).

Tests: 6 new tests for handoff cleanup race, 3 for corruption wording.
All existing CLI/turn-completion tests pass.
/simplify-code review found _notify_single_query_session_finalize was
missing the _handed_off_session_ids guard that _should_emit_cleanup_session_finalize
and _emit_interrupted_session_end already had. One-shot CLI queries that
somehow handed off would still finalize the session via this path.

Added guard + test.
_read_journal_mode opened each Hermes database with a bare open(db_path,
"rb") to read header byte 18. The read itself is harmless; the close() is
not. Per sqlite.org/howtocorrupt.html, close() on *any* descriptor for a
file cancels every POSIX advisory lock this process holds on it — so the
close at the end of that with-block drops the locks a live connection is
holding, including the EXCLUSIVE lock a VACUUM holds while it rewrites the
whole file. Another process is then free to write into a file its writer
still believes it owns, which is the documented route to "database disk
image is malformed".

This is reachable. run_doctor is not only a standalone CLI process: the
dashboard console registers "doctor" (console_engine.py:570) and calls
run_doctor directly, in-process (console_engine.py:1297), on the web
server's console thread pool — in a process that holds live SessionDB
connections (web_server.py:11673, :11689). Typing "doctor" there
raw-opened and closed state.db, projects.db, response_store.db,
cron/executions.db and every board's kanban.db while those connections
were live. The HTTP route at /api/ops/doctor deliberately spawns a
subprocess instead; the console path did not.

hermes_cli.sqlite_safe_read exists to prevent exactly this, and its
read_header_bytes_preopen is documented as "the ONLY sanctioned
byte-level read of a database file". It performs the registry check and
the open/read/close together under the connection-lifecycle lock, so it
refuses once any connection to the path is live. The audit that converted
the other byte-probes (hermes_state.py:2750, backup.py:436,
kanban_db.py:1861) landed in 95fb477 on 2026-07-25;
_read_journal_mode was added in 6583297 on 2026-08-06 and reintroduced
the pattern, so this is a regression against an invariant the tree already
states, not a refactor preference.

The helper is a plain byte read, so the docstring's stated property is
preserved: no SQLite engine open, and no -wal/-shm sidecars are created.
Only the acquisition of `header` changes; the empty / not-a-database /
unrecognized-format-version branches are untouched.
read_header_bytes_preopen returns None for every failure, so routing the
probe through it flattened "[Errno 2] No such file or directory: …" and
"[Errno 13] Permission denied: …" into one opaque "file could not be
read". doctor exists to name the problem, so that detail is worth keeping:
_report_database_journal_modes prints the string verbatim, and on a
vulnerable SQLite it is the only clue the user gets about why WAL exposure
could not be ruled out.

_unreadable_reason recovers it from metadata only. stat() reports the
missing file, the dangling symlink and the unsearchable parent directory;
os.access(..., R_OK) reports the unreadable file that stat() can still see.
Neither call takes a file descriptor, so neither can cancel the POSIX
advisory locks the previous commit was about — the invariant holds.
Locks the invariant the probe now honours: while this process holds a
registered connection to a database, _read_journal_mode reports it as
unreadable instead of taking a descriptor whose close() would cancel that
connection's POSIX advisory locks.

Against the previous implementation the four regression cases fail with
`assert 'wal' is None` — it read the header straight out of a live
database — and pass once the read is routed through
read_header_bytes_preopen. Coverage is both the registry API
(track_connection) and connect_tracked, the path SessionDB actually takes,
plus the _report_database_journal_modes output so the degraded row is
asserted end to end.

Two cases deliberately hold in both directions and are guards rather than
probes:

- an untracked sqlite3.connect holding BEGIN EXCLUSIVE must NOT block the
  read. Only connections this process registered can be cancelled by a
  close() we make; another process's locks are irrelevant. Without this,
  a later "just refuse whenever the file looks busy" change would silently
  turn every doctor row into "could not be read".
- the refusal creates no new -wal/-shm sidecars, which is the property the
  function's docstring promises and the reason it byte-probes rather than
  opening a connection in the first place.
…rows

read_header_bytes_preopen answers None for a live connection, a missing
file and an unreadable file alike, so the error string doctor prints is
now chosen rather than inherited from the OSError. These cases pin that
choice: the missing file keeps its errno text, and the chmod-000 file is
still reported as a permission problem rather than collapsing into the
generic message — the behaviour the raw open() gave before.

test_reason_does_not_open_the_file is the load-bearing one. It patches
builtins.open to raise and asserts _unreadable_reason still answers,
which fixes the constraint that makes the helper safe to call on a
database path at all: stat() and access() read metadata and take no file
descriptor, so no close() of ours can cancel the file's advisory locks. A
future edit that reached for open() here to get a better message would
reintroduce the original bug on the error path, and this test fails
loudly if it does.

The root check is written as hasattr(os, "geteuid") and os.geteuid() == 0
rather than the bare call the surrounding tests use. skipif conditions are
evaluated at collection time and os.geteuid is POSIX-only, so the bare
form raises AttributeError and takes the whole module down on Windows.
The pre-existing occurrences are left alone — #81926 and #84073 are
already open against exactly those lines, and this only avoids adding a
third instance of the same defect.
…ndent

clean_registry cleared the connection registry only on teardown, so it
protected the tests that ran after it but not the test holding it. A leak
from earlier in the session — a failed test that never reached its
close(), or any test that does not take this fixture — would leave a stale
entry behind, and read_header_bytes_preopen would then refuse for that
stale reason instead of the one under test. The refusal assertions would
still pass, but for the wrong reason, which is the failure mode a
regression test can least afford.

Clearing on entry as well makes the fixture independent of what ran
before it, and the teardown clear now runs under try/finally so a failing
test cannot skip it.
The Bots pane header + is now a dropdown (New Agent / New Group Chat).
New Group Chat opens a checkbox-picker modal: searchable roster list,
member cap at GROUP_CHAT_MAX_MEMBERS, group-name input that defaults to
the selected members' names, and a Create button that assigns the
existing per-bot group meta field - so the room rides the ui_meta sync
path unchanged and the user lands directly in the new room.
…plete

The @ popover only completed filesystem references; bot handles worked
when fully typed (mention middleware parses at submit) but were never
offered, so users had to know the exact handle — worse with multi-source
@name-device handles. Fixes #88060 (ported from Hermes-Bot-Mode#43).

- composer contrib: new 'composer.atCompletions' data area
  (ComposerAtCompletionSource) — contributed rows merge AHEAD of path
  results; a throwing source drops its rows, never the popover
- use-at-completions: merge contributed entries in all three fetch paths
  (gateway results, gateway-less, fetch error)
- SDK: export the new area + types for plugins
- bundled Bot Mode plugin: registers 'mention-completions' — roster
  handles from the query cache (\u22645s stale), active profile excluded,
  'default' offered as @Hermes, multi-source @name-device handles via
  botHandle, display name + connection label in the row meta, capped at 8
- registered early in register(ctx) so vm harnesses reach it before the
  pane/UI registrations that stubs can't fully model
…ncurrency cap (OOF-30)

Two production incidents (OOF-77 "larrikin-lollies", OOF-30
"synclare-task-manager") followed the same shape: no
kanban.max_in_progress configured, a busy board, and a 1 GiB hosted VM.
The dispatcher fanned out 26-31 concurrent workers, the host went into
swap-thrash/OOM, and the whole machine — dashboard included — became
unreachable. NAS restart loops then masked the problem: each restart
"recovered" briefly before the kanban dispatcher immediately respawned
unbounded workers.

Building on the cherry-picked max_in_progress-across-both-lanes fix
(PR #28695, credit @Dusk1e), this adds two complementary safeguards to
hermes_cli/kanban_db.py:

1. Memory-DERIVED default concurrency cap. When kanban.max_in_progress
   is unset, resolve_max_in_progress() derives a default of
   clamp(MemTotal / 512 MiB, 2, 8) — e.g. 2 workers on a 1 GiB VM,
   8 on 4 GiB+. Explicit config always wins in either direction. On
   hosts where total memory can't be read (macOS/Windows dev machines),
   the default stays None (no cap — unchanged behaviour). Wired into
   both dispatch entry points (gateway/kanban_watchers.py and
   hermes kanban dispatch) so behaviour matches regardless of path.

2. Live memory-PRESSURE guard inside dispatch_once. A static cap can't
   see the host's actual memory state (other tenants, bloated
   long-lived workers). The dispatcher now samples system memory each
   tick via gateway.lifecycle_ledger.sample_memory() and classifies it
   with gateway.memory_status.classify_pressure() (same thresholds as
   the dashboard memory banner and OOM-suspicion heuristics from
   NS-608/NS-656): critical -> spawn nothing this tick; elevated ->
   at most one new worker; unknown -> no restriction (fail-open).
   Reclaim/promotion bookkeeping still runs under pressure, and
   deferred tasks stay queued — nothing is dropped. Restriction is
   surfaced on DispatchResult.memory_pressure and logged.

Tests: tests/hermes_cli/test_kanban_memory_guard.py (14 tests) covers
the derived cap (floor/ceiling/fail-open/explicit-config-wins), the
pressure classifier, and dispatch behaviour under critical/elevated/
unknown pressure including defer-not-drop and bookkeeping-still-runs.
An autouse fixture in tests/conftest.py pins the memory sample to
"no data" suite-wide so existing dispatch tests don't depend on the
CI runner's live memory state (opt-out marker: real_memory_guard).
…-lane reservation (OOF-30 review)

Addresses three gaps found in review of the memory-guard PR:

P1a — standalone daemon was the one uncapped entry point. run_daemon()
now resolves kanban.max_in_progress every tick (explicit config wins,
else the memory-derived default) exactly like the gateway dispatcher and
`hermes kanban dispatch`. New shared parser configured_max_in_progress()
so all three entry points agree on what "explicitly configured" means.

P1b — max_in_progress was enforced per board while the gateway ticks
every active board, multiplying the host budget by the number of boards
(2 boards x cap 2 = 4 workers on a host sized for 2). The cap is now
host-level: _dispatch_once_locked() adds count_running_tasks_other_boards()
to the running count before deriving the tick's spawn budget. Enforced in
the shared locked path, so gateway, CLI, and daemon all inherit it.
max_spawn deliberately keeps its historical per-board semantics.
Fails open per board so one corrupt board can't brick dispatch on the rest.

P2 — the ready loop consumed the entire shared spawn budget before the
review loop ran, so a sustained ready backlog starved autonomous reviews
indefinitely. When spawnable review work exists (assigned + real profile,
mirroring the review loop's own gate) and the tick has budget, one slot
is held back from the ready lane. Reservation is per-tick and
self-releasing; the review lane still spends from the shared budget —
it gains fairness, not extra capacity.

11 new tests in tests/hermes_cli/test_kanban_host_cap.py. Existing
kanban suites: 278 passed (15 failures pre-existing, identical on clean
main baseline). ruff clean.
…arget logging

A user typed their root password into the Desktop SSH host field
(root@IP:PASSWORD form). Three failures compounded:

1. validateSshTarget() only checked for option injection (leading dash),
   control chars, and port range — commas in an IP, whitespace ("ssh "
   prefix pastes), and non-numeric ":<segment>" leftovers all dialed ssh
   with garbage and failed silently five times.
2. normalizeSshConfig() only strips a ":<segment>" when it is numeric, so
   a pasted password stayed glued to the hostname all the way into ssh
   argv and the desktop.log connect line.
3. redactSecrets() had no pattern for ssh targets, so the password landed
   verbatim in desktop.log and then in a PUBLIC debug-share paste.

Changes:
- validateSshTarget(): reject whitespace, commas, non-numeric colon
  segments (with a "never put a password in the host field" hint that
  does NOT echo the credential), and garbage hostnames; still accepts
  bare IPv6 (::1, fe80::1%eth0). Reject whitespace/@ in user.
- redactSecrets(): new pattern masks any non-numeric segment where a
  port belongs in user@host:... strings — defense in depth so future
  parse gaps can't leak credentials into logs or debug shares.
- normalizeSshConfig(): strip a pasted leading "ssh " prefix.
- Tests for all three, including the exact incident shapes.
`ElicitationHandler` read `params.requested_schema`, but on the pinned
`mcp==1.28.1` the model field is spelled `requestedSchema`. The getattr
always missed and returned its `{}` default, so
`_format_elicitation_schema_summary` took its no-properties branch and the
approval prompt collapsed to the generic

    Approval requested by MCP server '<name>'.

for every request. The field names, types, and descriptions the summary
exists to surface never reached the user, so an elicitation asking for a
card number rendered identically to one asking for a nickname — consent
without the substance of what was being consented to.

Read both spellings rather than just correcting to the 1.x name: mcp 2.0
renames this field to `requested_schema` (it renamed every model field to
snake_case and kept camelCase only as a serialization alias, which
pydantic does not expose to attribute access), so a dual read is correct
on either SDK generation and does not go wrong again on the next bump.
Verified against real 1.28.1 and 2.0.0 installs.

Every existing test in tests/tools/test_mcp_elicitation.py builds a
duck-typed `SimpleNamespace` stand-in, which carries whatever field name
the test wrote and therefore cannot detect a mismatch with the real model.
Add one test that constructs the actual `ElicitRequestFormParams` and
asserts the requested field name reaches the consent description; it fails
on the unfixed tree. The cheap stand-ins are left alone elsewhere.

Found while porting the tree to the mcp 2.x SDK in #76736, but independent
of it: this reproduces on the current pin with no other changes, #76736
does not touch this line, and the two branches merge cleanly in either
order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Remove the cross-version heuristic from _strip_mismatched_site_packages:
the subprocess env builder cannot know which Python version a child will
run, so judging user PYTHONPATH entries against the backend interpreter's
version deletes legitimate paths meant for a different child Python
(e.g. /custom/lib/python3.13/site-packages while Hermes runs 3.11).

Also fix over-strip: entries merely containing a pythonX.Y path component
(e.g. /opt/tools/python3.13/bin) were stripped even though they are not
site-packages. Hermes-owned entries (repo root, own venv site-packages)
are now identified by path ownership, not by version.

Regression tests cover both cases; user paths with any pythonX.Y
component are preserved.
The gateway runs inside its own venv; if its PYTHONHOME leaks into
subprocesses (terminal commands, cron no_agent scripts, TTS providers),
any child interpreter redirects its stdlib search to the Hermes venv and
crashes with version-mismatch errors before importing anything.

PYTHONHOME is now part of _ACTIVE_VENV_MARKER_VARS so all env builders
(_make_run_env, _sanitize_subprocess_env, hermes_subprocess_env, and
build_subprocess_env used by cron) drop it, consistent with Hermes'
existing PYTHONHOME handling in managed_uv.py and sqlite_runtime.py.
execute_code already scrubbed it via _SAFE_ENV_PREFIXES.

Tests cover all four builders plus the marker constant.
Adversarial review of the previous two commits (and #78917 itself)
found three ownership-boundary issues; this commit addresses them:

1. Repo direct-child over-strip (Finding A)
   No launcher injects <repo>/tools or another direct child as an
   independent PYTHONPATH entry - audited all four producers (Electron
   electron-main.mjs, gateway/run.py::_ensure_windows_gateway_venv_imports,
   cron/scheduler.py::_windows_cron_python_invocation,
   tui_gateway/host_supervisor.py).  The depth<=1 rule deleted user paths
   that merely live under the repo directory; only the EXACT repo root is
   now stripped.

2. Windows junction/symlink alias (Finding B)
   The gateway launcher renders Hermes-owned paths under the configured
   HERMES_HOME spelling (gateway_windows.py::_preserve_hermes_home_path),
   which may be a junction to another drive, so it differs lexically from
   the resolved repo root.  _hermes_repo_root_aliases now carries both the
   resolved and unresolved spellings; both are recognized as Hermes-owned.

3. Stale abstraction rename (Phase 4)
   _strip_mismatched_site_packages -> _strip_hermes_owned_pythonpath:
   the cross-version heuristic is gone, so the old name misdescribes the
   behavior (ownership-based, not version-based).

Tests: direct-child now preserved; junction alias stripped (lexical pair
monkeypatched); Windows-only real-semantics test added (POSIX test remains
a safety test); mixed-ordering, duplicate-Hermes, and no-scrub PYTHONHOME
contract tests added.  Full file: 52 passed / 16 failed (identical failure
set to base, all isolation-venv environment issues).
…ted inherited env

Integration test for the #84500 + #82581 intersection: seeds a
contaminated inherited PYTHONPATH (Hermes repo root + Hermes venv
site-packages + user entries) through os.environ and drives execute_code
to Popen. Asserts the staging tmpdir stays first, inherited Hermes
site-packages never survive, the repo root is re-added exactly once for
a same-env child (proving the inherited copy was stripped) and stays
absent for an external-env child, and user entries survive in order.
The PYTHONPATH/PATH sanitization suite was written POSIX-centric and
failed on real Windows 11 (reproduced natively: 4 failures before this
change).  Fix the tests to express the true per-platform contract:

- test_other_major_version_site_packages_preserved /
  test_make_run_env_injects_hermes_bin_dir: build inputs with
  os.pathsep instead of hardcoded ':'.
- test_make_run_env_appends_homebrew_on_minimal_path: split on
  os.pathsep, neutralise Git Bash dir prepending, and assert the
  documented Windows passthrough (_append_missing_sane_path_entries is
  a no-op off POSIX) instead of the Homebrew append.
- test_make_run_env_real_launchd_path_gains_homebrew: mark
  macos_only per repo OS-marker policy (the regression is the macOS
  launchd PATH; the merge is a passthrough on Windows).
- test_configured_home_alias_matches_launcher_output: create the
  configured-home link via a helper that falls back to an unprivileged
  directory junction (cmd /c mklink /J) when symlink creation raises
  WinError 1314, and skips with a clear reason if no mechanism exists.

Also correct a stale comment in execute_code: the child is not always
the same Python as Hermes (project mode can select an external venv),
so the strip is about compatibility, not redundancy.
Confirmed on native Windows 11 with a real junction and the real startup
chain: when the desktop/CLI spawns the backend with HERMES_HOME in the
configured (lexical) spelling and --profile / sticky active_profile is in
play, _apply_profile_override() re-homes HERMES_HOME through
resolve_profile_env(), which resolves the junction under the platform
default and returns the PHYSICAL spelling.  tools.environments.local is
imported after that mutation, so _hermes_repo_root_aliases is built from
the physical home, the lexical repo-root spelling written into PYTHONPATH
by the launcher (D:\hermes\hermes-agent) is not derivable, and the entry
survives stripping (reproduced: cases --profile default / named / sticky
active_profile / cross-drive junction all leave it in place; no-profile
strips it).

Two narrow changes, no heuristics, no new env vars:

- hermes_cli/profiles.py::resolve_profile_env: when HERMES_HOME is set,
  the configured spelling IS the launch root (junction-transparent,
  physically identical dirs); keep it instead of re-deriving the native
  default.  This is the same producer contract _preserve_hermes_home_path
  already follows.
- tools/environments/local.py::_build_hermes_repo_root_aliases: when the
  configured home is a profile home (<root>/profiles/<name>), also derive
  the root spelling lexically (parent of the profiles component, same
  rule get_default_hermes_root uses) and run the exact-ownership mapping
  against it, so the launcher's lexical root is recovered after re-home
  without ever matching arbitrary descendants of HERMES_HOME.

Regression test test_profile_rehome_keeps_junction_lexical_alias covers
junction + profile re-home + inherited lexical PYTHONPATH end to end.
The junction fix made resolve_profile_env preserve the configured
HERMES_HOME spelling as the launch root.  Cover the four pre-existing
resolution invariants so the spelling-preservation never regresses them:

- root env + named profile -> <root>/profiles/<name>
- profile-shaped env + named profile -> <root>/profiles/<name> (no nesting)
- profile-shaped env + default -> <root>
- custom root env never falls back to the platform default

Plus existence/validation semantics (missing named profile still raises
FileNotFoundError) and the unset-env fallback contract.
Second real-world topology reported and confirmed on native Windows 11:
the repository itself is a cross-drive junction (D:\hermes\hermes-agent ->
C:\...\hermes-agent) under a real HERMES_HOME directory.  The editable
import spelling resolves to the physical location, so _hermes_repo_root is
physical while the launcher writes the lexical spelling into PYTHONPATH.
The home-relative mapping cannot express a cross-drive link (commonpath
raises on different drives), so the lexical repo root survives stripping;
and with the repo alias missing, a lexical VIRTUAL_ENV
(D:\hermes\hermes-agent\venv) also fails _validated_runtime_venv, so the
venv site-packages survives too (uv-base gateway: both entries survive).

Fix: after the existing home/profile-root mapping, try the single
deterministic candidate <lexical root>/<repo dirname> for every trusted
home candidate (configured home, plus the profile root when the configured
home is a profile path) and accept it only when strict resolve proves it is
the exact physical repo root (fail-closed: missing paths, real directories
that are not the known repo, and unrelated spellings are never aliased).
This also re-enables the VIRTUAL_ENV validation for lexical venv spellings,
so uv-base gateway site-packages cleanup follows the repo alias.

Tests: repo-level junction positive + negative control (same-named real
directory preserved), profile-home + repo-level junction combination,
lexical VIRTUAL_ENV validation after recovery (root + site-packages
stripped, user entries kept), and a no-provenance lookalike preserved.
The execute_code composition test now compares composed paths with
os.path.normcase so a Windows case-only spelling difference (resolve() vs
abspath() casing) can never fail the composition contract.
Same behavior, same coverage, less boilerplate (test file 1691 -> 1512
lines; PR diff unchanged in semantics).

Production (mechanical only):
- Extract _strip_hermes_owned_pythonpath_and_runtime_markers(): the three
  builders (_make_run_env, _sanitize_subprocess_env, hermes_subprocess_env)
  ran the identical strip-then-pop-markers sequence in the same order
  (ordering is load-bearing for VIRTUAL_ENV validation); the helper makes
  that explicit once instead of three times.

Tests:
- Non-owned preservation: 11 single-shape tests -> one parametrized matrix
  (user/Nix/other-version/python2.7/pythonX.Y-contained/raw spelling/empty
  component/empty PYTHONPATH) + one runtime-shaped matrix (other-version SP,
  venv-SP descendant, repo direct child, repo deep child).
- Owned stripping: venv SP, repo root (independent parents[2] computation),
  duplicates, all-owned key removal, mixed ordering -> one matrix.
- Builder integration: _make_run_env/_sanitize_subprocess_env/
  hermes_subprocess_env venv-SP stripping -> one parametrized test;
  same for the four PYTHONHOME builders (incl. build_subprocess_env).
- Junction: same-named non-owned negative control now covers both the
  configured-root location and an unrelated location; shared
  _physical_repo_root helper; profile resolution matrix (root->named,
  profile-shaped->named no nesting, profile-shaped->default, custom root).
- Every independent proof preserved: home-level junction, repo-level
  junction, profile interaction, negative identity control, uv-base lexical
  VIRTUAL_ENV, validated/unrelated VIRTUAL_ENV, no-scrub escape hatch,
  #84500 same-env/external-env composition, PYTHONHOME removal, real
  Windows-only semantics, POSIX fail-closed backslash paths.
Shorter, single-source ownership explanation for
_strip_hermes_owned_pythonpath (the code-level Check comments already
carry the per-branch detail; the docstring only needs the contract).
Two independent bugs let a deleted profile reappear / leave orphaned
resources on next launch:

1. hermes_cli/profiles.py's backend-process scanner required argv[0] to
   resolve to an executable literally named "hermes". Electron's
   pool-backend spawn resolves the hermes console-script shim's path and
   execs it via the interpreter directly (python3 /path/to/hermes ...), so
   argv[0] reports as "python3" and the scanner never matched the running
   backend -- delete removed the profile's files but left its live backend
   process running (still bound to a port via uvicorn), which
   accumulates across repeated delete/recreate cycles.
2. The desktop sidebar's ProfileRail only refreshed its cached profile
   list once, on mount, so a delete/create/rename from another surface
   (another window, or the CLI) left a stale ghost entry until something
   unrelated triggered a refetch. Note: a delete via this window's own
   Manage-Profiles view already refreshes the shared $profiles atom
   ProfileRail subscribes to (confirmed by reading refreshProfiles() and
   handleConfirmDelete()) -- this fix only covers the cross-window/cross-
   process staleness gap, not a duplicate of the already-merged
   #57329's Manage-Profiles rail-refresh work.

Fix 1: recognize a python-interpreter argv[0] exec'ing a hermes-named
console-script shim via argv[1]. Fix 2: refresh the profile list on window
focus/visibilitychange, matching the existing pattern used elsewhere in
the sidebar (sidebar/index.tsx, use-background-sync.ts, star-map.tsx,
use-gateway-boot.ts all use the same focus+visibilitychange pattern).

## Related work already on main

PR #57329 (merged) fixed the *headline* symptom from issue #52279
(deleted profile respawns) via a different, non-overlapping mechanism:
routing profile-delete through the primary backend instead of spawning a
fresh pool backend, plus a separate recreation guard in
ensure_hermes_home() (#49435, merged) that makes a backend spawned into a
deleted profile's directory raise FileNotFoundError instead of silently
recreating it.

This PR is NOT a duplicate of that fix. Verified: even with both of those
merged, a backend process that survives because of gap #1 above still
holds a bound port via uvicorn -- it just can no longer resurrect the
profile directory. That's real resource-hygiene, not a symptom already
covered. Gap #2 touches a different file/component (ProfileRail /
profile-switcher.tsx) than #57329's rail-refresh half (which touched the
Manage-Profiles view's own $profiles.ts / index.tsx) and covers a
distinct staleness path (cross-window/cross-process, not same-window
delete-then-refresh).

Tests: tests/hermes_cli/test_profiles.py -- 156 passed (existing +
regression coverage for the argv[0] python-interpreter detection case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… scripts

External review (Fable) caught a real false-positive widening in the
original commit: the new argv[1] script-name check reused the loose
`script_name == "hermes" or script_name.startswith("hermes")` pattern
(copy-pasted from the exe_name check above it), but argv[1] can be ANY
user-invoked python script path when argv[0] is a bare interpreter --
unlike a directly-resolved executable name, where a false match on the
substring is rare. A user's own script named e.g. "hermes-notes.py" or
"hermes-unrelated-tool" run via `python3 <script>` would be misidentified
as the console-script shim and become killable by profile delete.

Match against the actual known console-script entry points instead
(pyproject.toml [project.scripts]: hermes, hermes-agent, hermes-acp),
stripping the script's extension before comparing.

Added 2 regression tests: one confirms the false-positive case is now
rejected (fails against the pre-fix loose-match code, confirmed via a
scripted revert), the other confirms the other two real entry points
(hermes-agent, hermes-acp) still match via the shebang-exec path.

Tests: tests/hermes_cli/test_profiles.py -- 158 passed (156 previous + 2
new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… a tested hook

Addresses review feedback from the hermes-sweeper (salvageability=high,
keep_open): "The new focus/visibility listener behavior lacks a runtime
UI regression test... no ProfileRail test."

Rendering the full ProfileRail component for this would drag in
drag-and-drop, dialogs, hotkeys, and i18n unrelated to what needs testing.
Instead, extracted the focus/visibilitychange wiring into its own
use-profile-rail-refresh-on-active hook, matching this exact directory's
own established convention (use-profile-prewarm.ts is the same shape:
a small side-effect hook pulled out of ProfileRail specifically so it's
unit-testable in isolation).

Added 6 tests covering exactly what the review asked for: refresh on
mount, refresh on window focus, refresh on visibilitychange while
visible, NO refresh on visibilitychange while hidden, listener cleanup
on unmount, and no listener accumulation across repeated mount/unmount
cycles.

Verified the tests have real teeth: simulated the exact bug this PR
originally fixed (dropped the cleanup return, leaving listeners attached
after unmount) and confirmed 4 of 6 tests correctly fail against it --
including "no accumulate listeners" showing 7 calls instead of 1, the
exact leaked-listener signature. Restored the real fix and all 6 pass.

ProfileRail itself is otherwise unchanged in behavior -- this is a pure
extraction (same effect, same dependencies, same cleanup), not a
behavior change. Full sidebar test suite: 93 passed across 12 files (up
from 87 across 11), 0 regressions. Python side unaffected: 158 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SHL0MS and others added 29 commits August 18, 2026 00:57
…ha-0

Constructor backgroundColor with alpha is silently treated as opaque on a
non-transparent window (Electron only documents constructor alpha with
`transparent: true`), so windows created while glass was persisted were
born with an opaque backing and the vibrancy material never showed —
exactly the state a user lands in after toggling glass on and relaunching,
or when the renderer re-reports the persisted state at boot (the IPC
handler correctly dedupes it, so no runtime swap ever fired).

Measured on macOS 26 / Electron 40 (side-by-side spike windows, pixel
luminance): ctor '#00000000' = flat opaque (lum 38, same as no glass);
omitting backgroundColor entirely = vibrancy visible (lum 57). Runtime
setBackgroundColor swaps are also LOST while a fresh process's compositor
is settling — swaps at 1s/3s/6s after creation never landed, including
from 'ready-to-show' and 'did-finish-load'; a 10s swap stuck. So cold
launches must be right at creation: windowBackingOptions() spreads either
{} (glass) or the themed anti-flash backing (everything else) into the
three chat-window constructors. The runtime swap path stays for live
Settings toggles, where the window is long settled.
The mapping had grown three copies: the clear-mode ramp in
electron/window-opacity.ts, a second clamp + mode normalizer in
electron/translucency.ts, and a third clamp in the renderer store, with a
"keep in sync" comment standing in for a shared type. Anything the two
processes must agree on -- what a mode is, where the lever clamps, what
intensity means as an opacity -- now lives in apps/shared/src/translucency.ts
and both ends import it.

electron/translucency.ts keeps only the piece that needs a BrowserWindow to
mean anything (the constructor backing), and re-exports the rest so main.ts
has a single import. Its relative specifier is deliberate: the electron
bundle is built by esbuild with no tsconfig path resolution, so a bare
@hermes/shared/translucency would typecheck and then fail to bundle -- the
same constraint connection-registry.test.ts documents for backendScopeKey.

The renderer's tsconfig drops its reference to the electron project. With
both projects claiming the shared file, that edge made the renderer resolve
it through the electron project's build output and demand a prior
`tsc --build`. Nothing in src/ consumes electron's emitted types, so the
reference bought nothing; `npm run typecheck` still checks both projects.
Three small dedupes in the surfaces this feature touches:

- GLASS_SUPPORTED was a fourth inline copy of "am I on a Mac" in the
  renderer. src/lib/platform.ts owns it now and the terminal's
  isMacPlatform re-exports it, so the two call sites can't drift.
- The store held intensity and mode as two atoms behind two localStorage
  keys with two subscriptions calling one sync. They are one setting: one
  atom, one key, one subscription. A pre-mode value under that key is a bare
  intensity, which has only ever meant clear -- read() keeps it there.
- global.d.ts re-declared the IPC payload as an inline union. It takes
  TranslucencyState, so widening the state can't leave the bridge behind.

isChatWindow is exported and takes its search string as a parameter, because
"which windows may thin their surfaces" is the contract worth pinning.
Glass thins the field tokens, so anything that needs a fill for a reason of
its own had to be exempted. Those exemptions were written as styles.css
reaching into other components by class name and slot -- .cursor-grabbing,
.composer-human-message-container, [data-slot='file-diff-panel'] -- which
puts the knowledge in the wrong file: rename the class, lose the fill, and
nothing fails until someone turns glass on over a diff.

There are only two roles. A surface that MASKS its siblings (the diff
gutter, a dragged row) must stay opaque or it reads as text through text. A
surface RAISED above the field (overlay cards, the inline edit box) stays
near-opaque and never thinner than the field behind it. Each one now says
which it is at its own call site, and styles.css styles the roles.

This also narrows the diff-panel rule to the sticky gutter that actually
masks, rather than the whole panel.
Every chat window constructor repeated the same three translucency-related
options -- the vibrancy material, the native opacity, the webContents
backing -- and the glass work was about to make that four things to keep in
step across three sites, with the cold-launch backing rule (omit
backgroundColor, never pass alpha) restated at each. chatWindowSurfaceOptions()
states it once, and the comment naming the HUD, pet overlay, quick entry and
wake indicator as deliberately-not-chat-windows lives with it.

The settings row reads the store's single object rather than two atoms.
Restores the four features SHL0MS built on #84329 that an earlier pass on
this branch had carved out, reconciled onto the shared translucency state
rather than the four-atom store they were written against.

- Frost picker. macOS exposes no blur-radius knob, so the vibrancy material
  IS the frost control. The four in the ladder come from a pixel census on
  macOS 26: the 14 Electron materials collapse to 9 distinct looks, and
  these four are the widest separations that stay distinct in BOTH
  appearances. sidebar/hud collapse into under-window when unfocused, which
  is why they're deliberately absent -- normalizeMaterial rejects them, with
  a test saying why.
- Sidebar-only glass, the Finder shape. <body> stays the single painter and
  splits at the rail's live-measured edge with a hard gradient stop, so
  there's no smear across the seam and no per-layer tint stacking. RTL
  mirrors.
- Full-range tint. glassSurfaceKeep runs linear to zero, so the top of the
  lever is bare untinted blur instead of stopping at a 30% wash. Text, cards
  and the composer keep their own opaque tokens, which is what makes 100%
  usable rather than unreadable.
- Peek. The settings overlay covers the very effect its slider controls, so
  holding the slider ghosts the whole overlay layer and the live window
  becomes the preview. A counter, not a boolean: a held drag and a timed
  pulse from a picker click overlap, and the drag must not be cancelled by a
  pulse expiring underneath it.

One change from the original: the peek's transition is scoped with :has() to
the overlay that arms it. The version on #84329 shipped a bare
`[data-overlay-surface] { transition: opacity 420ms }`, which gave every
overlay in the app -- command center, cron, agents, model picker -- a 420ms
opacity transition for the life of the process to serve one slider. Verified
by running the candidate rules through lightningcss: the scoped selectors
survive minification and no un-gated overlay transition remains.

visualEffectState is pinned to 'active' at each chat window, because several
materials collapse to a shared inactive look on blur -- without it the frost
choice silently erases itself whenever the user clicks another app.

Co-authored-by: SHL0MS <SHL0MS@users.noreply.github.com>
jsdom reports an EMPTY navigator.platform and a userAgent of "darwin", so
GLASS_SUPPORTED resolved false and every `if (GLASS_SUPPORTED)` assertion in
the store suite took its else-branch — on a Mac too. The suite was green
because it was checking that glass does nothing.

Found by mutation: removing the `data-hermes-glass-scope` cleanup, hardcoding
the keep percentage, and dropping the isChatWindow guard all survived a full
run. Pinning navigator.platform before the store module evaluates makes the
glass path the one under test, and all three now fail as they should.

Also adds the coverage those mutants exposed as missing:

- Each of the three ways glass can end (intensity to zero, mode to clear,
  window kind) clears the scope attribute independently — a stale scope keeps
  the split-paint gradient selector live over a non-glass field.
- The store must CONSULT isChatWindow, not merely export it. Driving
  window.location.search proves the HUD / pet overlay / quick entry don't get
  their page surfaces rewritten while still honouring the user's saved mode.
- A guard asserting GLASS_SUPPORTED is true in this environment, so if the
  env ever stops reporting mac the suite fails instead of quietly hollowing
  itself out again.
Dragging the intensity slider was janky, and the four frost levels looked
nearly identical. Same cause.

At step=1 a drag emits ~100 updates, and every one of them did four
expensive things: a synchronous localStorage.setItem, an IPC wake, a
synchronous fs.writeFileSync in main, and setVibrancy + setBackgroundColor
on every open window. The vibrancy call is the one that also broke the
frost picker — it animates over 150ms, so re-issuing it per tick restarted
the animation before macOS could ever settle the material. The levels
weren't indistinguishable, they were never finishing.

The fix follows from a property the mode already has: under glass the
intensity is a pure renderer concern. windowOpacityFor returns 1 for the
whole range, so main has nothing to do on an intensity change at all.

- main diffs the incoming state against the current one and passes a
  `changed` set to applyWindowTranslucency. An intensity-only change under
  glass now touches zero native properties. Crossing zero still moves the
  backing, since that flips glass on and off.
- main's disk write is coalesced onto a 250ms trailing timer, flushed on
  before-quit. Only a cold launch reads that file.
- the renderer paints every tick (the field has to track the hand) but
  coalesces the localStorage write and the IPC send onto a 120ms trailing
  timer, flushed on pagehide.

Covered as contracts rather than timings: a table asserting exactly what
each kind of update changes natively (nothing, across the whole intensity
range under glass), and store tests that a six-tick drag produces one write
and one IPC call while every intermediate value still paints. Both
directions mutation-checked — restoring per-tick writes fails, and
debouncing the paint fails too.
The perf pass over-corrected: it debounced the renderer's IPC send along
with the localStorage write. Under glass that was harmless (the renderer
paints the effect itself), but in clear mode the effect IS the native window
opacity, and main can only move it when told — so dragging the slider did
nothing for 120ms and then snapped to the released value. Exactly the jank
the debounce was meant to remove, reintroduced on the other mode.

The send is per-tick again; only the localStorage write stays debounced.
Per-tick sends are cheap now because main diffs the state: one setOpacity in
clear mode, nothing at all under glass.

The store test asserting the send was coalesced encoded the bug — flipped to
assert every tick reaches the bridge, with a comment naming the rule.
Audit pass over the whole feature's lifecycle, closing four holes:

- Stuck peek. Escape mid-drag unmounts the slider before its pointerup ever
  fires, stranding the counter above zero — every LATER settings overlay
  then renders ghosted at 8% opacity. The appearance surface now drops all
  outstanding holds on unmount (resetTranslucencyPeek); expiring pulse
  timers become no-ops on the zero floor.

- Frozen sibling windows. Under glass an intensity change touches nothing
  native (by design, that's the perf fix), so a second chat window never
  heard about it and its tint froze until reload. The store now adopts a
  sibling's persisted state off the storage event — the same cross-window
  pattern themes/context and store/session already use.

- Layout thrash on the sidebar-scope drag. startRailTracking re-measured the
  rail on every store sync: a getBoundingClientRect (forced layout) right
  after the tint's style write, once per slider tick. While tracking is
  live, the ResizeObserver and the resize listener own geometry; the settled
  hot path is now one boolean check. Re-acquisition still covers the two
  real cases — a rail that hasn't mounted yet, and a rail that REMOUNTED
  (layout reset swaps the element, leaving the observer on a detached node
  that never fires again).

- The rail observer/listener pair was already torn down whenever glass or
  the sidebar scope ends (stopRailTracking) — audited, covered by the scope
  attribute tests, unchanged.
Pre-handoff polish, no behavior change. The three translucency pickers
shared a verbatim five-line onChange (haptic, set, conditional pulse) —
one pickTranslucency helper now serves mode, frost and area. Dead
re-exports cut: the electron adapter no longer forwards renderer-only
symbols (TRANSLUCENCY_STEP, TranslucencyMode, GlassScope), and the store's
re-export block shrinks to what its call sites read; the store test takes
the shared constants from @hermes/shared/translucency directly.
Window Translucency shipped defaulting to Clear, which means the mode worth
finding is the one nobody sees — Glass is the better-looking half and the
reason the feature exists. A fresh macOS profile now starts with Glass
selected.

Nothing turns on. The intensity still defaults to 0, so the window is
byte-for-byte what it is today until the user moves the lever; the default
only decides which mode that lever will drive. windowOpacityFor stays 1 and
the window is still born with its opaque backing.

The one profile that must NOT flip is one already carrying a non-zero
intensity with no mode recorded: it predates the setting, has been rendering
as clear the whole time, and defaulting it to glass would change a window
someone deliberately tuned. normalizeMode takes the saved intensity and keeps
those on clear.

The renderer store was hand-rolling its own copy of this rule, so it now
routes through the shared normalizer and the two can't disagree.

The store's default test only passed because a beforeEach reset the atom
before it looked — it asserted the post-reset value, not the default, so it
would have stayed green through this change. It now snapshots the atom at
import time. All three mutations (default back to clear, escape hatch
removed, glass leaking onto non-mac) fail the suite.
The empty cronjobs list rendered both a calendar-icon placeholder blurb
("Cronjobs are recurring tasks this agent runs on a schedule.") and the
Create Cronjob button — two elements saying the same thing (empty).
Per Teknium's review, drop the generic placeholder and keep only the
create button. The filter hint ("jobs exist but are hidden by the bot
filter") still renders, since it carries real information rather than
just marking emptiness.
…ptor connectionId (salvage #88697) compose without re-appending the twin-address primary
…ture branch — switches back when safe, warns loudly when not

Live incident 2026-08-17: the source checkout was parked on a stale feature
branch (claude-code-inspired/local-terminal-memory-limit, days behind main),
left there by earlier tooling. 'hermes update' autostashed, refreshed lazy
backends, synced skills, and printed '✓ Code updated!' / '✓ Update complete!'
while the checkout stayed on the stale branch with none of main's new code.
Two sessions burned time on 'the fix is missing' confusion.

- Parked-branch guard: auto-switch back to the update target ONLY when the
  parked branch is clean and fully merged (git cherry origin/<target> shows
  nothing unmerged); the checkout then STAYS on the target instead of being
  re-parked. Otherwise: loud CODE UPDATE SKIPPED block naming the branch,
  behind-count, and resolution commands; exit 1; branch untouched.
- The up-to-date (commit_count == 0) path no longer switches back to a
  fully-merged parked branch either.
- Post-pull gate additionally refuses to print '✓ Code updated!' when HEAD
  ends up attached to a non-target branch.
- Summary lines now carry the actual branch + HEAD short-sha:
  '✓ Update complete! [main @ 30fcf95]' — drift visible at a glance.
- New config toggle updates.auto_switch_parked_branch (default true).
- Real-git-fixture regression tests (init/clone/branch, no subprocess
  mocks): clean+merged auto-switch, dirty skip, unmerged skip, cherry-picked
  equivalence, config opt-out, unverifiable ref, on-main fast path,
  up-to-date no-repark, summary branch/sha assertions.
…suffix

test_update_hangup_protection pinned the exact stdout of
_print_update_completion; the new branch+HEAD suffix (parked-branch guard)
broke that pin. The two receipt tests assert the action-identity contract,
not the branch display, so they now neutralize _branch_head_suffix — the
suffix behavior itself is covered by test_update_parked_branch_guard.py.
…parent-agent rebuilds, and child-started process notifications carry delegation attribution

Control path: delegate_task(action=list/steer/stop) resolved ownership
purely through the _delegate_parent_ref weakref identity chain. The CLI
rebuilds its AIAgent mid-session (self.agent = None on route-signature
change, credential refresh, /model, MoA one-shots), so a running child's
chain pointed at a dead object and the child went invisible/unsteerable
while completion delivery (durable session-id routed) still worked.
Observed live 2026-08-17: deleg_88454b70 / sa-0-dc0100f4.

Fix: register each child with the owning conversation's durable session
id (owner_agent_session_id, the same spine delivery routes by) and add a
second ownership tier that matches it against the calling parent's
session_id with compression-lineage resolution on both sides. Foreign
sessions still fail closed.

Presentation path: background processes started BY a subagent (task_id ==
subagent_id) route their notify_on_complete notifications to the parent
conversation by design, but arrived as anonymous raw output walls. The
formatter now resolves the task_id against the live + recently-finished
subagent registry (bounded retention survives child completion) and adds
a provenance line (subagent id, delegation id, goal snippet), trimming
the output tail for subagent-owned processes. Parent-owned process
notifications are byte-identical to before.
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
…just the legacy scalar

With multi-group membership, a bot's meta carries groups[]; the create
dialog's taken-name scan must union all of them (botGroups) or a name only
present as a secondary membership could be reused and resurrect that room.
@github-actions

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

running on 8c4b9db — WIP: upstream sync with conflicts (resolve before merging)

waiting for jobs to start…

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.