Compare commits

...

9 Commits

Author SHA1 Message Date
alt-glitch
2246c81d0d chore: add poruru-code to AUTHOR_MAP 2026-04-25 10:05:44 +05:30
alt-glitch
8877688b34 fix(hindsight): preserve custom timeout on reconfig
post_setup() used self._config to read the existing timeout, but
self._config is None during setup. Read from .env instead.
2026-04-25 09:55:13 +05:30
poruru-code
9d42aca29a fix(hindsight): preserve existing LLM key on blank local_embedded setup
Salvaged from PR #15309 (poruru-code) + PR #15233 (LeonSGP43).
Cherry-picked key preservation logic and config hardening from #15309,
combined with masked-key prompt UX from #15233.
2026-04-25 09:53:25 +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 238 additions and 16 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

@@ -478,7 +478,9 @@ class HindsightMemoryProvider(MemoryProvider):
existing = {}
if config_path.exists():
try:
existing = json.loads(config_path.read_text())
parsed = json.loads(config_path.read_text())
if isinstance(parsed, dict):
existing = parsed
except Exception:
pass
existing.update(values)
@@ -589,12 +591,35 @@ class HindsightMemoryProvider(MemoryProvider):
val = input(f" LLM model [{default_model}]: ").strip()
provider_config["llm_model"] = val or default_model
sys.stdout.write(" LLM API key: ")
existing_llm_key = os.environ.get("HINDSIGHT_LLM_API_KEY", "")
if not existing_llm_key:
existing_llm_key = _load_simple_env(Path(hermes_home) / ".env").get(
"HINDSIGHT_LLM_API_KEY",
"",
)
if not existing_llm_key:
saved_config = dict(provider_config)
config_path = Path(hermes_home) / "hindsight" / "config.json"
try:
parsed = json.loads(config_path.read_text(encoding="utf-8"))
if isinstance(parsed, dict):
saved_config.update(parsed)
except Exception:
pass
saved_config.update(provider_config)
existing_llm_key = _load_simple_env(
_embedded_profile_env_path(saved_config)
).get("HINDSIGHT_API_LLM_API_KEY", "")
if existing_llm_key:
masked = f"...{existing_llm_key[-4:]}" if len(existing_llm_key) > 4 else "set"
sys.stdout.write(f" LLM API key (current: {masked}, blank to keep): ")
else:
sys.stdout.write(" LLM API key: ")
sys.stdout.flush()
llm_key = getpass.getpass(prompt="") if sys.stdin.isatty() else sys.stdin.readline().strip()
# Always write explicitly (including empty) so the provider sees ""
# rather than a missing variable. The daemon reads from .env at
# startup and fails when HINDSIGHT_LLM_API_KEY is unset.
if not llm_key and existing_llm_key:
llm_key = existing_llm_key
env_writes["HINDSIGHT_LLM_API_KEY"] = llm_key
# Step 4: Save everything
@@ -602,7 +627,9 @@ class HindsightMemoryProvider(MemoryProvider):
provider_config["recall_budget"] = "mid"
# Read existing timeout from config if present, otherwise use default
existing_timeout = self._config.get("timeout") if self._config else None
timeout_val = existing_timeout if existing_timeout else _DEFAULT_TIMEOUT
if not existing_timeout:
existing_timeout = _load_simple_env(Path(hermes_home) / ".env").get("HINDSIGHT_TIMEOUT")
timeout_val = int(existing_timeout) if existing_timeout else _DEFAULT_TIMEOUT
provider_config["timeout"] = timeout_val
env_writes["HINDSIGHT_TIMEOUT"] = str(timeout_val)
config["memory"]["provider"] = "hindsight"

View File

@@ -503,6 +503,9 @@ AUTHOR_MAP = {
"codex@openai.invalid": "teknium1",
"screenmachine@gmail.com": "teknium1",
"chenzeshi@live.com": "chen1749144759",
"mor.aleksandr@yahoo.com": "MorAlekss",
"poruru.code@gmail.com": "poruru-code",
"138243371+poruru-code@users.noreply.github.com": "poruru-code",
}

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

