Compare commits

...

8 Commits

Author SHA1 Message Date
alt-glitch
9aed1b2fe7 fix: restore accidentally deleted websocket close code assertion 2026-04-25 10:06:46 +05:30
alt-glitch
f87dbdf0a8 fix(web): reject empty values in PUT /api/env
The endpoint accepted empty strings, allowing any .env key to be
silently blanked out from the web UI. Add Pydantic validators to
reject empty keys and values.
2026-04-25 09:59:11 +05:30
brooklyn!
6407b3d5b3 Merge pull request #15488 from kevin-ho/fix/tui-mouse-toggle
fix(tui): proactive mouse disable on ConPTY + /mouse toggle command
2026-04-24 22:43:47 -05:00
Teknium
0a59994030 fix(cli-config): keep delegation overrides commented in example 2026-04-24 20:38:58 -07:00
MorAlekss
0ed37c0ca4 docs(delegate): document max_concurrent_children and max_spawn_depth + cost warning 2026-04-24 20:38:58 -07:00
Vesper (on behalf of Director)
1c8ce33d51 fix(tui): proactive mouse disable on ConPTY + /mouse toggle command
On Windows WSL2, ConPTY implicitly enables mouse event injection when
the alternate screen buffer (DEC 1049) is entered, causing raw escape
sequences to appear in the transcript as ghost characters.

Fix (two parts):
1. ConPTY fix: send DISABLE_MOUSE_TRACKING immediately after entering
   alt screen when mouse tracking is off (AlternateScreen.tsx)
2. Runtime toggle: add /mouse [on|off|toggle] slash command with config
   persistence (display.tui_mouse) so users can manage this at runtime

The env var HERMES_TUI_DISABLE_MOUSE continues to work as the initial
default, but can now be overridden via /mouse and persisted to config.

Closes: upstream ConPTY mouse injection issue
Credits: OutThisLife / PR #13716 for the toggle concept
2026-04-24 20:32:12 -07:00
Clifford Garwood
2182de55bb fix(matrix): drop needless DeviceID import + mock put_device_id in tests
Two adjustments to make CI pass:

- In gateway/platforms/matrix.py: `DeviceID` is `NewType("DeviceID", str)`,
  so passing `client.device_id` directly (already a str) works identically
  at runtime. The explicit import was cosmetic and tripped CI environments
  where `mautrix.types` doesn't re-export DeviceID at the expected path
  ("cannot import name 'DeviceID' from 'mautrix.types' (unknown location)").

- In tests/gateway/test_matrix.py: add `put_device_id` to the hand-written
  `PgCryptoStore` fake so the three encryption-path tests
  (test_connect_with_access_token_and_encryption,
  test_connect_uses_configured_device_id_over_whoami,
  test_connect_registers_encrypted_event_handler_when_encryption_on) can
  exercise the new crypto-store binding without AttributeError.
2026-04-25 07:17:03 +05:30
Clifford Garwood
3cf13747b7 fix(matrix): bind PgCryptoStore device_id so fresh E2EE installs work
PgCryptoStore.__init__ defaults _device_id to "" and put_account writes
that blank value into crypto_account. The UPSERT's ON CONFLICT DO UPDATE
clause deliberately does not touch device_id, so once the row is written
blank it stays blank forever — breaking every downstream device-scoped
olm operation. Peers' to-device olm ciphertext can't match our identity
key, no megolm sessions ever land, and the user sees "hermes is in the
room but never responds to encrypted messages".

Fix: call put_device_id(client.device_id) immediately after
crypto_store.open() and before olm.load(). This sets the store's
in-memory _device_id so the first put_account INSERT writes the correct
value from the start.

Observable symptoms without the fix, on a fresh crypto.db:
  - crypto_account.device_id = ""
  - crypto_tracked_user: 0 rows
  - crypto_device: 0 rows
  - crypto_olm_session: 0 rows
  - crypto_megolm_inbound_session: 0 rows
  - "No one-time keys nor device keys got when trying to share keys"
    warning on every startup
  - "olm event doesn't contain ciphertext for this device" DecryptionError
    on any inbound to-device event
  - Encrypted room messages arrive but never decrypt

After the fix (wiped crypto.db + restart):
  - device_id populated with actual runtime device (e.g. CZIKTRFLOV)
  - all counts populate from sync as expected
  - encrypted DMs flow normally

