Commit graph

488 commits

Author SHA1 Message Date
墨綠BG
bdb1c87247 fix(dashboard): pass backup output with -o 2026-07-14 13:55:18 -07:00
Erosika
c7e09f2571 fix(desktop): restore curated declared schema for the provider panel
The desktop provider panel previously rendered the curated declarations
from hermes_cli/memory_providers.py: five hindsight fields, and no panel
at all for undeclared providers like honcho (OAuth connect only). The
dashboard provider-switching rework re-pointed the shared config route
at raw plugin schemas, so the desktop began dumping every internal field
(35 for hindsight) and grew a bespoke honcho panel.

Serve both surfaces from the same route: ?surface=declared returns the
curated schema (empty for undeclared providers) with the original
config-file + env-store write semantics; the dashboard keeps the raw
plugin schema unchanged. The desktop client opts into declared.
2026-07-13 21:01:55 -07:00
Teknium
861d69c7bb
fix(dashboard): keep memory.provider in the config schema so Desktop's dropdown survives (#63886)
The dashboard's dedicated memory-provider UI (4b184cbe5) excluded
memory.provider from /api/config/schema server-side. Desktop's settings
page builds its field list from that schema, so the Memory Provider
dropdown silently vanished from Desktop after v0.18.1.

- web_server.py: restore memory.provider as a select in _SCHEMA_OVERRIDES,
  with options built from plugins.memory discovery (was a stale hardcoded
  [builtin, honcho] list before the removal)
- plugins/memory: add list_memory_provider_names() — directory-scan-only
  name listing, safe at module import time (no provider imports)
- web ConfigPage: hide memory.provider client-side instead — the Plugins
  page owns the dedicated provider-switching UI there
- tests: schema contract (select present, category memory, builtin
  sentinel) + invariant that every discoverable provider is selectable
2026-07-13 18:56:51 -07:00
ethernet
6f7ee72be5 feat(desktop): base-branch picker for new worktree dialog
The sidebar "New worktree" button branched off whatever HEAD you were
on — now a filterable Popover+Command combobox lets you pick any local
or remote-tracking branch as the base, defaulting to origin/HEAD.

Backend listBaseBranches() queries refs/heads + refs/remotes via
for-each-ref, flags origin/HEAD (falling back to the local default for
no-remote repos). Mirrored on the Python REST API side
(base_branch_list + /api/git/base-branches route).
2026-07-13 14:53:34 -04:00
kshitijk4poor
ac705b52c9 fix(sessions): validate imported session payloads
Reject metadata that would make session queries fail, bound import work, and detach cyclic lineage links. Guard lineage traversal against pre-existing corrupt cycles.
2026-07-13 15:41:00 +05:30
Shannon Sands
b51d365ef0 feat(dashboard): add session import flow 2026-07-13 15:41:00 +05:30
Robert Blaas
da6d6164ba fix(dashboard): correct approvals.mode select options
The web UI CONFIG_SCHEMA showed ['ask', 'yolo', 'deny'] for the
approvals.mode select field. These don't match any real config values
and 'smart' mode was entirely unreachable from the dashboard.

Correct the options to ['manual', 'smart', 'off'] which match the
values defined and documented in hermes_cli/config.py.

Adds a regression test to TestBuildSchemaFromConfig to pin the correct
option names and guard against future drift.