@@ -329,8 +329,89 @@ class TestPostSetup:
profile_env = user_home / ".hindsight" / "profiles" / "hermes.env"
assert profile_env.exists()
assert (hermes_home / ".env").read_text() == "HINDSIGHT_LLM_API_KEY=existing-key\nHINDSIGHT_TIMEOUT=120\n"
assert "HINDSIGHT_API_LLM_API_KEY=existing-key\n" in profile_env.read_text()
def test_local_embedded_setup_preserves_existing_key_from_nondefault_profile_env_when_input_left_blank(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes-home"
user_home = tmp_path / "user-home"
user_home.mkdir()
monkeypatch.setenv("HOME", str(user_home))
selections = iter([1, 0]) # local_embedded, openai
monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections))
monkeypatch.setattr("shutil.which", lambda name: None)
monkeypatch.setattr("builtins.input", lambda prompt="": "")
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
monkeypatch.setattr("getpass.getpass", lambda prompt="": "")
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
provider = HindsightMemoryProvider()
provider.save_config({"profile": "coder"}, str(hermes_home))
profile_env = user_home / ".hindsight" / "profiles" / "coder.env"
profile_env.parent.mkdir(parents=True, exist_ok=True)
profile_env.write_text("HINDSIGHT_API_LLM_API_KEY=existing-key\n")
provider.post_setup(str(hermes_home), {"memory": {}})
assert (hermes_home / ".env").read_text() == "HINDSIGHT_LLM_API_KEY=existing-key\nHINDSIGHT_TIMEOUT=120\n"
assert "HINDSIGHT_API_LLM_API_KEY=existing-key\n" in profile_env.read_text()
def test_local_embedded_setup_ignores_nondict_saved_config_when_input_left_blank(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes-home"
user_home = tmp_path / "user-home"
user_home.mkdir()
monkeypatch.setenv("HOME", str(user_home))
selections = iter([1, 0]) # local_embedded, openai
monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections))
monkeypatch.setattr("shutil.which", lambda name: None)
monkeypatch.setattr("builtins.input", lambda prompt="": "")
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
monkeypatch.setattr("getpass.getpass", lambda prompt="": "")
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
config_path = hermes_home / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text("[]")
env_path = hermes_home / ".env"
env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.write_text("HINDSIGHT_LLM_API_KEY=existing-key\n")
provider = HindsightMemoryProvider()
provider.post_setup(str(hermes_home), {"memory": {}})
profile_env = user_home / ".hindsight" / "profiles" / "hermes.env"
assert profile_env.exists()
assert (hermes_home / ".env").read_text() == "HINDSIGHT_LLM_API_KEY=existing-key\nHINDSIGHT_TIMEOUT=120\n"
assert "HINDSIGHT_API_LLM_API_KEY=existing-key\n" in profile_env.read_text()
def test_local_embedded_setup_preserves_existing_timeout(self, tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes-home"
user_home = tmp_path / "user-home"
user_home.mkdir()
monkeypatch.setenv("HOME", str(user_home))
selections = iter([1, 0]) # local_embedded, openai
monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections))
monkeypatch.setattr("shutil.which", lambda name: None)
monkeypatch.setattr("builtins.input", lambda prompt="": "")
monkeypatch.setattr("sys.stdin.isatty", lambda: True)
monkeypatch.setattr("getpass.getpass", lambda prompt="": "sk-test")
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
env_path = hermes_home / ".env"
env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.write_text("HINDSIGHT_LLM_API_KEY=sk-test\nHINDSIGHT_TIMEOUT=300\n")
provider = HindsightMemoryProvider()
provider.post_setup(str(hermes_home), {"memory": {}})
env_content = (hermes_home / ".env").read_text()
assert "HINDSIGHT_TIMEOUT=300" in env_content
# ---------------------------------------------------------------------------
# Tool handler tests

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