Who hits this: anyone with a fresh crypto.db — includes first-time matrix
E2EE setup, nio→mautrix migrations (since matrix.py removes the legacy
pickle on startup, creating a fresh SQLite store), and anyone who wipes
crypto.db to start over. Existing installs that somehow already have a
non-blank device_id would be unaffected, but no prior code path writes
it correctly, so that set is likely empty.
2026-04-25 07:17:03 +05:30
17 changed files with 168 additions and 11 deletions

View File

@@ -790,8 +790,11 @@ code_execution:
# Supports single tasks and batch mode (default 3 parallel, configurable).
delegation:
max_iterations: 50 # Max tool-calling turns per child (default: 50)
# max_concurrent_children: 3 # Max parallel child agents (default: 3)
# max_spawn_depth: 1 # Tree depth cap (1-3, default: 1 = flat). Raise to 2 or 3 to allow orchestrator children to spawn their own workers.
# max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling).
# WARNING: values above 10 multiply API cost linearly.
# max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat).
# Raise to 2 to allow workers to spawn their own subagents.
# Requires role="orchestrator" on intermediate agents.
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
# inherit_mcp_toolsets: true # When explicit child toolsets are narrowed, also keep the parent's MCP toolsets (default: true). Set false for strict intersection.
# model: "google/gemini-3-flash-preview" # Override model for subagents (empty = inherit parent)

View File

@@ -532,6 +532,20 @@ class MatrixAdapter(BasePlatformAdapter):
)
await crypto_store.open()
# Bind the store to the runtime device_id before any
# put_account() runs. PgCryptoStore defaults _device_id
# to "" and its crypto_account UPSERT never updates the
# device_id column on conflict — so once put_account
# writes blank, it stays blank forever. That breaks
# every downstream device-scoped olm operation: peer
# to-device ciphertext can't find our identity key and
# no megolm sessions ever land. Setting _device_id here
# (in-memory; the on-disk row may not exist yet) makes
# the first put_account write the correct value.
# DeviceID is a NewType(str) so plain str works at runtime.
if client.device_id:
await crypto_store.put_device_id(client.device_id)
crypto_state = _CryptoStateStore(state_store, self._joined_rooms)
olm = OlmMachine(client, crypto_store, crypto_state)

View File

@@ -53,7 +53,7 @@ try:
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
except ImportError:
raise SystemExit(
"Web UI requires fastapi and uvicorn.\n"
@@ -425,6 +425,20 @@ class EnvVarUpdate(BaseModel):
key: str
value: str
@field_validator("key")
@classmethod
def key_must_be_nonempty(cls, v: str) -> str:
if not v.strip():
raise ValueError("key must not be empty")
return v
@field_validator("value")
@classmethod
def value_must_be_nonempty(cls, v: str) -> str:
if not v.strip():
raise ValueError("value must not be empty; use DELETE /api/env to remove a key")
return v
class EnvVarDelete(BaseModel):
key: str

View File

@@ -503,6 +503,7 @@ AUTHOR_MAP = {
"codex@openai.invalid": "teknium1",
"screenmachine@gmail.com": "teknium1",
"chenzeshi@live.com": "chen1749144759",
"mor.aleksandr@yahoo.com": "MorAlekss",
}

View File

@@ -197,10 +197,14 @@ def _make_fake_mautrix():
self.account_id = account_id
self.pickle_key = pickle_key
self.db = db
self._device_id = ""
async def open(self):
pass
async def put_device_id(self, device_id):
self._device_id = device_id
mautrix_crypto_store_asyncpg.PgCryptoStore = PgCryptoStore
# --- mautrix.util ---

View File

@@ -1925,3 +1925,34 @@ class TestPtyWebSocket:
):
pass
assert exc.value.code == 4400
class TestEnvVarUpdateValidation:
"""PUT /api/env must reject empty values to prevent .env key destruction."""
def test_rejects_empty_value(self):
from hermes_cli.web_server import EnvVarUpdate
import pydantic
with pytest.raises(pydantic.ValidationError):
EnvVarUpdate(key="SOME_KEY", value="")
def test_rejects_whitespace_only_value(self):
from hermes_cli.web_server import EnvVarUpdate
import pydantic
with pytest.raises(pydantic.ValidationError):
EnvVarUpdate(key="SOME_KEY", value=" ")
def test_accepts_nonempty_value(self):
from hermes_cli.web_server import EnvVarUpdate
update = EnvVarUpdate(key="SOME_KEY", value="sk-abc123")
assert update.value == "sk-abc123"
def test_rejects_empty_key(self):
from hermes_cli.web_server import EnvVarUpdate
import pydantic
with pytest.raises(pydantic.ValidationError):
EnvVarUpdate(key="", value="some-value")