Fixes #31925
2026-07-12 17:42:42 -07:00
Teknium
9a15fad0d6
fix(web): preserve declared providers in model writes (#63058)
Unify the named-provider fixes from #52506, #57185, #60337, and #60901 at the main-model normalization chokepoint.

Co-authored-by: izumi0uu <izumi0uu@gmail.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
Co-authored-by: Paulo Henrique <paulohenrique_789@hotmail.com>
2026-07-12 03:08:08 -07:00
Teknium
7550c594ce
feat(reasoning): add max and ultra effort levels (#62650) 2026-07-12 00:26:49 -07:00
Erosika
f000fbe5c5 revert(memory): drop the provider actions extension point
No bundled provider declares actions, and the motivating request
(openviking, #56309) needs dynamic select options rather than actions.
Removing the unconsumed POST dispatch surface keeps this PR focused on
the config panel; the extension point can return as its own PR with its
first real consumer.
2026-07-10 15:41:52 -04:00
Brooklyn Nicholson
301acc9eaa fix(web): paste/drop images into dashboard Chat via HERMES_HOME/images
Dashboard Chat is an xterm mirror of a TUI inside the gateway, so
server-side clipboard.paste never sees the browser clipboard. Upload
pasted/dropped images to the profile's images/ dir (same place
clipboard.paste / image.attach use), then drive /image over the PTY.

Uses a dedicated /api/chat/image-upload endpoint (magic-byte check,
25MB cap, profile scope) instead of relative managed-files uploads that
400 on local dashboards without a locked root. Ctrl/Cmd+Shift+V also
tries clipboard.read() for images before falling back to text, since
preventDefault on that chord suppresses the DOM paste event.

Salvages #57912 (client composition + /image PTY drive) and folds in
#48563's upload endpoint + drop path.

Co-authored-by: bird <6666242+bird@users.noreply.github.com>
Co-authored-by: tt-a1i <53142663+tt-a1i@users.noreply.github.com>
2026-07-10 02:20:04 -05:00
kshitijk4poor
f82c71396d fix(cron): scope profile runtime during webhook fire 2026-07-10 11:43:09 +05:30
embwl0x
ec0227b435 fix(cron): isolate profile store paths by context 2026-07-10 11:43:09 +05:30
kshitijk4poor
74609f926c fix(dashboard): run residual cron profile I/O off the event loop
Two async handlers still called the cron profile-walk helpers directly on
the FastAPI event loop after the 49fa04a23/346e5673d threadpool migration:

- POST /api/cron/fire called _find_cron_job_profile() inline — it walks
  every profile and lists its jobs (file I/O per profile), stalling the
  loop before the 202 is returned.
- POST /api/cron/blueprints/instantiate called _call_cron_for_profile()
  inline for create_job.

Route both through the existing _run_cron_dashboard_io threadpool wrapper
like every other cron dashboard endpoint.

Credit: @riceharvest (#50948) originally identified the sync-I/O-in-async-
handlers bug class for the desktop boot endpoints; 49fa04a23, 346e5673d,
7d0ddbb2f, d5eee133e and 24d5bda1e have since fixed most of that PR's scope
via the managed threadpool + PID cache + alias-map surfaces. This covers
the two cron handlers those merges missed.
2026-07-09 14:27:35 +05:30
Shannon Sands
3e24b16f56 fix(dashboard): support mobile OAuth login 2026-07-09 12:21:16 +05:30
kshitijk4poor
4f220fc88b fix: follow-up for salvaged #60347/#43653/#47437
- derive the compact_rows projection from SCHEMA_SQL (parse once, cache)
  instead of a hardcoded column list: the original #47437 list was cut
  against a June schema and silently dropped session_key/chat_id/chat_type/
  thread_id/display_name/origin_json/expiry_finalized/git_branch/
  git_repo_root/compression_failure_* — including desktop sidebar fields.
  Schema-derived means declaratively reconciled new columns are included
  automatically; only system_prompt is excluded.
- guard test pinning the schema<->projection contract (mutation-verified:
  dropping a column from the projection fails it)
- wire compact_rows=(not full) into /api/sessions and /api/profiles/sessions
  so the SQL projection pairs with the API-level field strip (?full=1 still
  returns complete rows end-to-end)
- pass compact_rows at the remaining hot list callers: /api/status active
  count, _session_latest_descendant fallback, /api/sessions/stats by-source
- thread compact_rows through the compression-tip projection
  (_get_session_rich_row) so projected tips can't reintroduce the blob
- add pagination tests for get_messages (#60347 shipped none): paging order,
  offset-past-end, active-flag interaction; add tip-projection compact test
- AUTHOR_MAP entries for mahdiwafy + CodeForgeNet (plain emails)
2026-07-09 01:36:46 +05:30
CodeForgeNet
22eb1af23a perf(state): add compact_rows to skip system_prompt blob in session list queries
list_sessions_rich and _get_session_rich_row previously used SELECT s.*,
pulling the system_prompt TEXT blob on every row even for dashboard and
picker callers that never display it. On large databases this blob routinely
runs to tens of kilobytes per session, causing unnecessary B-tree I/O.

Add compact_rows=False param to both functions. When True, an explicit
column list omitting system_prompt is substituted for s.* in both the
simple and the recursive-CTE (order_by_last_active) query paths.
Default is False so all existing callers are unaffected.

Update dashboard and session-picker callers in web_server.py and
tui_gateway/server.py to pass compact_rows=True.

Add seven regression tests covering: omission of system_prompt, presence
of all metadata fields, both query paths, _get_session_rich_row, and
backward-compat default.

(cherry picked from commit c470cbd304)
2026-07-09 01:36:46 +05:30
Omar Baradei
4df16c429b Refresh on upstream/main: resolve conflicts (no behavior change)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 1e70eaa49a)
2026-07-09 01:36:46 +05:30
mahdiwafy
0d5549a945 fix(api): add pagination to GET /api/sessions/{id}/messages
The session messages endpoint returned ALL messages in a single
response with no limit/offset. Sessions with 500+ messages produced
1.2-1.6 MB JSON payloads, causing GIL starvation and WebSocket
timeouts on the Desktop client (#60155).

Add optional limit/offset query params to both the API endpoint and
SessionDB.get_messages(). Limit clamped to 500 max per page. Response
now includes a pagination object with limit/offset/returned count.

Backward compatible: callers that omit limit get the old behavior
(all messages).

Closes #60155

(cherry picked from commit d58396b154)
2026-07-09 01:36:46 +05:30
kshitijk4poor
664878b0ba fix: guard /api/status active count against missing state.db
Review finding: SessionDB(read_only=True) requires the DB file to exist
(its documented contract says callers guard on db_path.exists()); on a
fresh install every /api/status poll paid an OperationalError until the
first session was written. Short-circuit to 0 when state.db is absent.
Tests: fresh-install guard + existing read_only test adjusted.
2026-07-09 01:19:07 +05:30
kshitijk4poor
1e2ad17afd fix: descendant CTE must dedup (UNION) to survive parent-chain cycles
The #39140 CTE used UNION ALL, which recurses forever if a corrupted
parent chain loops (a -> b -> a) — reproduced: query never returns. The
old Python walk was cycle-safe via a seen-set. UNION dedups the working
set and terminates. Regression test added and mutation-verified (UNION
ALL hangs the test, UNION passes).
2026-07-09 01:19:07 +05:30
0-CYBERDYNE-SYSTEMS-0
24d5bda1ef fix(dashboard): run GET /api/sessions session-DB read off the event loop
Flip the handler from async def to sync def so FastAPI executes it in
its threadpool: the SessionDB open + list_sessions_rich query no longer
block the single uvicorn event loop.

Residual hunk from PR #53966 — that PR's get_profiles_sessions flip
already landed via #54523/1bb7b59c5, and its get_status offload is
superseded by #58238's read_only + timeout variant in this branch.

(cherry picked from commit 414c12a40d)
2026-07-09 01:19:07 +05:30
sebastianlutycz
d7e4d94e2f perf(dashboard): recursive-CTE descendant lookup in _session_latest_descendant
_session_latest_descendant fetched EVERY sessions row and built the
parent->children tree in Python on each call. Replace with a recursive
CTE that loads only the target session's descendant branch.

Hand-applied from PR #39140 (the schema-init cache and Rust PTY bridge
parts of that PR are intentionally NOT salvaged here); main's function
signature gained a db parameter since the PR was cut.

(cherry picked from commit 8ed5e54f65)
2026-07-09 01:19:07 +05:30
luyifan
492be28e50 fix(web): bound dashboard action log tails
(cherry picked from commit a4188a3e24)
2026-07-09 01:19:07 +05:30
0disoft
7d0ddbb2ff fix(dashboard): cache gateway PID status probes
(cherry picked from commit a2bbe564ad)
2026-07-09 01:19:07 +05:30
墨綠BG
49fa04a235 🐛 fix(dashboard): use managed cron threadpool
(cherry picked from commit 97fabc289c)
2026-07-09 01:19:07 +05:30
墨綠BG
346e5673de 🐛 fix(dashboard): offload cron profile scans
(cherry picked from commit 1fded709aa)
2026-07-09 01:19:07 +05:30
yoma
9a4341aa90 fix(dashboard): keep status responsive when session db locks
(cherry picked from commit a3454dd15d)
2026-07-09 01:19:07 +05:30
Erosika
1508ece2d9 Merge origin/main: unify declared-schema and instance-schema provider config
Main's #60569 (dashboard memory provider switching) rewrote the same
GET/PUT /api/memory/providers/{name}/config routes this PR owns — the
dashboard and desktop share one backend. Resolve by dispatching: providers
that declare a config_schema.py get the declared path (host-block storage,
locked honcho writes, profile scoping, actions); everything else keeps
main's instance get_config_schema()/save_config path unchanged, so the
dashboard's PluginsPage behavior is preserved for instance providers.
Setup manifests, the /setup endpoint, provider switching, and name
validation from #60569 apply to both paths. Instance payloads gain the
docs_url/actions keys the desktop panel expects; declared payloads gain
the setup block the dashboard expects. Main's hindsight/honcho instance-
schema tests are updated for the dispatch, and its status test gets the
HOME pin it was missing (it read the developer's real ~/.honcho).
2026-07-08 15:03:24 -04:00
Grace
0cf2e39c41 feat(gateway): add webhook payload filters 2026-07-08 08:10:55 -07:00
Shannon Sands
4f620a0bbc Add WhatsApp dashboard pairing flow 2026-07-07 17:45:51 -07:00
teknium1
6015ee5d2a fix: pass profile-scoped SessionDB to _session_latest_descendant in dashboard chat PTY resume
The chat PTY launch path landed on main after PR #50558 and still called
_session_latest_descendant() with the old one-arg signature. Open the
requested profile's state DB (matching the REST endpoint) so profile-scoped
resume resolves descendants in the right database.
2026-07-07 17:44:44 -07:00
Shannon Sands
543f069093 Fix dashboard chat model profile scoping 2026-07-07 17:44:44 -07:00
teknium1
d9a4b5a5e5 fix: validate memory provider names before filesystem lookup and setup commands
Strict charset allowlist (alnum + - _, max 64) on the {name} path param of
the memory-provider config/setup endpoints. Prevents traversal-shaped names
from reaching find_provider_dir(), and setup now 404s when neither a
loadable provider nor a plugin manifest exists, so the command-running path
is only reachable for discoverable plugins. Adds regression tests.
2026-07-07 17:27:54 -07:00
Shannon Sands
4b184cbe54 Add dashboard memory provider switching 2026-07-07 17:27:54 -07:00
Ben Barclay
8d66e78844
feat(dashboard): expose profile names + gateway_mode on gated /api/status (#60585)
The profile+gateway topology added in #60537 sits entirely behind the
loopback/--insecure auth gate. But a hosted agent (Hermes Cloud) binds
non-loopback with OAuth, so should_require_auth is True, and NAS reads
/api/status over the network (fly-provider.ts getInstanceRuntimeStatus)
with no session token. On that gated path the whole topology block was
omitted, so the Portal could never render the profile list.

Split the topology readout by sensitivity:
- profile NAMES (profiles) + gateway_mode are low-sensitivity product
  surface and now ride the always-public status body, surviving the auth
  gate so NAS/the Portal can enumerate profiles.
- the per-gateway detail (gateways[], carrying host ports) is deployment
  recon and stays gated alongside hermes_home / config_path / env_path /
  gateway_pid / gateway_health_url.

The collector now runs unconditionally (still in the executor, off the
event loop). No new fields; only the gate placement changes.
2026-07-08 00:20:13 +00:00
Teknium
5633fa19b8
fix(dashboard): advertise truecolor to the embedded chat TUI (#60576)
Headless/hosted deploys run the dashboard server without COLORTERM in
the process environment, so chalk inside the PTY-spawned TUI child
downgraded every skin hex color to the xterm 256 palette — the default
skin's bronze banner border (#CD7F32) snapped to palette 173 (#D7875F,
salmon red) and the gold caduceus rendered red/yellow on fresh cloud
instances. Local launches never reproduced it because the operator's
interactive terminal leaks COLORTERM=truecolor into the server env.

xterm.js always renders 24-bit RGB, so the dashboard PTY child should
always advertise truecolor: backfill COLORTERM=truecolor in
_resolve_chat_argv via setdefault (an explicit operator value wins).

Verified with a clean-env PTY probe of the real TUI binary:
no COLORTERM -> 0 truecolor SGRs / 165 palette-256 (salmon 38;5;173);
with the backfill -> 166 truecolor SGRs, exact bronze 38;2;205;127;50.
2026-07-07 17:17:51 -07:00
Teknium
b062083d0a
feat(dashboard): report profile + gateway topology in /api/status (#60537)
/api/status (loopback/insecure binds only) now includes:
- profiles: every profile on the host (default + named)
- gateway_mode: none | single | multiple | multiplex
- gateways: one entry per live gateway with the host ports its
  port-binding platforms listen on, plus served_profiles when the
  default gateway is multiplexing

Ports resolve from each profile's config.yaml (top-level platforms:
wins over gateway.platforms:, matching load_gateway_config precedence)
with adapter defaults as fallback. Topology enumeration runs in an
executor so the profile scan + process-table probes stay off the event
loop, and the whole block is gated behind the same loopback-only split
as hermes_home/gateway_pid so gated binds leak nothing new.
2026-07-07 16:13:03 -07:00
Benn Denton
c3d2be073a feat(pty): periodic reaper wired into dashboard lifespan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:15:37 -07:00
Benn Denton
e10e4bca82 feat(chat): reattach /api/pty sessions via ?attach= token
Keep-alive path when ?attach=<token> is present: PTY outlives the socket
via PTY_REGISTRY, reattaches on reconnect. No token = unchanged legacy
pump (_legacy_pump). detach (not close) on disconnect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:15:37 -07:00
Ronald Reis
37a4cf9000 fix: limit desktop model pickers to explicit providers 2026-07-07 15:12:54 -07:00
Que0x
b4289200ba fix(web-server): close OAuth token TOCTOU by writing 0o600 atomically
`_save_anthropic_oauth_creds` wrote the Anthropic OAuth token file with
`os.replace(tmp, path)` followed by a post-hoc `chmod(0o600)`. Between the
rename and the chmod the token file existed at the default umask (0o644 on most
hosts) — a window in which another local user could read the access/refresh
tokens.

Write via `utils.atomic_json_write(..., mode=0o600)`, which creates the temp
with mode 0o600 *before* any content is written, fsyncs, atomically replaces,
preserves the existing file's owner, and cleans up its temp on failure. This
matches the `atomic_json_write(mode=0o600)` call already used elsewhere in this
module for the credential-pool write, and #56644's owner preservation.

Tests updated for the new mechanism, plus a check that the write goes through
`atomic_json_write(mode=0o600)` (mutation-verified).
2026-07-07 13:54:07 -07:00
Erosika
a182ddbf9f feat(memory): per-field info tooltips + name the profile in the full-config modal
Fields can declare a longer 'info' text rendered as an (i) tooltip next to
the label in both the panel and the modal; honcho uses it to spell out the
session strategy, write frequency, and recall mode semantics. The modal
description claimed 'the active profile' without saying which — show the
active gateway profile name.
2026-07-07 11:52:25 -04:00
Erosika
35099685be feat(memory): provider actions extension point
Providers with behavior beyond fields (validate a server, start a local
instance, link a CLI profile) previously had no mount besides forking the
panel. Let the schema declare actions and one generic endpoint run them:
ProviderAction on ProviderConfigSchema, POST
/api/memory/providers/{name}/actions/{action} dispatching to
ACTION_HANDLERS in the plugin's config_actions.py (path-loaded and
import-light, like the schema), profile-scoped and off the event loop.
Handlers get the submitted values dict, return a JSON-able result, and
raise ValueError for user-facing 400s. The panel renders declared actions
as generic buttons beside Save. No bundled provider declares actions yet.
2026-07-07 11:00:25 -04:00
Erosika
76c063b3d9 fix(memory): profile-scope the provider config endpoints
The settings page follows the desktop's active-profile switcher, but the
provider config calls didn't: no profileScoped() on the client and no
profile param on the backend, so a multi-profile desktop edited the serving
process's config while every surrounding card showed the selected profile's.

Accept ?profile= on both endpoints and resolve inside _profile_scope (the
skills/toolsets contract), spread profileScoped() into the two client calls,
and key the schema cache on the resolved config_schema.py path instead of
the provider name — user-installed plugins are per-profile, so one profile's
lookup must never answer for another's.
2026-07-07 10:53:43 -04:00
Ben Barclay
4b9d9b205b
fix(dashboard): use loopback host for in-container WebSocket client (#58993) [salvage #59682] (#60092)
* fix(dashboard): use loopback host for in-container WebSocket client (#58993)

Fixes #58993 - the in-container Dashboard's WebSocket client was dialing
the bind host (0.0.0.0) instead of 127.0.0.1, hijacking the host browser
when the container port was exposed.

* `hermes_cli/web_server.py::resolve_dashboard_ws_url()` now substitutes
  127.0.0.1 for any 0.0.0.0 bind host discovered via the existing
  `find_unused_port` / `get_listen_address` path. LAN IPs and explicit
  `DASHBOARD_WS_HOST` overrides pass through unchanged.
* Existing tests preserved (no regression on the explicit-bind case).

Tests in `tests/dashboard/test_ws_client_host.py` cover:
- Bind host 0.0.0.0 → ws URL uses 127.0.0.1
- Bind host 127.0.0.1 → ws URL uses 127.0.0.1 (no regression)
- Bind host 192.168.1.5 → ws URL preserves the LAN IP
- DASHBOARD_WS_HOST env override wins over auto-detection

AI-assisted fix by https://github.com/SquabbyZ/peaks-loop

(cherry picked from commit 5501dd38d6)

* chore(release): map SquabbyZ email for AUTHOR_MAP attribution (#59682)

---------

Co-authored-by: SquabbyZ <601709253@qq.com>
2026-07-07 18:33:28 +10:00
Teknium
76979a0869
fix(auth): per-profile Anthropic OAuth file + complete port-binding platform set (#57563 salvage) (#59339)
* fix(auth): resolve Anthropic OAuth file per-profile + close port-binding platform gaps

Two focused pieces salvaged from PR #57563:

1. _HERMES_OAUTH_FILE was computed at module import time — frozen before
   HERMES_HOME/profile overrides, so multiplexed profile turns read and
   wrote the DEFAULT profile's .anthropic_oauth.json (OAuth path hijack).
   Replaced with a lazy _get_hermes_oauth_file(); all web_server.py call
   sites updated.

2. _PORT_BINDING_PLATFORM_VALUES was missing whatsapp_cloud and line —
   both bind aiohttp TCP listeners, so a secondary multiplex profile
   enabling them would collide with the primary's listener instead of
   failing fast at startup.

Original work by @austinlaw076. The rest of #57563 was redundant on
main (adapter routing sweep superseded by #56854's salvage; cron secret
scope landed in fdab380a1; nested-config fallback in from_dict).

* chore(release): map austinlaw076 author email for PR #57563 salvage

* test(hermes_cli): patch _get_hermes_oauth_file instead of removed _HERMES_OAUTH_FILE constant

---------

Co-authored-by: Austin <austin@openvm067.space>
Co-authored-by: Ben <ben@nousresearch.com>
2026-07-07 08:33:06 +00:00
Ben
5eac665252 feat(status): expose nous_session_valid on /api/status for hosted-agent self-heal
A hosted agent whose Nous bootstrap session dies terminally (invalid_grant /
quarantine) looks HEALTHY to every liveness/connectivity probe — the machine,
relay ws, and dashboard all stay up — yet every inference turn hard-fails with
a provider-auth error until a human re-logs-in. Nothing currently surfaces that
condition to NAS.

Add get_nous_session_validity() (valid|terminal|unknown), classified from local
auth-store state (no working token required), and report it on the public
/api/status payload. NAS's 2-min health sweep reads it and re-mints the
bootstrap session in place on 'terminal'.

Anti-flap: only a terminal failure (relogin_required / persisted quarantine
marker with tokens cleared) maps to 'terminal'; transient/mid-rotation blips and
merely-expiring tokens report 'unknown' so a healthy box never triggers a
spurious re-mint.

Part of the hosted-agent bootstrap-session self-heal (NAS side reads this field).
2026-07-07 00:53:56 -07:00
helix4u
4b4f058860 fix(tui): probe active custom model provider 2026-07-06 13:08:50 -07:00
Brooklyn Nicholson
409560a7d9 Merge remote-tracking branch 'origin/main' into bb/serve-headless-no-web-build 2026-07-06 12:51:15 -05:00