Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a34d5e2
fix(chat): settle stream_end without done
ai-ag2026 May 24, 2026
bb9698e
Stage 403: PR #2852 — fix(chat): settle stream_end without done by @a…
May 24, 2026
145a442
ci(windows): rework #2811 with mock hermes_cli (maintainer ask, optio…
Koraji95-coder May 24, 2026
ae6b6b1
ci(windows): make taskkill no-op when server.py already exited
Koraji95-coder May 24, 2026
3aae462
Stage 403: PR #2811 — ci(windows): add native-Windows startup E2E wor…
May 24, 2026
2c9fc4c
style(composer): responsive composer-box max-width via clamp()
Koraji95-coder May 24, 2026
029d95a
style(composer): address Copilot review on PR #2812
Koraji95-coder May 24, 2026
a5c937e
Stage 403: PR #2812 — style(composer): clamp composer-box max-width o…
May 24, 2026
71ba863
fix(terminal): drop PR_SET_PDEATHSIG preexec_fn that killed every Lin…
May 24, 2026
4d8a80b
Stage 403: PR #2854 — fix(terminal): drop PR_SET_PDEATHSIG preexec_fn…
May 24, 2026
5d0d2bd
fix(updates): apply path must follow check-side fall-through past the…
May 24, 2026
7be9a26
feat: PATCH /api/mcp/servers/{name} — enable/disable toggle
roryford May 23, 2026
a290af6
Stage 403: PR #2855 — fix(updates): apply path must follow check-side…
May 24, 2026
af1d26a
Stage 403: PR #2776 — feat: PATCH /api/mcp/servers/{name} enable/disa…
May 24, 2026
c77936f
feat(i18n): add Turkish (tr) locale support
ugur-murat-alt May 22, 2026
6c811dc
fix(i18n): address Turkish locale review feedback
ugur-murat-alt May 22, 2026
d4603b0
fix(i18n): correct double-escaped ellipsis in Turkish locale
ugur-murat-alt May 23, 2026
4fb5749
Stage 403: PR #2772 — feat(i18n): add Turkish (tr) locale by @vaur94
May 24, 2026
f92eff5
Stage 403: i18n parity — Turkish translations for 9 MCP/VS-Code/ignor…
May 24, 2026
130be3d
Stage 403: Opus pre-release fixes (1 MUST-FIX + 3 SHOULD-FIX)
May 24, 2026
d84f8b2
Stamp CHANGELOG for v0.51.127 (Release CY / stage-batch9 / 7-PR low-r…
May 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions .github/workflows/native-windows-startup.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
name: Native Windows startup

# Runs on PRs that touch start.ps1 (or this workflow). Validates the
# native-Windows launch script catches the bug classes the recent
# Windows-only batch caught manually (#2805 WOW64 ProgramFiles redirect,
# #2806 venv-portability claim, #2807 port-parse + finally-cleanup).
#
# Scope (per nesquena-hermes comment on #2811 — option 1, mock-only):
# hermes-agent is not published to PyPI, so we cannot pip-install it on
# the runner. Instead we stub a hermes_cli/ directory next to a sibling
# hermes-agent/ folder — just enough for start.ps1's existence guard to
# pass. The workflow then runs start.ps1 for a few seconds and asserts
# that none of start.ps1's own Write-Error guards fired. Server-boot
# regressions remain covered by the Linux jobs and docker-smoke.yml.

on:
pull_request:
paths:
- 'start.ps1'
- '.github/workflows/native-windows-startup.yml'
workflow_dispatch:

jobs:
native-windows-startup:
name: start.ps1 path discovery (mock hermes-agent)
runs-on: windows-latest
timeout-minutes: 8

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'

# Create the WebUI venv. start.ps1 prefers $AgentDir\venv if it
# exists, then falls back to the python on PATH. We create a
# WebUI-local venv to mirror the README's documented native path
# and to give start.ps1 a real python.exe to invoke.
- name: Create venv (README path)
shell: pwsh
run: |
python -m venv venv
if (-not (Test-Path venv\Scripts\python.exe)) {
throw "venv\Scripts\python.exe missing after venv create"
}

# Mock-only hermes-agent provisioning. We can't pip-install
# hermes-agent (not on PyPI), so we stub the minimum that
# start.ps1's `Test-Path hermes_cli -PathType Container` guard
# needs to pass. server.py would crash on this stub at import
# time — we deliberately do NOT probe /health below.
- name: Stub hermes-agent (mock hermes_cli only)
shell: pwsh
run: |
$agentDir = Join-Path (Split-Path -Parent $PWD) 'hermes-agent'
$cliDir = Join-Path $agentDir 'hermes_cli'
New-Item -ItemType Directory -Force -Path $cliDir | Out-Null
Set-Content -Path (Join-Path $cliDir '__init__.py') -Value '# stub for CI path-discovery test only'
"HERMES_WEBUI_AGENT_DIR=$agentDir" >> $env:GITHUB_ENV
Write-Host "Stub hermes-agent provisioned at $agentDir"

# Run start.ps1 and verify it passes its own discovery guards
# without erroring out. server.py will exit non-zero on the stub
# (no real CLI code) — that's expected and not asserted against.
# We only fail if start.ps1's own Write-Error guards fire.
- name: Run start.ps1 + verify path discovery
shell: pwsh
run: |
$stdout = Join-Path $env:RUNNER_TEMP 'start-ps1.out'
$stderr = Join-Path $env:RUNNER_TEMP 'start-ps1.err'
$proc = Start-Process -FilePath 'pwsh' `
-ArgumentList '-NoLogo','-File','.\start.ps1' `
-WorkingDirectory $PWD `
-PassThru `
-RedirectStandardOutput $stdout `
-RedirectStandardError $stderr
"SERVER_PID=$($proc.Id)" >> $env:GITHUB_ENV
Write-Host "Spawned start.ps1 wrapper PID $($proc.Id)"

# Path discovery is sub-second; the 8s buffer lets the python
# launch land in the logs (and immediately exit on the stub).
Start-Sleep -Seconds 8

Write-Host "===== start.ps1 stdout ====="
$stdoutContent = if (Test-Path $stdout) { Get-Content $stdout -Raw } else { '<empty>' }
Write-Host $stdoutContent
Write-Host "===== start.ps1 stderr ====="
$stderrContent = if (Test-Path $stderr) { Get-Content $stderr -Raw } else { '<empty>' }
Write-Host $stderrContent

# Pattern set: every Write-Error message start.ps1 can emit on
# its own discovery path. If any of these appear in stderr,
# path discovery regressed and the job must fail.
$guardErrors = @(
'Python 3 is required',
'hermes-agent not found',
'HERMES_WEBUI_AGENT_DIR is set to',
'is not a valid integer port',
'is out of TCP-port range',
'server.py not found'
)
foreach ($msg in $guardErrors) {
if ($stderrContent -and $stderrContent -match [regex]::Escape($msg)) {
throw "REGRESSION: start.ps1 errored on guard '$msg' - path discovery failed."
}
}
Write-Host "OK: start.ps1 path discovery - all guards passed."

# taskkill /T walks the process tree, /F forces. taskkill returns
# 128 ("process not found") if the PID is already gone — that's
# the expected steady state for this mock-only workflow because
# server.py exits immediately on the stub hermes_cli. Reset
# $LASTEXITCODE so the step never fails on the cleanup itself.
- name: Stop background server (tree-kill)
if: always()
shell: pwsh
run: |
if ($env:SERVER_PID) {
& taskkill /PID $env:SERVER_PID /T /F 2>&1 | Out-Host
$global:LASTEXITCODE = 0
}
# Belt-and-suspenders: kill anything still bound to 8787.
$hanging = Get-NetTCPConnection -LocalPort 8787 -State Listen -ErrorAction SilentlyContinue
if ($hanging) {
foreach ($c in $hanging) {
try { Stop-Process -Id $c.OwningProcess -Force -ErrorAction Stop } catch {}
}
}
exit 0
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,36 @@

## [Unreleased]

## [v0.51.127] — 2026-05-24 — Release CY (stage-batch9 — 7-PR low-risk batch — brick-class Linux + brick-class update apply + composer wide-screen + Turkish locale + MCP toggle + SSE settlement + Windows CI)

### Fixed

- **PR #2854** by @nesquena-hermes — Embedded terminal opens then immediately closes with `[terminal closed]` on every Linux install past `71d8a8fb`. Root cause: `_terminal_shell_preexec_fn` set `PR_SET_PDEATHSIG=SIGTERM` on the PTY shell so orphans would die when WebUI crashed, but `PR_SET_PDEATHSIG` is **per-thread**, not per-process. WebUI uses `ThreadingHTTPServer`, so each HTTP request runs in its own short-lived worker thread; when the request handler returns and the worker thread exits, the kernel sees the pdeathsig-parent thread has died and SIGTERMs the PTY shell within ~10ms. macOS users were unaffected because `libc.prctl` doesn't exist there. Fix: drop the `preexec_fn` entirely; rely on `atexit.register(close_all_terminals)` for graceful shutdown and explicit `close_terminal` for user-driven close. Adds `tests/test_terminal_process_cleanup.py::test_pty_shell_survives_when_spawning_thread_exits` (real PTY shell spawned via worker thread, asserts shell alive after 500ms grace) plus static-check that `preexec_fn` cannot be re-introduced. Closes #2853.

- **PR #2855** by @nesquena-hermes — "Update Now" loops for every user past the latest tag (#2846). After #2758 the update check correctly fell through to branch comparison when `HEAD` had moved past the latest `v*` tag, but `_select_apply_compare_ref` still returned `tags[0]` — so `git pull --ff-only v2026.5.16` no-op'd, the server bounced, and the banner reappeared unchanged. `apply_force_update` had the same bug except worse (would `git reset --hard v2026.5.16` and rewind the checkout 254 commits). Fix: extract `_head_is_past_latest_tag(path, current_tag)` and have both check and apply paths consult it. Opus pre-release review caught a "case D" parameter-asymmetry drift (HEAD on older tag + commits + newer tag exists → predicate flipped between the two callsites) and patched the apply-side predicate to use `current_tag` + a `behind == 0` gate, exactly mirroring the check-side rule. Adds `test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_exists`. Closes #2846.

- **PR #2852** by @ai-ag2026 — Chat `stream_end` handler now settles from the persisted session when `done` was not received or replayed, instead of leaving the active pane with live `Thinking` / assistant DOM and inflight state projected indefinitely. Reconnect / journal / replay paths can deliver `stream_end` without preceding `done`; the prior code treated `stream_end` as transport-only close. Duplicate / replayed `done` events are also made idempotent before completion sound / final render side effects. Opus pre-release review added a post-await race guard inside `_restoreSettledSession` to catch the case where a late `done` event runs the finalize path while the settlement is awaiting the `/api/session` roundtrip. Adds 4 new regression tests across `tests/test_1694_terminal_cleanup_ownership.py` covering both `stream_end`-without-`done` and duplicate-`done` paths.

- **PR #2811** by @Koraji95-coder — Native-Windows startup E2E workflow now self-tests on PR push (closes the post-#2783 gap where Windows-only regressions like the WOW64 ProgramFiles redirect could only be caught after release). Reworked per maintainer feedback to use a stub `hermes_cli/__init__.py` next to a sibling `hermes-agent/` folder rather than `pip install hermes-agent` (which is not on PyPI). Workflow runs `start.ps1` for 8s and asserts none of its `Write-Error` guards fired (no Python, no agent dir, bad port, missing `hermes_cli`, missing `server.py`). PowerShell syntax + path discovery is the testable surface; the server can't actually boot on a stub. `taskkill` exit-128 swallowed when the stub process is already gone.

### Changed

- **PR #2812** by @Koraji95-coder — Composer max-width is now responsive on wide displays. Pre-change `.composer-box` had a fixed `max-width: 780px` that pinched footer chips (workspace name, model picker, reasoning chip, context ring) against each other on 1440p+ monitors. Switched to `max-width: clamp(780px, 60vw, 1100px)` — the 780px floor preserves byte-identical layout at 1280px (Aron's laptop reference width); 1440px viewports gain ~84px (864px composer); 1920px viewports gain ~320px (1100px composer cap). Mobile responsive logic untouched. Single-line CSS change in `static/style.css`.

### Added

- **PR #2772** by @vaur94 — Complete Turkish (`tr`) locale across `static/i18n.js` (~1,182 keys matching existing locale coverage). Adds Turkish login page strings in `api/routes.py` `_LOGIN_LOCALE`. Settings → Language now offers **Türkçe**; speech recognition uses `tr-TR`. Stage build absorbed a sibling-PR i18n collision with #2776 below (9 missing keys: `mcp_enable_server`, `mcp_disable_server`, `mcp_enabled_toast`, `mcp_disabled_toast`, `mcp_toggle_failed`, `open_in_vscode`, `open_in_vscode_failed`, `settings_label_ignore_agent_updates`, `settings_desc_ignore_agent_updates`) — Turkish translations added in-stage so locale-parity test passes. Closes #2537 as superseded (byzuzayli's earlier Turkish PR with narrower scope).

- **PR #2776** by @roryford — New `PATCH /api/mcp/servers/{name}` endpoint accepts `{"enabled": bool}`, writes `mcp_servers.<name>.enabled` to `config.yaml`, calls `reload_config()`, returns `{"ok": true, "name": "<name>", "enabled": <bool>}`. Each MCP server row in the panel now shows a clickable Enabled/Disabled toggle. Also fixes a pre-existing bug: `_handle_mcp_server_delete` and `_handle_mcp_server_update` were defined at line ~11656 but never wired into the HTTP router — DELETE wired into `handle_delete`, PUT wired via new `handle_put` / `do_PUT` in `server.py`. CORS preflight `Access-Control-Allow-Methods` updated to include `PUT` (Opus pre-release review nit). Adds 5 i18n keys to all 11 locales (en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr via in-stage parity fix). 7 new tests covering enable, disable, 404, empty-name, missing-field, response payload, URL-decoded names.

### Notes

- Two PRs (#2854, #2855) are brick-class fixes — every Linux install was unable to use the embedded terminal, and every install past the latest agent tag was stuck in an Update Now loop. They land in the same low-risk batch as cosmetic / locale / CI changes because both fixes are mechanical, well-tested, and the brick-class severity made deferring impossible.
- Opus pre-release advisor reviewed all 5 risk areas (PR_SET_PDEATHSIG removal, update apply path symmetry, MCP toggle wiring, composer clamp, stream_end settlement). 1 MUST-FIX + 3 SHOULD-FIX all addressed inline before tag. Net: +69/-9 across 5 files for the Opus fixes.
- Full pytest: 6,424 passed / 6 skipped / 3 xpassed / 8 subtests passed.
- UX evidence for #2812 captured at 1280/1440/1920/mobile (iPhone 14 emulation); Telegram-approved.
- File a follow-up issue for pdeathsig-on-supervisor-thread hardening (#2854 deferred Option B) and French-locale `open_in_vscode` parity gap (predates this batch, Opus advisor flagged).

## [v0.51.126] — 2026-05-24 — Release CX (stage-batch8 — 2-PR low-risk batch — kanban markdown + live activity timeline)

### Added
Expand Down
52 changes: 51 additions & 1 deletion api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2561,6 +2561,15 @@ def submit_pending(session_key: str, approval: dict) -> None:
"invalid_pw": "\ube44\ubc00\ubc88\ud638\uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4",
"conn_failed": "\uc5f0\uacb0 \uc2e4\ud328",
},
"tr": {
"lang": "tr-TR",
"title": "Oturum a\u00e7",
"subtitle": "Devam etmek i\u00e7in \u015fifrenizi girin",
"placeholder": "\u015eifre",
"btn": "Oturum a\u00e7",
"invalid_pw": "Ge\u00e7ersiz \u015fifre",
"conn_failed": "Ba\u011flant\u0131 ba\u015far\u0131s\u0131z",
},
}


Expand Down Expand Up @@ -6229,6 +6238,9 @@ def handle_patch(handler, parsed) -> bool:
if not _check_csrf(handler):
return j(handler, {"error": _csrf_rejection_error(handler)}, status=403)
body = read_body(handler)
if parsed.path.startswith("/api/mcp/servers/"):
name = parsed.path[len("/api/mcp/servers/"):]
return _handle_mcp_server_toggle(handler, name, body)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_patch

Expand All @@ -6244,6 +6256,9 @@ def handle_delete(handler, parsed) -> bool:
if not _check_csrf(handler):
return j(handler, {"error": _csrf_rejection_error(handler)}, status=403)
body = read_body(handler)
if parsed.path.startswith("/api/mcp/servers/"):
name = parsed.path[len("/api/mcp/servers/"):]
return _handle_mcp_server_delete(handler, name)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_delete

Expand All @@ -6253,6 +6268,17 @@ def handle_delete(handler, parsed) -> bool:
return True
return False


def handle_put(handler, parsed) -> bool:
"""Handle all PUT routes. Returns True if handled, False for 404."""
if not _check_csrf(handler):
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
body = read_body(handler)
if parsed.path.startswith("/api/mcp/servers/"):
name = parsed.path[len("/api/mcp/servers/"):]
return _handle_mcp_server_update(handler, name, body)
return False

# ── GET route helpers ─────────────────────────────────────────────────────────

# MIME types for static file serving. Hoisted to module scope to avoid
Expand Down Expand Up @@ -11888,7 +11914,7 @@ def _handle_mcp_servers_list(handler):
]
return j(handler, {
"servers": result,
"toggle_supported": False,
"toggle_supported": True,
"reload_required": True,
})

Expand All @@ -11912,6 +11938,30 @@ def _handle_mcp_server_delete(handler, name):
return j(handler, {"ok": True, "deleted": name})


def _handle_mcp_server_toggle(handler, name, body):
"""Toggle enabled state for an MCP server (PATCH /api/mcp/servers/{name})."""
from urllib.parse import unquote
name = unquote(name)
if not name:
return bad(handler, "name is required")
if "enabled" not in body:
return bad(handler, "enabled field is required")
enabled = bool(body["enabled"])
cfg = get_config()
servers = cfg.get("mcp_servers", {})
if not isinstance(servers, dict):
servers = {}
if name not in servers:
return bad(handler, f"MCP server '{name}' not found", 404)
if not isinstance(servers[name], dict):
return bad(handler, f"MCP server '{name}' has invalid config", 400)
servers[name]["enabled"] = enabled
cfg["mcp_servers"] = servers
_save_yaml_config_file(_get_config_path(), cfg)
reload_config()
return j(handler, {"ok": True, "name": name, "enabled": enabled})


_MASKED_PLACEHOLDER = "••••••"


Expand Down
24 changes: 11 additions & 13 deletions api/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,17 @@ def put_output(self, event: str, payload: dict) -> None:
_LOCK = threading.RLock()


def _terminal_shell_preexec_fn() -> None:
"""Ask Linux to terminate the PTY shell when the WebUI parent dies."""
try:
import ctypes

libc = ctypes.CDLL(None)
libc.prctl(1, signal.SIGTERM) # PR_SET_PDEATHSIG=1, SIGTERM=15
except Exception:
# Non-Linux platforms or restricted runtimes should still be able to
# open an embedded terminal; they just do not get the Linux pdeathsig
# hardening.
pass
# NOTE on parent-death-signal: a previous version of this module set
# PR_SET_PDEATHSIG via a preexec_fn to terminate orphaned PTY shells when the
# WebUI process crashed. That broke every Linux user (#2853): WebUI runs a
# ThreadingHTTPServer, so the Popen call happens on a short-lived per-request
# thread, and PR_SET_PDEATHSIG is per-thread. The PTY shell registered the
# spawning thread as its "parent" and was killed with SIGTERM the instant that
# thread joined — within ~10 ms of opening the terminal — surfacing as the
# `[terminal closed]` banner. The graceful path is covered by
# `atexit.register(close_all_terminals)` and the explicit `close_terminal`
# call sites; hard kills of the WebUI process leak the shell, which is the
# tradeoff for working on Linux at all.


def _decode_terminal_output(decoder, data: bytes) -> str:
Expand Down Expand Up @@ -193,7 +192,6 @@ def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int =
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
preexec_fn=_terminal_shell_preexec_fn,
start_new_session=True,
)
os.close(slave_fd)
Expand Down
Loading
Loading