Compare commits

...

7 Commits

Author SHA1 Message Date
alt-glitch
5eaceb82af fix(setup): skip AUXILIARY_VISION_MODEL write when input is blank
Blank input at the non-OpenAI vision model prompt was unconditionally
written to .env, overwriting any existing custom model.
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 141 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

@@ -867,7 +867,8 @@ def setup_model_provider(config: dict, *, quick: bool = False):
)
else:
_selected_vision_model = prompt(" Vision model (blank = use main/custom default)").strip()
save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
if _selected_vision_model:
save_env_value("AUXILIARY_VISION_MODEL", _selected_vision_model)
print_success(
f"Vision configured with {_base_url}"
+ (f" ({_selected_vision_model})" if _selected_vision_model else "")

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

@@ -527,3 +527,20 @@ def test_offer_launch_chat_manual_fallback_when_unresolvable(monkeypatch, capsys
captured = capsys.readouterr()
assert "Run 'hermes chat' manually" in captured.out
class TestVisionModelBlankGuard:
"""Regression: blank vision model input must not overwrite existing .env value."""
def test_save_env_value_overwrites_with_empty(self, tmp_path, monkeypatch):
"""Proves save_env_value has no internal guard — caller must guard."""
env_path = tmp_path / ".env"
env_path.write_text("AUXILIARY_VISION_MODEL=custom-model\n")
monkeypatch.setattr("hermes_cli.config.get_env_path", lambda: env_path)
from hermes_cli.config import save_env_value
save_env_value("AUXILIARY_VISION_MODEL", "")
# save_env_value DOES overwrite — this is by design.
# The guard must be at the call site in setup.py.
assert "AUXILIARY_VISION_MODEL=\n" in env_path.read_text()

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