View File

@@ -276,7 +276,14 @@ def _get_max_concurrent_children() -> int:
val = cfg.get("max_concurrent_children")
if val is not None:
try:
return max(1, int(val))
result = max(1, int(val))
if result > 10:
logger.warning(
"delegation.max_concurrent_children=%d: each child consumes API tokens "
"independently. High values multiply cost linearly.",
result,
)
return result
except (TypeError, ValueError):
logger.warning(
"delegation.max_concurrent_children=%r is not a valid integer; "
@@ -2229,8 +2236,8 @@ DELEGATE_TASK_SCHEMA = {
"never enter your context window.\n\n"
"TWO MODES (one of 'goal' or 'tasks' is required):\n"
"1. Single task: provide 'goal' (+ optional context, toolsets)\n"
"2. Batch (parallel): provide 'tasks' array with up to delegation.max_concurrent_children items (default 3). "
"All run concurrently and results are returned together.\n\n"
"2. Batch (parallel): provide 'tasks' array with up to delegation.max_concurrent_children items (default 3, configurable via config.yaml, no hard ceiling). "
"All run concurrently and results are returned together. Nested delegation requires role='orchestrator' and delegation.max_spawn_depth >= 2.\n\n"
"WHEN TO USE delegate_task:\n"
"- Reasoning-heavy subtasks (debugging, code review, research synthesis)\n"
"- Tasks that would flood your context with intermediate data\n"

View File

@@ -2789,6 +2789,23 @@ def _(rid, params: dict) -> dict:
_write_config_key("display.tui_statusbar", nv)
return _ok(rid, {"key": key, "value": nv})
if key == "mouse":
raw = str(value or "").strip().lower()
display = _load_cfg().get("display") if isinstance(_load_cfg().get("display"), dict) else {}
current = bool(display.get("tui_mouse", True))
if raw in ("", "toggle"):
nv = not current
elif raw == "on":
nv = True
elif raw == "off":
nv = False
else:
return _err(rid, 4002, f"unknown mouse value: {value}")
_write_config_key("display.tui_mouse", nv)
return _ok(rid, {"key": key, "value": "on" if nv else "off"})
if key in ("prompt", "personality", "skin"):
try:
cfg = _load_cfg()
@@ -2917,6 +2934,10 @@ def _(rid, params: dict) -> dict:
display.get("tui_statusbar", "top") if isinstance(display, dict) else "top"
)
return _ok(rid, {"value": _coerce_statusbar(raw)})
if key == "mouse":
display = _load_cfg().get("display")
on = display.get("tui_mouse", True) if isinstance(display, dict) else True
return _ok(rid, {"value": "on" if on else "off"})
if key == "mtime":
cfg_path = _hermes_home / "config.yaml"
try:

View File

@@ -53,7 +53,7 @@ export function AlternateScreen(t0: Props) {
}
writeRaw(
ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : '')
ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING)
)
ink?.setAltScreenActive(true, mouseTracking)

View File

@@ -1121,6 +1121,23 @@ export default class Ink {
this.repaint()
}
}
/**
* Toggle mouse tracking at runtime while the alt screen is active.
* Writes the appropriate DEC reset/set sequences so the terminal
* (and ConPTY on Windows WSL2) reflects the change immediately.
*/
setAltScreenMouseTracking(enabled: boolean): void {
if (this.altScreenMouseTracking === enabled) {
return
}
this.altScreenMouseTracking = enabled
if (this.altScreenActive) {
this.options.stdout.write(enabled ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING)
}
}
get isAltScreenActive(): boolean {
return this.altScreenActive
}

View File

@@ -1,18 +1,21 @@
import { useStore } from '@nanostores/react'
import { GatewayProvider } from './app/gatewayContext.js'
import { useMainApp } from './app/useMainApp.js'
import { $uiState } from './app/uiStore.js'
import { AppLayout } from './components/appLayout.js'
import { MOUSE_TRACKING } from './config/env.js'
import type { GatewayClient } from './gatewayClient.js'
export function App({ gw }: { gw: GatewayClient }) {
const { appActions, appComposer, appProgress, appStatus, appTranscript, gateway } = useMainApp(gw)
const { mouseTracking } = useStore($uiState)
return (
<GatewayProvider value={gateway}>
<AppLayout
actions={appActions}
composer={appComposer}
mouseTracking={MOUSE_TRACKING}
mouseTracking={mouseTracking}
progress={appProgress}
status={appStatus}
transcript={appTranscript}

View File

@@ -88,6 +88,7 @@ export interface UiState {
detailsMode: DetailsMode
info: null | SessionInfo
inlineDiffs: boolean
mouseTracking: boolean
sections: SectionVisibility
showCost: boolean
showReasoning: boolean

View File

@@ -84,6 +84,27 @@ export const coreCommands: SlashCommand[] = [
run: (_arg, ctx) => ctx.session.die()
},
{
aliases: ['scroll'],
help: 'toggle mouse/wheel tracking [on|off|toggle]',
name: 'mouse',
run: (arg, ctx) => {
const current = ctx.ui.mouseTracking
const next = flagFromArg(arg, current)
if (next === null) {
return ctx.transcript.sys('usage: /mouse [on|off|toggle]')
}
patchUiState({ mouseTracking: next })
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'mouse', value: next ? 'on' : 'off' })
.catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next ? 'on' : 'off'}`))
}
},
{
aliases: ['new'],
help: 'start a new session',

View File

@@ -2,6 +2,7 @@ import { atom } from 'nanostores'
import { ZERO } from '../domain/usage.js'
import { DEFAULT_THEME } from '../theme.js'
import { MOUSE_TRACKING } from '../config/env.js'
import type { UiState } from './interfaces.js'
@@ -12,6 +13,7 @@ const buildUiState = (): UiState => ({
detailsMode: 'collapsed',
info: null,
inlineDiffs: true,
mouseTracking: MOUSE_TRACKING,
sections: {},
showCost: false,
showReasoning: false,

View File

@@ -46,6 +46,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
compact: !!d.tui_compact,
detailsMode: resolveDetailsMode(d),
inlineDiffs: d.inline_diffs !== false,
mouseTracking: d.tui_mouse !== false,
sections: resolveSections(d.sections),
showCost: !!d.show_cost,
showReasoning: !!d.show_reasoning,

View File

@@ -61,6 +61,7 @@ export interface ConfigDisplayConfig {
streaming?: boolean
thinking_mode?: string
tui_compact?: boolean
tui_mouse?: boolean
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
}

View File

@@ -216,8 +216,24 @@ Restricting toolsets keeps the subagent focused and prevents accidental side eff
## Constraints
- **Default 3 parallel tasks** batches default to 3 concurrent subagents (configurable via `delegation.max_concurrent_children` in config.yaml no hard ceiling, only a floor of 1)
- **Nested delegation is opt-in** leaf subagents (default) cannot call `delegate_task`, `clarify`, `memory`, `send_message`, or `execute_code`. Orchestrator subagents (`role="orchestrator"`) retain `delegate_task` for further delegation, but only when `delegation.max_spawn_depth` is raised above the default of 1 (1-3 supported); the other four remain blocked. Disable globally via `delegation.orchestrator_enabled: false`.
- **Default 3 parallel tasks**: batches default to 3 concurrent subagents (configurable via `delegation.max_concurrent_children` in config.yaml, no hard ceiling, only a floor of 1)
- **Nested delegation is opt-in**: leaf subagents (default) cannot call `delegate_task`, `clarify`, `memory`, `send_message`, or `execute_code`. Orchestrator subagents (`role="orchestrator"`) retain `delegate_task` for further delegation, but only when `delegation.max_spawn_depth` is raised above the default of 1 (1-3 supported); the other four remain blocked. Disable globally via `delegation.orchestrator_enabled: false`.
### Tuning Concurrency and Depth
| Config | Default | Range | Effect |
|--------|---------|-------|--------|
| `max_concurrent_children` | 3 | >=1 | Parallel batch size per `delegate_task` call |
| `max_spawn_depth` | 1 | 1-3 | How many delegation levels can spawn further |
Example: running 30 parallel workers with nested subagents:
```yaml
delegation:
max_concurrent_children: 30
max_spawn_depth: 2
```
- **Separate terminals** — each subagent gets its own terminal session with separate working directory and state
- **No conversation history** — subagents see only the `goal` and `context` the parent agent passes when calling `delegate_task`
- **Default 50 iterations** — set `max_iterations` lower for simple tasks to save cost