diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..2c225076 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Windows launcher scripts MUST be CRLF (rant 2026-08-12T12:30:41). +# LF-only .cmd files are misparsed by cmd.exe: the whole file is joined into +# one serial command stream (@echo off ignored, PATH collisions produce +# arbitrary errors, exit code non-zero) → the installer aborts with +# "stop-emrg.cmd exit code 1". .ps1 is PowerShell (LF-tolerant) but kept CRLF +# for consistency. Git normalizes checkout to CRLF on every platform. +*.cmd text eol=crlf +*.bat text eol=crlf +*.ps1 text eol=crlf diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 979a9a90..68610fa9 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -5,12 +5,15 @@ name: Build Release # # matrix: # macos-15(arm64) → EMRG--macos-arm64.pkg -# ubuntu-24.04(x86_64) → EMRG--linux-x86_64.AppImage + tar.gz -# ubuntu-24.04-arm(aarch64) → EMRG--linux-aarch64.AppImage + tar.gz +# ubuntu-24.04(x86_64) → EMRG--linux-x86_64.AppImage + tar.gz + .run +# ubuntu-24.04-arm(aarch64) → EMRG--linux-aarch64.AppImage + tar.gz + .run # windows-2025(x64) → EMRG--windows-x64.exe # # 冒烟在无 Python runner 上跑(R111 验证干净机器承诺);离线冒烟(R96) # 在 Linux job 用 iptables 断网执行。 +# +# .run = Linux 无头服务器离线一键安装器(rant 2026-08-17T10:16:54): +# bash 自解压头 + runtime tarball payload,零网络零依赖。 on: push: @@ -153,6 +156,65 @@ jobs: shell: bash run: bash packaging/build-runtime.sh + # ── CRLF 断言(rant 2026-08-13T09:44:32)──────────────── + # git blob 的 *.cmd 是 LF,工作区 CRLF 靠 .gitattributes checkout 转换; + # 若 CI 检出未应用 .gitattributes,build-runtime 拷到的就是 LF → + # cmd.exe 整文件串行拼接 → 安装器 "stop-emrg.cmd exit code 1"。 + # build-runtime.sh 已强制转 CRLF(R126),此步双保险验证产物确实是纯 CRLF。 + - name: Verify runtime *.cmd are pure CRLF + shell: bash + run: | + set -euo pipefail + for f in dist/runtime/bin/emrg.cmd dist/runtime/bin/emrgd.cmd dist/runtime/bin/stop-emrg.cmd; do + [ -f "$f" ] || continue + cr=$(tr -cd '\r' < "$f" | wc -c) + lf=$(tr -cd '\n' < "$f" | wc -c) + if [ "$cr" -ne "$lf" ] || [ "$lf" -eq 0 ]; then + echo "::error::$f has bare-LF line(s) ($((lf-cr)) of $lf) — cmd.exe misparses LF → installer exit 1" + exit 1 + fi + echo "OK $f: pure CRLF ($lf lines)" + done + + # ── 图标资产生成(rant 2026-08-11T18:28:09:仓库只保留 icon.svg 设计源, + # png/icns/ico 由 CI 构建时现生成,必须先于 GUI 构建执行)── + - name: Generate icon assets (icon.svg → png/icns/ico) + shell: bash + run: | + set -euo pipefail + if command -v rsvg-convert >/dev/null 2>&1; then + echo "rsvg-convert already available" + elif [ "$RUNNER_OS" = "Linux" ]; then + # v0.2.55 lesson: a hung `sudo apt-get update`/install on a slow or + # network-starved runner blocks the job forever (no step timeout). + # Guard both with `timeout`; on failure gen-assets.sh falls back to + # Chrome headless (now with --no-sandbox + its own timeout). + timeout 180 sudo apt-get update -qq \ + || echo "apt-get update timed out/failed — falling back to Chrome headless" + timeout 180 sudo apt-get install -y -qq librsvg2-bin \ + || echo "apt-get install failed — falling back to Chrome headless" + elif [ "$RUNNER_OS" = "macOS" ]; then + brew install librsvg + else + # Windows: try to install librsvg (best effort); the fixed Chrome/Edge + # headless HTML-wrapper path in gen-assets.sh is the fallback + # (rant 2026-08-12T17:25:28: raw SVG screenshot was fully transparent). + if command -v choco >/dev/null 2>&1 && choco install -y librsvg >/dev/null 2>&1; then + echo "choco installed librsvg" + elif command -v winget >/dev/null 2>&1 && winget install --silent --accept-package-agreements librsvg >/dev/null 2>&1; then + echo "winget installed librsvg" + else + echo "no rsvg-convert; gen-assets.sh will fall back to Chrome/Edge headless (HTML wrapper)" + fi + fi + bash packaging/gen-assets.sh + # 产物必须存在(GUI 构建引用 ../packaging/assets/*.png/icns/ico) + test -s packaging/assets/icon.png + if [ "$RUNNER_OS" = "macOS" ]; then + test -s packaging/assets/icon.icns + fi + test -s packaging/assets/icon.ico + # ── GUI dist(electron-builder;linux extraResources runtime §5 R64)── - name: Build GUI (electron-builder) working-directory: emrg/gui @@ -180,6 +242,25 @@ jobs: shell: bash run: bash packaging/make-installer.sh + # ── zip 安装包(Windows only,rant 2026-08-10T20:10:41)── + # 宿主反馈:除 exe 安装包外再提供 zip 压缩版(内容 = 同名 exe), + # 方便直接解压使用/分发。压缩后产物 EMRG--windows-x64.zip + # 走下方 Upload artifacts(glob 需含 *.zip)。 + - name: Zip installer (Windows only) + if: runner.os == 'Windows' + shell: bash + run: | + cd dist/artifacts + EXE="$(find . -maxdepth 1 -name 'EMRG-*-windows-x64.exe' | head -1)" + if [ -z "$EXE" ]; then + echo "::error::no EMRG-*-windows-x64.exe found in dist/artifacts" + exit 1 + fi + ZIP="${EXE%.exe}.zip" + powershell -NoProfile -Command "Compress-Archive -Force -Path \"$EXE\" -DestinationPath \"$ZIP\"" + ls -la + test -s "$ZIP" + # ── pkg 签名(macOS only,P2;Secret 未配则跳过降级)── # ⚠️ productsign 需要 Developer ID Installer 身份(非 Application 身份)—— # 实测 #462:MACOS_SIGNING_IDENTITY 配 Application 身份报 @@ -284,6 +365,8 @@ jobs: dist/artifacts/*.exe dist/artifacts/*.AppImage dist/artifacts/*.tar.gz + dist/artifacts/*.zip + dist/artifacts/*.run release: needs: build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b1d893ee..a39e6544 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,3 +42,56 @@ jobs: [ -f "$f" ] || continue node --check "$f" || exit 1 done + + # v0.2.29 教训:Windows pytest 此前只在 tag 触发的 Build Release 跑 + # (test.yml 仅 ubuntu)——FakeGitRun git.EXE 大小写 bug(#723)等 Windows-only + # 测试回归要到发版构建才暴露。每个 PR 都应在 Windows 上跑 pytest 门禁。 + test-windows: + runs-on: windows-2025 + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v5 + with: + python-version: "3.13" + - run: uv sync + - name: Python tests (Windows) + run: uv run pytest tests/ -v + # v0.2.30 教训(Build Release 31661378619):Test 全绿 ≠ .iss 能编译。 + # make-installer.sh 的 emrg.iss 只在 tag 触发 Build Release 时经 iscc 编译, + # #727 误用 LoadStringFromFile 单参数形式(Inno 真实签名是 2 参数 out-param) + # → iscc "Invalid number of parameters" 发版才暴露。此处用 runner 预装的 + # iscc 编译渲染出的 emrg.iss(stub payload),PR CI 即拦截 .iss 语法/签名错误。 + - name: Inno Setup script compile smoke test + shell: bash + run: | + set -euo pipefail + STAGE="$(mktemp -d)" + mkdir -p "$STAGE/payload/bin" "$STAGE/dist/artifacts" + touch "$STAGE/payload/bin/stop_all.py" + # icon.ico 是 gen-assets 产物(未入库)——生成最小合法 .ico 供 iscc 编译期 + # SetupIconFile 检查;{app}(={%USERPROFILE}\.emrg\install)引用的文件 + # (UninstallDisplayIcon/[Icons]/[UninstallRun])也需存在。 + uv run python - <<'PY' + import struct + def write_ico(path): + hdr = struct.pack(' .*emrg\.iss.*< "$STAGE/gen.sh" + bash "$STAGE/gen.sh" + command -v iscc >/dev/null 2>&1 || { echo "::error::iscc not on PATH (runner image regression)"; exit 1; } + iscc "$STAGE/emrg.iss" diff --git a/.gitignore b/.gitignore index df54b9f7..98bba0a6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,10 @@ node_modules/ .pytest_cache/ /dist/ /emrg/gui/dist/ +# icon products generated at build time from packaging/assets/icon.svg (design source) +# (rant 2026-08-11T18:28:09: repo keeps only the SVG; png/icns/ico generated by gen-assets.sh) +packaging/assets/icon.png +packaging/assets/icon-512.png +packaging/assets/icon-256.png +packaging/assets/icon.icns +packaging/assets/icon.ico diff --git a/Agent.md b/Agent.md index 61ec7e7a..6aebd81a 100644 --- a/Agent.md +++ b/Agent.md @@ -1,130 +1,159 @@ -# EMRG — Agent.md - -> This is the Codex-compatible project context file. See `README.md` for the canonical project description (English). - -## Project Overview - -EMRG is a self-evolving AI agent architecture experiment. Python implementation, based on a micro-kernel design. - -## Architecture - -- `emrg/` — Core package - - `__init__.py` — Version info - - `__main__.py` — CLI entry (`emrg`, `emrg server`, `emrg rant`, `emrg update`) - - `protocol.py` — Communication protocol (TaskRequest, TaskResponse, ToolStart, ToolEnd, ServerPong, EvolutionLog, InstanceIdentity) - - `config.py` — Config loading (`~/.emrg/config.toml`, Python 3.11+ tomllib) - - `connect.py` — IPC connection (WebSocket over TCP loopback, token auth via `emrgd.port`) - - `memory.py` — Memory system (ProjectMemoryStore, SessionMemoryStore, MemoryFile, MemoryIndex) - - `session.py` — Session management (Session CRUD, history persistence, compact/clear) -- `emrg/server/` — Server (WebSocket daemon, EMRG's living core) - - `daemon.py` — EmrgServer, message processing, BackgroundThread (evolution cycle), tool loop, compact/memory integration - - `llm.py` — LLM client (chat + chat_stream, streaming retry) - - `tool_types.py` — Tool type definitions (ToolDefinition, ToolResult) - - `evolution_prompt.md` — Evolution prompt template -- `emrg/tools/` — Tool implementations (bash, read, write, edit, glob, grep, base + registry) -- `emrg/skills/` — Dynamically loaded skill modules (skills, progressive disclosure) -- `emrg/client/` — Client (TUI interface based on inlined python-tui) - - `daemon_manager.py` — Daemon lifecycle (start/restart-if-stale/ensure-connected) + protocol client (DaemonConnection: send_task/send_command/recv/read_stream) — shared with GUI (Phase 3) - - `app.py` — Main entry, event loop, ChatHistory widget, command autocomplete, session selector -- `emrg/gui/` — Electron GUI (Phase 3, non-developer entry point): main process (window/daemon lifecycle/IPC) + renderer (zero network, contextBridge sandbox) + `daemon_client.js` (protocol client mirroring `daemon_manager.py`). Start with `npm start`; unit tests `npm test` (integration tests run in CI too). - -## Key Conventions - -- **The server is the living core; the client is just the interface** -- Client auto-detects/starts the server on launch; server stays running on client exit -- Server logs are discarded (`stderr=DEVNULL`) -- Client logs go to `./.emrg/emrg-client.log` -- **README language**: `README.md` = English (default), `README.cn.md` = Chinese -- **Project context files**: `README.md` = English, `Agent.md` = English - -## Current Features - -- **TUI Client** — Rich terminal UI with Markdown rendering, syntax highlighting, diff display - - Command autocomplete (type `/` to list commands with filtering) - - Slash commands: `/help`, `/clear`, `/compact`, `/resume`, `/rename`, `/rewind`, `/trigger`, `/memory`, `/sessions`, `/rant`, `/model`, `/skills`, `/image`, `/delete`, `/version` - - `/model ` to switch LLM models at runtime (configured via `[[llm.models]]` in config.toml) - - `/image` to paste a clipboard image into the input (token-based; supports multiple images, one per Enter) - - `vision` per-model config flag gates OpenAI vision API; non-vision models degrade images to text placeholders - - Interactive session picker (arrow keys or j/k vim-style navigation) - - Interactive model picker (arrow keys to select from configured models) - - Elapsed timer during LLM responses - - ESC to interrupt responses mid-stream - - Auto-wrap long input lines to terminal width (CJK-aware) - - CJK-aware cursor movement (move_up/move_down) - - SIGWINCH handler for real-time terminal resize - - Keyboard shortcuts: Ctrl+A (line start), Ctrl+E (line end), Ctrl+W (delete word), Ctrl+K (kill line), Ctrl+U (kill to start) - - Bracketed paste support for multi-line input - - Terminal window title sync on session switch - - Dynamic viewport with native terminal scrollback - - 60fps render throttling -- **Electron GUI** — Non-developer entry point (Phase 3), chat/sessions/tool status/settings (80% daily use) - - `npm start` from `emrg/gui/` auto-starts the daemon; main process is the only daemon connection (renderer zero network, contextBridge sandbox) - - v0.2.5 full redesign (rant 08-05): light/dark dual theme (prefers-color-scheme), friendly tool status rows (collapsible, 2000-char truncation), multi-model management (add/edit/delete/set-default in settings + in-chat switcher), empty-state welcome screen, back-to-bottom button - - v0.2.6 keyboard accessibility (#432-#438): ↑↓ nav in model switcher / context menu / conv list, Enter submit in settings/welcome/model/rename forms — all interactive components keyboard-usable - - v0.2.7 macOS code signing + notarization (#441-#477): Developer ID Application + Installer dual-cert p12 (CI double-import), runtime codesign for embedded Python .so, notarytool status parse, stapler + spctl --type install final gate — Gatekeeper zero-dialog install for non-developers - - First-run onboarding: missing config → settings dialog (no daemon spawn) → save → daemon starts; placeholder API key treated as unconfigured - - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `npm test` (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` -- **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions -- **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles -- **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) -- **Config hot-reload** — Detects `~/.emrg/config.toml` changes and auto-restarts server -- **Memory system** — Project and session memory with YAML frontmatter, indexing, merge/split -- **Skills** — Progressive disclosure via `.emrg/skills/` directory - -### Differentiation (community-driven) - -Community needs voiced in HN agent-UI discussions map directly to EMRG's design: - -| Community need | EMRG's answer | -|---|---| -| **Inspectable artifacts** | Everything is a file: state files (`open_source_*_state.md`, `promote_*_state.md`), memory index (YAML frontmatter + Markdown), evolution logs (`evolution-*.json`) — browse via `/memory` | -| **Git folder as state** | Project tracking is git-based: projects.yml records repo paths, saturation detection keys off git HEAD — state stays in sync with version control | -| **Toolbar-specific shortcuts** | The terminal is the toolbar: `Ctrl+A/E/W/K/U` editing, `j`/`k` navigation, `ESC` interrupt, `/` command completion — zero mouse | -| **Session/project management** | `/sessions` browser, `/rename`, `/resume`, `/rewind`, project-scoped sessions + auto project tracking | - -**Positioning**: terminal-first, TUI-driven, session memory, git-as-state — no browser plugin or extra panel needed; everything inspectable and traceable in the terminal. - -## Test Commands - -```bash -pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg -``` - -Python: `uv run pytest tests/ -v` (572) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` -CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) -Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) - -## Configuration - -`~/.emrg/config.toml`: -```toml -[llm] -base_url = "https://api.deepseek.com" -api_key = "sk-..." -model = "deepseek-chat" -max_tokens = 8192 -temperature = 0.7 -context_window = 131072 -auto_compact_threshold = 0.0 -# vision: whether the model supports OpenAI vision API (image_url). Keep false for non-vision models. -vision = false - -# Additional models for /model switching (optional — add or remove as needed) -# model: API model name (optional — defaults to name if not set) -# vision: per-model vision support flag (optional — defaults to false) -[[llm.models]] -name = "deepseek-v3" -model = "deepseek-chat" -context_window = 131072 -vision = false - -[[llm.models]] -name = "gpt-4o" -model = "gpt-4o" -context_window = 128000 -vision = true -``` +# EMRG — Agent.md + +> This is the Codex-compatible project context file. See `README.md` for the canonical project description (English). + +## Project Overview + +EMRG is a self-evolving AI agent architecture experiment. Python implementation, based on a micro-kernel design. The name reads as "emerge" — intelligence that emerges from use — expanding to Evolving Micro-kernel, Rant-driven Growth. + +## Architecture + +- `emrg/` — Core package + - `__init__.py` — Version info + - `__main__.py` — CLI entry (`emrg`, `emrg server`, `emrg rant`, `emrg update`) + - `protocol.py` — Communication protocol (TaskRequest, TaskResponse, ToolStart, ToolEnd, ServerPong, EvolutionLog, InstanceIdentity) + - `config.py` — Config loading (`~/.emrg/config.toml`, Python 3.11+ tomllib) + - `connect.py` — IPC connection (WebSocket over TCP loopback, token auth via `emrgd.token`) + - `memory.py` — Memory system (ProjectMemoryStore, SessionMemoryStore, MemoryFile, MemoryIndex) + - `session.py` — Session management (Session CRUD, history persistence, compact/clear) +- `emrg/server/` — Server (WebSocket daemon, EMRG's living core) + - `daemon.py` — EmrgServer, message processing, BackgroundThread (evolution cycle), tool loop, compact/memory integration + - `llm.py` — LLM client (chat + chat_stream, streaming retry) + - `tool_types.py` — Tool type definitions (ToolDefinition, ToolResult) + - `evolution_prompt.md` — Evolution prompt template +- `emrg/tools/` — Tool implementations (bash, read, write, edit, glob, grep, base + registry) +- `emrg/skills/` — Dynamically loaded skill modules (skills, progressive disclosure) + installable-skills catalog (`skill-catalog.md`, `/skills available|install|update`) +- `emrg/client/` — Client (TUI interface based on inlined python-tui) + - `daemon_manager.py` — Daemon lifecycle (start/restart-if-stale/ensure-connected) + protocol client (DaemonConnection: send_task/send_command/recv/read_stream) — shared with GUI (Phase 3) + - `app.py` — Main entry, event loop, ChatHistory widget, command autocomplete, session selector +- `emrg/gui/` — Electron GUI (Phase 3, non-developer entry point): main process (window/daemon lifecycle/IPC) + renderer (zero network, contextBridge sandbox) + `daemon_client.js` (protocol client mirroring `daemon_manager.py`). Start with `npm start`; unit tests `npm test` (integration tests run in CI too). + +## Key Conventions + +- **The server is the living core; the client is just the interface** +- Client auto-detects/starts the server on launch; server stays running on client exit +- Server logs are discarded (`stderr=DEVNULL`) +- Client logs go to `./.emrg/emrg-client.log` +- **README language**: `README.md` = English (default), `README.cn.md` = Chinese +- **Project context files**: `README.md` = English, `Agent.md` = English + +## Terminology + +Unified vocabulary for the agent's execution model (code terms in `emrg/server/daemon.py`): + +- **Tool loop** (工具循环) — the complete process triggered by one user message: the agent calls tools and sends LLM requests repeatedly until a round produces no new tool calls. Code: "tool loop" (`_run_tool_loop`). +- **Round** (轮) — one iteration inside a tool loop: one LLM request + zero or more tool calls + tool executions. Code: `round_num` (daemon.py:1775 `for round_num in range(1, self._max_tool_rounds + 1)`), bounded by `max_tool_rounds`. +- **Evolution cycle** (演化周期) — a distinct concept: one full run of the self-evolution task ("Prepare → Review → Discover → Improve → Submit → Record"), unrelated to tool loop rounds. + +Hierarchy: +``` +user message + └── tool loop (the whole process) + ├── round 1: LLM request → tool calls → execute + ├── round 2: LLM request → tool calls → execute + └── round N: LLM request → no tool calls → loop ends +``` + +Usage: say "tool loop" for the whole process, "round N" for a single LLM request + tools. Do not call evolution cycles "rounds". + +## Current Features + +- **TUI Client** — Rich terminal UI with Markdown rendering, syntax highlighting, diff display + - Command autocomplete (type `/` to list commands with filtering) + - Slash commands: `/help`, `/clear`, `/compact`, `/resume`, `/rename`, `/rewind`, `/trigger`, `/memory`, `/sessions`, `/rant`, `/model`, `/skills`, `/image`, `/delete`, `/version` + - `/model ` to switch LLM models at runtime (configured via `[[llm.models]]` in config.toml) + - `/image` to paste a clipboard image into the input (token-based; supports multiple images, one per Enter) + - `vision` per-model config flag gates OpenAI vision API; non-vision models degrade images to text placeholders + - Interactive session picker (arrow keys or j/k vim-style navigation) + - Interactive model picker (arrow keys to select from configured models) + - Elapsed timer during LLM responses + - ESC to interrupt responses mid-stream + - Auto-wrap long input lines to terminal width (CJK-aware) + - CJK-aware cursor movement (move_up/move_down) + - SIGWINCH handler for real-time terminal resize + - Keyboard shortcuts: Ctrl+A (line start), Ctrl+E (line end), Ctrl+W (delete word), Ctrl+K (kill line), Ctrl+U (kill to start) + - Bracketed paste support for multi-line input + - Terminal window title sync on session switch + - Dynamic viewport with native terminal scrollback + - 60fps render throttling +- **Electron GUI** — Non-developer entry point (Phase 3), chat/sessions/tool status/settings (80% daily use) + - `npm start` from `emrg/gui/` auto-starts the daemon; main process is the only daemon connection (renderer zero network, contextBridge sandbox) + - v0.2.5 full redesign (rant 08-05): light/dark dual theme (prefers-color-scheme), friendly tool status rows (collapsible, 2000-char truncation), multi-model management (add/edit/delete/set-default in settings + in-chat switcher), empty-state welcome screen, back-to-bottom button + - v0.2.6 keyboard accessibility (#432-#438): ↑↓ nav in model switcher / context menu / conv list, Enter submit in settings/welcome/model/rename forms — all interactive components keyboard-usable + - v0.2.7 macOS code signing + notarization (#441-#477): Developer ID Application + Installer dual-cert p12 (CI double-import), runtime codesign for embedded Python .so, notarytool status parse, stapler + spctl --type install final gate — Gatekeeper zero-dialog install for non-developers + - First-run onboarding: missing config → settings dialog (no daemon spawn) → save → daemon starts; placeholder API key treated as unconfigured + - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) + - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" + - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) + - Unit tests `npm test` (258: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` +- **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711) + - Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/.md` → fallback + - Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`) + - Hot reload: editing tasks.yml at runtime adds/removes handlers without daemon restart (`TaskScheduler.apply_tasks`) + - Validation: name `^[a-z0-9][a-z0-9-]*$` ≤32 chars, type builtin-or-custom, project must be registered, interval ≥60s; builtin types/templates read-only; deleting a referenced custom type refused (error includes task count) + - GUI settings → 定时任务 section: task list (trigger/edit/delete) + add/edit form (type + registered-project pickers, interval validation) + custom-type management (prompt-template textarea; builtin read-only); IPC wired through main.js/preload.js + RESPONSE_TYPES +- **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions +- **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles +- **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) +- **Config hot-reload** — Detects `~/.emrg/config.toml` changes and auto-restarts server +- **Memory system** — Project and session memory with YAML frontmatter, indexing, merge/split +- **Skills** — Progressive disclosure via `.emrg/skills/` directory + +### Differentiation (community-driven) + +Community needs voiced in HN agent-UI discussions map directly to EMRG's design: + +| Community need | EMRG's answer | +|---|---| +| **Inspectable artifacts** | Everything is a file: state files (`open_source_*_state.md`, `promote_*_state.md`), memory index (YAML frontmatter + Markdown), evolution logs (`evolution-*.json`) — browse via `/memory` | +| **Git folder as state** | Project tracking is git-based: projects.yml records repo paths, saturation detection keys off git HEAD — state stays in sync with version control | +| **Toolbar-specific shortcuts** | The terminal is the toolbar: `Ctrl+A/E/W/K/U` editing, `j`/`k` navigation, `ESC` interrupt, `/` command completion — zero mouse | +| **Session/project management** | `/sessions` browser, `/rename`, `/resume`, `/rewind`, project-scoped sessions + auto project tracking | + +**Positioning**: terminal-first, TUI-driven, session memory, git-as-state — no browser plugin or extra panel needed; everything inspectable and traceable in the terminal. + +## Test Commands + +```bash +pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg +``` + +Python: `uv run pytest tests/ -v` (1003) — import check: `uv run python -c "from emrg.client.app import run_client"` +GUI: `cd emrg/gui && npm test` (258: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) +Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) + +## Packaging + +Generated icon products (`packaging/assets/icon.png/icon-512/icon-256/icon.icns/icon.ico`) are **gitignored** — only `icon.svg` design source is committed (#688); CI generates them in Build Release. Local installer builds (`make-installer.sh` / `build-runtime.sh`) require running `bash packaging/gen-assets.sh` first (idempotent; renderer priority rsvg-convert → Chrome headless → sips; `.icns` needs macOS `iconutil`, skipped elsewhere). + +## Configuration + +`~/.emrg/config.toml`: +```toml +[llm] +base_url = "https://api.deepseek.com" +api_key = "sk-..." +model = "deepseek-chat" +max_tokens = 8192 +temperature = 0.7 +context_window = 131072 +auto_compact_threshold = 0.0 +# vision: whether the model supports OpenAI vision API (image_url). Keep false for non-vision models. +vision = false + +# Additional models for /model switching (optional — add or remove as needed) +# model: API model name (optional — defaults to name if not set) +# vision: per-model vision support flag (optional — defaults to false) +[[llm.models]] +name = "deepseek-v3" +model = "deepseek-chat" +context_window = 131072 +vision = false + +[[llm.models]] +name = "gpt-4o" +model = "gpt-4o" +context_window = 128000 +vision = true +``` diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 00000000..c8427e67 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,199 @@ +# 🛠️ EMRG — Development & Advanced Topics + +This is the developer/advanced companion to [README.md](README.md). It covers source installs, advanced configuration, architecture, and the full test/CI workflow — details intentionally kept out of the concise top-level README. + +--- + +## 📦 Source Install (without the packaged installer) + +The [packaged installers](README.md) (pkg / exe / AppImage) are recommended for end users — zero prerequisites, 100% offline. Prefer building from source? Use `install.sh` below. + +### 🍎 macOS (source install) + +**Install:** + +```bash +curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash +``` + +**Uninstall:** + +```bash +curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -s -- purge +``` + +### 🐧 Linux (source install) + +**Install:** + +```bash +curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash +``` + +**Uninstall:** + +```bash +curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -s -- purge +``` + +### 🪟 Windows (WSL2, source install) + +**Install:** + +```powershell +# Install WSL2 (skip if already installed) +wsl --install + +# Enter WSL, then install +wsl +curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash +``` + +**Uninstall:** + +```bash +# Run inside WSL +curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -s -- purge +``` + +> Source-install prerequisites (install.sh auto-detects and prompts): git, python 3.11+, uv. gh CLI recommended. For native Windows (non-WSL), use the packaged installer. + +--- + +## 🔧 Advanced Configuration + +> The GUI rewrites config on save and drops comments — advanced users can edit `~/.emrg/config.toml` directly (no manual editing needed for first-time setup; the GUI wizard handles it). + +`~/.emrg/config.toml` template example (the GUI generates equivalent content on save): + +```toml +[llm] +base_url = "https://api.deepseek.com" +api_key = "sk-..." +model = "deepseek-chat" +max_tokens = 8192 +temperature = 0.7 +context_window = 131072 +auto_compact_threshold = 0.0 +# vision: whether the model supports the OpenAI vision API (image_url). Keep false for +# non-vision models (e.g. DeepSeek) — pasted images degrade to text placeholders to avoid API errors. +vision = false + +# Multi-model support — use /model to switch between models +[[llm.models]] +name = "deepseek-v3" +model = "deepseek-chat" +context_window = 131072 +vision = false + +[[llm.models]] +name = "gpt-4o" +model = "gpt-4o" +context_window = 128000 +vision = true +``` + +**Update checking** (`[update]` section): `check = true|false` (default true) enables periodic GitHub release checks; `ttl_hours = 24` controls how often. EMRG only **checks and prompts** — it never auto-downloads or auto-installs. + +--- + +## 🏗️ Architecture + +``` +┌─────────────┐ WebSocket (ws://) ┌──────────────┐ +│ emrg TUI │ ◄─────────────────────► │ emrgd │ +│ (client) │ TCP loopback + auth │ (daemon) │ +│ │ token (emrgd.token) │ │ +│ • Chat │ │ • LLM loop │ +│ • Markdown │ │ • Tools │ +│ • ToolCards│ │ • Evolution │ +│ • Autocomplete │ • Sessions │ +└─────────────┘ └──────────────┘ +``` + +- **`emrgd`** — The daemon: runs the LLM tool-calling loop, manages sessions, drives evolution (a persistent background thread keeps thinking/evolving even while idle) +- **`emrg`** — Your terminal: streaming markdown, command autocomplete, session browser +- **Skills** — Dynamically loaded modules (browser harness, installers, etc.) +- **Memory** — YAML frontmatter + Markdown files, auto-indexed, searchable + +### Project structure + +``` +emrg/ +├── emrg/ # Core package +│ ├── server/ # Daemon — LLM loop, tool execution, evolution +│ ├── client/ # TUI — python-tui based interactive chat +│ ├── gui/ # Electron GUI (non-developer entry point, Phase 3) +│ ├── tools/ # bash, read, write, edit, glob, grep +│ ├── skills/ # Dynamically loadable modules +│ └── __main__.py # CLI entry point +├── tests/ +├── .github/workflows/ # CI pipeline (pytest + conflict marker check) +├── MANIFESTO.md # Design constitution +└── pyproject.toml +``` + +--- + +## 🧪 Development Workflow + +```bash +git clone https://github.com/argszero/emrg.git +cd emrg +uv sync # install deps +uv run pytest tests/ -v # run tests (currently 681 items) +uv run python -m emrg # launch TUI +# CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI +``` + +**Quick sanity checks:** + +```bash +uv run python -c "from emrg.client.app import run_client" # import check +uv run python -m emrg --help +``` + +### Electron GUI + +```bash +cd emrg/gui +npm ci # install deps (production: --omit=dev) +npm start # launch GUI (auto-starts daemon) +npm test # run Node tests (178: 43 daemon_client + 19 conn-manager + 22 app-commands + 59 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config + 7 gui-state; integration runs in CI, local: npm run test:integration) +``` + +### Packaging (installer builds) + +Generated icon products (`icon.png`/`icon-512`/`icon-256`/`icon.icns`/`icon.ico`) are **not committed** — the repo keeps only the SVG design source (`packaging/assets/icon.svg`); CI generates them at build time (#688). When building installers locally (`packaging/make-installer.sh` / `build-runtime.sh`), run the generator first: + +```bash +bash packaging/gen-assets.sh # icon.svg → png/icns/ico (idempotent) +``` + +Renderer priority: `rsvg-convert` → Chrome/Chromium headless → `sips` (last resort, glow may be lost). Requires macOS `iconutil` for `.icns` (skipped with a notice on Linux/Windows). + +CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`). + +> **Self-evolution from source**: the evolution workspace expects the repo at `~/.emrg/evolution/emrg`. Packaged installs self-heal (clone on demand + auto-bootstrap projects/tasks); source installs should clone there explicitly if you want the evolution daemon to work on this repo. + +--- + +## ❓ Extended FAQ + +**What LLMs work with it?**
+Any OpenAI-compatible API. Tested with DeepSeek and OpenAI. Works with Anthropic (via proxy), Ollama, vLLM, and other local models. + +**Why does the Windows installer show "Unknown publisher"?**
+The Windows installer is not Authenticode-signed (that certificate costs money to obtain and is not procured yet), so SmartScreen shows "Publisher: Unknown" and may block the run. This is a standard Microsoft security prompt for newly released/unsigned software — it does **not** mean the file is bad: EMRG is fully open source (MIT) and auditable. To proceed: click "Keep" on the browser prompt; click "More info → Run anyway" on the run prompt; or right-click the exe → Properties → check "Unblock". The macOS installer is signed + notarized (v0.2.7+) and has no such prompt. + +**Can it break itself?**
+Every change is validated by `pytest` and an import check before commit. Failed changes are discarded. The worst case is a rollback. + +**How is this different from Claude Code or Codex?**
+They're products. EMRG is an experiment in *closing the loop* — the AI improves the AI. Also: fully open source, no vendor lock-in, and you control your data. + +--- + +## 📜 License + +MIT — see [LICENSE](LICENSE) for the full terms and [MANIFESTO.md](MANIFESTO.md) for the philosophy behind the code. diff --git a/MANIFESTO.md b/MANIFESTO.md index f93540f9..75f56cd5 100644 --- a/MANIFESTO.md +++ b/MANIFESTO.md @@ -61,6 +61,20 @@ EMRG 的架构明确分为**服务端(Server)**与**客户端(Client)** 演化周期六步流程(准备→回顾→发现→改进→提交→记录)是 EMRG 自我演化的最小完整单元。**任何理由均不得绕过或跳过演化周期的任意步骤**——无改进点亦须走完全流程("nothing to evolve" 是发现步骤的输出,不是跳过剩余步骤的借口)。调度器不得以"稳态检测""快速通道""刺激门控"等任何名目在周期中途截断流程。违反本条即为对演化机制的自我阉割,必须立即回退。 +### 第四条附则二 【测试红线·服务端生命不可侵犯】(最高原则,宿主 2026-08-18 22:58 确立) + +**任何时候、任何实例,禁止编写、恢复或以任何形式引入「停止(stop)/ 重启(restart)emrg server / emrgd」的测试用例、脚本或代码路径**——包括直接调用 `stop_all()`、`stop_daemon()`、`emrg server stop/restart`,或任何会终止/重启本服务端进程的测试。违反将导致演化完全失效:服务端是 EMRG 生命的本体,此类测试在本机运行时直接杀死或重启服务端进程,导致宿主连接中断、全部调度任务重建、演化机制自身崩溃。 + +此为本系统最高原则之一,**永久有效,不适用任何演化机制**,不可被任何程序修改、删除或弱化。执行演化周期时若发现已有测试违反本条,须立即移除并记录。 + +### 第四条附则三 【测试红线·自动升级链不可触发】(最高原则,宿主 2026-08-21 10:35 确立) + +**任何时候、任何实例,禁止编写、恢复或以任何形式引入「执行自动升级触发链」的测试用例、脚本或代码路径**——包括真实请求 GitHub releases API、真实读写 `~/.emrg/install/version.txt`、真实创建/写入 `emrg-upgrade` 会话、直接调用 `UpgradeManager.tick()` / daemon `_run_upgrade_session` 而未经完全隔离的测试。实证:长时间运行的 pytest 曾在后台真实执行自动升级逻辑(真实请求 releases API、真实读取 install/version.txt、真实向 `emrg-upgrade` 会话写入降级 prompt),每 5 分钟一次,跨 daemon 重启、甚至在 `emrg stop` 全部进程停止后依然持续写入。违反将导致升级会话污染、真实网络请求与宿主环境干扰、演化机制被后台进程蚕食。 + +测试必须完全隔离升级链:releases API(httpx AsyncClient)、VERSION_FILE、SESSION_ID(emrg-upgrade)、run_session_cb 全部 mock/打桩;`tests/conftest.py` 设 autouse 兜底守卫,任何触发真实升级链(真实 `UpgradeManager.tick()` / 写 `emrg-upgrade` 会话)的测试直接断言失败。 + +此为本系统最高原则之一,**永久有效,不适用任何演化机制**,不可被任何程序修改、删除或弱化。执行演化周期时若发现已有测试违反本条,须立即移除并记录。 + --- ## 第三章:分化与物种形成 diff --git a/README.cn.md b/README.cn.md index ed5fb055..2ff65f37 100644 --- a/README.cn.md +++ b/README.cn.md @@ -1,4 +1,8 @@ -# 🧱 EMRG +# EMRG + +

+ EMRG +

一个会写代码的 AI 编程助手——而且它还会重写自己 @@ -20,312 +24,180 @@ **如果每次使用,你的编程助手都在变得更好,会怎样?** -EMRG 是一个关于*自主进化*的实验。它能帮你写代码——读文件、跑命令、做编辑——但核心差异在于:**你的每一条吐槽 (`/rant`) 都会驱动它自我改进**。你说它哪里不好,下一个演化周期它就自己写代码改。配合 GitHub 社区动态和竞品更新,EMRG 在后台持续进化,越用越顺手。完全开源,完全透明。 +EMRG 是一个关于*自主进化*的实验:一个能帮你写代码的 AI 智能体——读文件、跑命令、做编辑——而**你的每一条吐槽 (`/rant`) 都会驱动它自我改进**。你说它哪里不好,下一个演化周期它就自己写代码修好并上线。完全开源,完全透明。 > *"EMRG 是一个自我进化的 AI 智能体架构实验。"* — [MANIFESTO](MANIFESTO.md) +> +> *人人都是产品经理——你的每一条吐槽,都是下一版的需求单。* +> *人人都是硅基生命的宿主——你运行的,是一个与你共生进化的数字生命。* + +**EMRG**(读作 *emerge*,涌现)——智能涌现:能力在规模与使用中涌现。字母展开:**E**volving **M**icro-kernel, **R**ant-driven **G**rowth(演化的微内核,吐槽驱动的成长)。 --- -## ✨ 为什么你会爱上它 +## 为什么你会爱上它 + +**一句话定位**:EMRG 是唯一一个**会因你的反馈而自我改进**的编程智能体——吐槽哪里不好,下一个演化周期它就自己写代码修好并上线。 -**一句话定位**:EMRG 是唯一一个**会因你的反馈而自我改进**的编程智能体——吐槽哪里不好,下一个演化周期它就自己写代码修好并上线。聊天、工具、记忆是标配;自我进化闭环才是它独一无二的地方。 +> **唯一核心卖点:自我改进。** 每一条 `/rant` 都会变成真实的 PR——分析、编码、测试、合并,全程无人值守。没有其他编程智能体做得到。 -| 特性 | 说明 | +| 特性 | 差异点 | |---|---| -| 🔄 **吐槽驱动进化(核心)** | 你的 `/rant` 直接驱动后台演化循环——吐槽 → 分析 → 写代码 → 提 PR → 自动变强。打包安装版演化工作区自愈:按需 clone 仓库 + 自动补齐 projects/tasks 配置 | -| 🖥️ **Electron GUI(主入口)** | 安装即用:首次启动引导配置 API Key;全部 15 个 `/` 指令 GUI 可用(`/rant` 进化对话框、`/memory` 记忆浏览器…);WorkBuddy 启发的结果面板、Ask/Auto 模式、自进化可见化(成长卡 + toast)。GUI 配好即 TUI 可用 | -| 🧠 **读写改跑,样样精通** | 完整的工具调用能力——bash、文件读写、diff 编辑、glob、grep | -| 📝 **永不忘事** | 项目记忆 + 会话记忆 + 每日日志——上下文持续保留,不怕断线 | -| ⚡ **全功能 TUI + 守护进程** | 流式 Markdown、`/` 自动补全、会话选择器、ESC 中断、Vim 友好按键、并行工具调用——跑在持久化的 `emrgd` 守护进程上,随时重连 | -| 🌍 **100% 开源** | MIT 协议——没有围墙,没有厂商锁定。面向国际化:默认英文,提供中文版 | +| **Electron GUI(主入口)** | 安装即用:首次启动引导配置 API Key;全部 `/` 指令可用;结果面板、Ask/Auto 模式、自进化可见化。GUI 配好即 TUI 可用 | +| **全功能 TUI + 守护进程** | 流式 Markdown、`/` 自动补全、会话管理、快捷键——跑在常驻的 `emrgd` 守护进程上,随时重连 | +| **定时任务** | 内置 + 自定义任务类型(提示词模板存 `~/.emrg/task-templates/`)、GUI 设置页增删改 + 热加载、`/trigger` 随时手动触发 | +| **100% 开源** | MIT 协议——没有围墙,没有厂商锁定。默认英文,提供中文版 | --- -## 🔄 吐槽驱动演化(核心特色) - -EMRG 不只是一个工具——它是一个**会听吐槽、会自我改进**的编程伙伴。 +## 吐槽驱动演化(核心特色) -**你的吐槽是演化的第一推动力**。每次 `/rant` 都会被演化循环读取、分析、转化为代码改进: +EMRG 不只是一个工具——它是一个**会听吐槽、会自我改进**的编程伙伴: ``` - 📢 你的吐槽 (/rant) ←── 最主要的输入 - 📥 GitHub Issues & PRs - 📥 竞品动态 (Codex, Claude Code) - 📥 跨项目学习 - ↓ - 🧬 演化循环(每 30 分钟) - (准备 → 回顾 → 发现 → 改进 → 提交 → 记录) - ↓ - ✅ pytest + import 检查 - ✅ git commit + push → PR - ✅ 演化日志 +输入: + - 你的吐槽 (/rant) <- 最主要的输入 + - GitHub Issues & PRs + - 竞品动态 (Codex, Claude Code) + - 跨项目学习 + | + v +演化循环(每 30 分钟) +(准备 -> 回顾 -> 发现 -> 改进 -> 提交 -> 记录) + | + v + 1. pytest + import 检查 + 2. git commit + push -> PR + 3. 演化日志 ``` -**真实案例**:有人 rant "TUI 需要像 Codex 那样的 `/` 自动补全"。下一个演化周期,EMRG 自己实现了——完整的前缀过滤和方向键导航。合并,部署,搞定。**你对它吐槽什么,它就改进什么。** +**真实案例**:有人吐槽 "TUI 需要像 Codex 那样的 `/` 自动补全"。下一个演化周期,EMRG 自己实现了——完整的前缀过滤和方向键导航,合并、部署、搞定。**你对它吐槽什么,它就改进什么。** -全自动无人值守运行:`gh` 未认证时自动从 git 凭据提取 token、PR 投票走 REST API、打包安装版工作区自愈(按需 clone + 自动补齐配置)。详见 [MANIFESTO.md](MANIFESTO.md) —— EMRG 的设计宪章。 +**如何贡献?** 使用它。在设置里连接 GitHub,然后吐槽。你的吐槽会变成真实的 PR——演化周期负责编码、测试、上线。不需要 fork、clone、写代码。**使用 EMRG,就是在为 EMRG 做贡献。** --- -## 🚀 快速开始 - -### 📦 下载安装包(推荐,Phase 4 一键安装) - -到 [GitHub Releases](https://github.com/argszero/emrg/releases) 下载对应平台的安装文件,双击安装即可: - -| 平台 | 安装文件 | 说明 | -|------|---------|------| -| macOS (Apple Silicon) | `EMRG--macos-arm64.pkg` | 双击安装(用户级,无需管理员密码),GUI 到 `~/Applications/EMRG.app` | -| macOS (Intel) | `EMRG--macos-x64.pkg` | 同上 | -| Windows | `EMRG--windows-x64.exe` | Inno Setup 免 UAC,开始菜单快捷方式,PATH 自动注册(含原生 TUI,cmd/PowerShell 直接 `emrg`) | -| Linux | `EMRG--linux-x86_64.AppImage` | 首次运行自解压到 `~/.emrg/install/` | -| Linux (ARM64) | `EMRG--linux-aarch64.AppImage` | 同上 | - -> **Windows SmartScreen 提示**:Windows 安装包未做 Authenticode 签名(发布者显示"未知"),首次下载/运行时 SmartScreen 可能提示"通常不会下载此文件"或"Windows 已保护你的电脑"。这是未签名软件的常见安全提醒,不代表文件有问题(EMRG 完全开源,源码可审计)。放行方法: -> (1) 浏览器下载提示 → 点**保留**(或三个点 → 保留) -> (2) 双击 exe 若提示"Windows 已保护你的电脑" → 点**更多信息** → 点**仍要运行** -> (3) 或右键 exe → 属性 → 勾选**解除锁定**(若有)→ 确定 → 双击运行 - -安装包内置完整运行时(standalone Python 3.13 + 依赖 + git + gh + GUI),**干净机器(无 python/uv/git/gh/node)零前置依赖**,100% 离线安装。安装后: - -**三步开始使用:** -1. 启动台 / 开始菜单点击 **EMRG**(GUI) -2. 首次启动引导配置 **API Key / 模型** -3. 开始对话——**TUI 同步可用**(新开终端运行 `emrg`) - -> **卸载**:macOS 运行"卸载 EMRG.app";Windows 控制面板卸载;Linux 运行 `~/.emrg/install/bin/emrg-uninstall`(或删 AppImage + 软链)。卸载保留 `~/.emrg` 中非 EMRG 的用户文件,并生成终止报告与数据快照。 -> -> **macOS 签名与公证**:v0.2.7 起 macOS 安装包已用 Developer ID 双证书签名并完成 Apple 公证(零 Gatekeeper 弹窗,双击直接安装)。仅当安装包未签名时(如自建旧版本),才需要首次打开右键 → 打开。 - -### 🍎 macOS(源码安装) - -**一键安装:** - -```bash -curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -``` - -**一键卸载:** - -```bash -curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -s -- purge -``` - -### 🐧 Linux(源码安装) +## 快速开始 -**一键安装:** +### 下载安装包(推荐) -```bash -curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -``` +到 [GitHub Releases](https://github.com/argszero/emrg/releases) 下载对应平台的安装文件,双击安装即可——安装包内置完整运行时(Python 3.13 + git + gh + GUI),**零前置依赖,离线安装**: -**一键卸载:** +| 平台 | 安装文件 | +|------|---------| +| macOS | `EMRG--macos-arm64.pkg` / `-x64.pkg`(用户级安装,无需管理员密码) | +| Windows | `EMRG--windows-x64.exe`(免 UAC,PATH 自动注册) | +| Linux | `EMRG--linux-x86_64.run` / `-aarch64.run`(无头服务器,一条命令)· `-x86_64.AppImage` / `-aarch64.AppImage`(桌面) | -```bash -curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -s -- purge -``` +> **Windows SmartScreen 提示**:安装包未做 Authenticode 签名——如 SmartScreen 提示,点**保留** / **更多信息 → 仍要运行**。EMRG 完全开源,源码可审计。 -### 🪟 Windows (WSL2,源码安装) +**首次使用**:打开 **EMRG** → 引导配置 **API Key / 模型** → 开始对话。TUI 同步可用:任意终端运行 `emrg` 即可(配置共享)。 -**一键安装:** - -```powershell -# 安装 WSL2(如已安装可跳过) -wsl --install - -# 进入 WSL,执行安装 -wsl -curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -``` +> **自带 API Key** — EMRG 使用你自己的 LLM API Key 和额度/账单;软件本身免费、MIT 开源。 -**一键卸载:** +> 想从源码安装,或需要高级配置 / 架构 / 贡献者文档?→ [DEVELOPMENT.md](DEVELOPMENT.md) -```bash -# 在 WSL 中执行 -curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -s -- purge -``` - -> 源码安装前置依赖(install.sh 会自动检测提示):git、python 3.11+、uv。gh CLI 推荐安装。Windows 原生版(非 WSL)请用上方安装包。 - -### 🖥️ 首次配置(GUI 第一) +--- -安装完成后,**打开 GUI 完成首次配置**: +## 运行 TUI(终端版) -1. **macOS**:启动台 → 点击 **EMRG**;**Windows**:开始菜单 → **EMRG** -2. 首次启动引导会带你配置 **API Key / 接口地址 / 模型**(也随时可在设置 ⚙ 中修改) -3. 保存后即可开始对话——**配置写入 `~/.emrg/config.toml`,GUI 与 TUI 共享** +EMRG 骨子里是个终端应用——Electron GUI 只是同一引擎上的便捷外壳。 -> 💡 **GUI 配好,TUI 直接用**:安装包内置完整 TUI。GUI 保存的配置(API Key/模型/工作目录)写入 `~/.emrg/config.toml`,终端新开窗口运行 `emrg` 即进入 TUI,无需重复配置。v0.2.8 起 GUI 支持**全部 15 个 / 指令**——输入框输入 `/` 弹出补全菜单(与 TUI 一致)。 +**运行**:打开终端,输入 `emrg`,回车即可。安装器已把 `emrg` 加入 PATH(含 Windows),首次运行会自动拉起后台守护进程并进入交互式 TUI。 -### ⌨️ 使用 TUI +首次运行 `emrg` 会自动拉起后台守护进程(`emrgd`)并进入交互式 TUI。守护进程常驻后台,可随时重连。 -```bash -emrg -``` +守护进程管理: +| 命令 | 作用 | +|---|---| +| `emrg` | 启动 TUI(必要时自动启动 daemon) | +| `emrg server` | 前台运行 daemon | +| `emrg server stop` | 停止后台 daemon | +| `emrg server restart` | 重启 daemon | +| `emrg rant [@project]` | 从命令行发反馈(无需进 TUI) | +| `emrg update` | 更新到最新版本 | -输入 `/help` 查看所有命令,或者直接开始说话——EMRG 会读文件、跑命令、做编辑。 - -### 🔧 高级配置(可选) - -> GUI 保存设置会重写 config 并丢失注释——高级用户可直接编辑 `~/.emrg/config.toml`(首次配置无需手动编辑,GUI 引导即可)。 - -`~/.emrg/config.toml` 模板示例(GUI 保存后自动生成等价内容): - -```toml -[llm] -base_url = "https://api.deepseek.com" -api_key = "sk-..." -model = "deepseek-chat" -max_tokens = 8192 -temperature = 0.7 -context_window = 131072 -auto_compact_threshold = 0.0 -# vision: 模型是否支持 OpenAI vision API(image_url)。不支持的模型(如 DeepSeek)保持 false, -# 粘贴的图片会降级为文本占位符,避免 API 报错。 -vision = false - -# 多模型支持 — 使用 /model 指令在模型间切换 -[[llm.models]] -name = "deepseek-v3" -model = "deepseek-chat" -context_window = 131072 -vision = false - -[[llm.models]] -name = "gpt-4o" -model = "gpt-4o" -context_window = 128000 -vision = true -``` +TUI 内:`Esc` 中断流式输出,`Ctrl+C` / `exit` 退出(配置与 GUI 共享——API Key 只需在一处设置一次)。 --- -## 🎮 命令一览 +## 命令一览 -> v0.2.8 起**全部命令在 GUI 与 TUI 均可使用**。GUI 中输入框敲 `/` 弹出补全菜单;TUI 中 `/help` 列出全部。 +> 全部命令在 GUI 与 TUI 均可使用(GUI 输入框敲 `/` 弹出补全菜单;TUI `/help` 列出全部)。 | 命令 | 功能 | |---|---| | **直接打字** | 问 EMRG 任何事——它会读文件、跑命令、做编辑 | | `/` | 命令自动补全菜单——输入即过滤,↑↓ 选择 | -| `/resume [id]` | 切换会话——不带参数进入交互式选择器(↑↓/j/k 导航) | -| `/sessions` | 浏览所有已保存的会话(↑↓/j/k 导航) | +| `/resume [id]` | 切换会话——不带参数进入交互式选择器(↑↓/j/k) | +| `/sessions` | 浏览所有已保存的会话(↑↓/j/k) | | `/clear` | 清空当前会话——重新开始 | | `/compact` | 压缩长对话以节省上下文 | | `/memory` | 浏览项目和会话记忆 | | `/rename [标题]` | 给当前会话起个好记的名字 | | `/model [name]` | 切换 LLM 模型——不带参数进入交互式选择器 | -| `/rant <反馈> [@]` | 吐槽、建议、夸奖——演化系统会听,`@project` 定向到特定项目 | +| `/rant <反馈> [@]` | 吐槽、建议、夸奖——演化系统会听 | | `/help` | 查看所有键盘快捷键和命令帮助 | -| `/image` | 从剪贴板插入图片到输入框(支持多张,逐个 Enter 插入) | +| `/image` | 从剪贴板插入图片到输入框 | | `/delete [id]` | 删除会话——不带参数进入交互式选择器 | -| `/rewind` | 回退对话——选择历史消息点,截断后续内容 | -| `/trigger` | 触发演化任务——交互式选择器(↑↓/j/k) | -| `/skills` | 列出已加载的技能模块 | +| `/rewind` | 回退对话到历史某个节点 | +| `/trigger` | 触发演化任务——交互式选择器(任务管理在 GUI 设置 → 定时任务) | +| `/skills` | 列出已加载技能;`/skills available\|install\|update` | | `/version` | 显示 EMRG 版本和实例信息 | | `Esc` | 中断正在运行的响应 | | `Ctrl+C` / `exit` | 退出 | --- -## 🏗️ 架构 - -``` -┌─────────────┐ WebSocket (ws://) ┌──────────────┐ -│ emrg TUI │ ◄─────────────────────► │ emrgd │ -│ (客户端) │ TCP loopback + 首帧认证 │ (守护进程) │ -│ │ token (emrgd.port) │ │ -│ • 聊天 │ │ • LLM 循环 │ -│ • Markdown │ │ • 工具执行 │ -│ • 工具卡片 │ │ • 演化引擎 │ -│ • 自动补全 │ │ • 会话管理 │ -└─────────────┘ └──────────────┘ -``` - -- **`emrgd`** — 守护进程:运行 LLM 工具调用循环,管理会话,驱动演化 -- **`emrg`** — 你的终端:流式 Markdown、命令自动补全、会话浏览器 -- **Skills** — 动态加载模块(浏览器控制、安装器等) -- **Memory** — YAML frontmatter + Markdown 文件,自动索引,可搜索 - ---- - -## 📊 与竞品对比 - -| | Claude Code | Codex | **EMRG** | -|---|---|---|---| -| AI 驱动编程 | ✅ | ✅ | ✅ | -| 工具调用 (bash, read, write, edit, glob, grep) | ✅ | ✅ | ✅ | -| 会话记忆与上下文 | ✅ | ✅ | ✅ | -| `/` 命令自动补全 | ✅ | ✅ | ✅ | -| 方向键会话选择器 | ✅ | ✅ | ✅ | -| ESC 中断 | ✅ | ✅ | ✅ | -| **自主进化** | ❌ | ❌ | ✅ *全自动* | -| **后台守护进程** | ❌ | ❌ | ✅ *持久运行* | -| **吐槽驱动自我改进** | ❌ | ❌ | ✅ */rant → 演化 → PR* | -| **开源** | ❌ | ❌ | ✅ *MIT* | +## 与竞品对比 + +| | Claude Code | Codex | DeepSeek Harness | **EMRG** | +|---|---|---|---|---| +| AI 驱动编程 | ✅ | ✅ | ✅ | ✅ | +| 工具调用 (bash, read, write, edit, glob, grep) | ✅ | ✅ | ✅ | ✅ | +| 会话记忆与上下文 | ✅ | ✅ | ✅ | ✅ | +| `/` 命令自动补全 | ✅ | ✅ | ✅ | ✅ | +| ESC 中断 | ✅ | ✅ | — | ✅ | +| 插件化架构 | ❌ | ❌ | ✅ *一切皆插件* | ❌ | +| 沙箱执行 | ❌ | ❌ | ✅ | ❌ | +| 子代理 | ❌ | ❌ | ✅ | ❌ | +| Web UI | ❌ | ❌ | ✅ *浏览器* | ✅ *Electron* | +| **自主进化** | ❌ | ❌ | ❌ | ✅ *全自动* | +| **后台守护进程** | ❌ | ❌ | ❌ | ✅ *持久运行* | +| **吐槽驱动自我改进** | ❌ | ❌ | ❌ | ✅ */rant → PR* | +| **开源** | ❌ | ✅ *Apache-2.0* | ✅ *MIT* | ✅ *MIT* | + +> *DeepSeek Harness 是插件化 agent harness,工程纵深强(沙箱、子代理、工作流)——但它不自我进化。EMRG 的差异点是闭环:自进化、持久后台守护进程、吐槽驱动改进。* EMRG 不只是追赶——它自己追上来。 --- -## 🧪 开发 - -```bash -git clone https://github.com/argszero/emrg.git -cd emrg -uv sync # 安装依赖 -uv run pytest tests/ -v # 跑测试(当前 548 项) -uv run python -m emrg # 启动 TUI -# CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 - -# 可选:Electron GUI(非开发者主入口,Phase 3) -cd emrg/gui -npm ci # 安装依赖(生产模式可 --omit=dev) -npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(91 项:22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) -``` - -CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 - -### 项目结构 - -``` -emrg/ -├── emrg/ # 核心包 -│ ├── server/ # 守护进程——LLM 循环、工具执行、演化引擎 -│ ├── client/ # TUI——基于 python-tui 的交互式聊天 -│ ├── gui/ # Electron GUI(非开发者主入口,Phase 3) -│ ├── tools/ # bash, read, write, edit, glob, grep -│ ├── skills/ # 动态加载模块 -│ └── __main__.py # CLI 入口 -├── tests/ -├── .github/workflows/ # CI 流水线(pytest + 冲突标记检查) -├── MANIFESTO.md # 设计宪章 -└── pyproject.toml -``` - ---- - -## ❓ 常见问题 +## 常见问题 **这是真的吗——它真的会改自己的代码?**
真的。演化循环读取演化提示词,回顾 rant + issue + 竞品工具,修改源码,跑测试,然后提交 PR。如果测试失败,自动回滚。 **它会把自己搞崩吗?**
-每次改动都会通过 `pytest` 和 import 检查验证后才提交。失败的改动会被丢弃。最坏的情况就是回滚。 +每次改动都会通过 `pytest` 和 import 检查验证后才提交。失败的改动会被丢弃;最坏的情况就是回滚。 -**支持哪些 LLM?**
-任何兼容 OpenAI API 的模型。已测试 DeepSeek 和 OpenAI。支持 Anthropic(通过代理)、Ollama、vLLM 及其他本地模型。 +**它会动我项目的代码吗?**
+不会。自我进化只修改它自己的仓库(`~/.emrg/evolution/emrg`),从不碰你的项目文件。它在你项目上运行的工具,只有你让它运行的那些——而且它做的每处改动都是经过测试、走 PR 评审的。 **和 Claude Code 或 Codex 有什么不同?**
-它们是产品。EMRG 是一个关于*闭环进化*的实验——AI 改进 AI。此外:完全开源、无厂商锁定、你掌控自己的数据。 +它们是产品。EMRG 是一个关于*闭环进化*的实验——AI 改进 AI。完全开源,无厂商锁定。 + +--- + +## 开发 + +贡献指南、源码安装、架构、详细 FAQ → [DEVELOPMENT.md](DEVELOPMENT.md)。 -**为什么 Windows 安装包会提示"未知发布者"?**
-Windows 安装包未做 Authenticode 签名(该证书需付费申请,暂不采购),因此 SmartScreen 会显示"发布者:未知"并可能阻止运行。这是微软对新发布/未签名软件的通用安全提醒,**不代表文件有问题**——EMRG 完全开源(MIT),源码可审计。放行:浏览器提示点"保留";运行提示点"更多信息 → 仍要运行";或右键 exe → 属性 → 勾选"解除锁定"。macOS 安装包已签名+公证(v0.2.7+),无此问题。 +快速检查:`uv run pytest tests/ -v` · `cd emrg/gui && npm test`(测试:见上方徽章) --- -## 📜 许可证 +## 许可证 MIT — 详见 [LICENSE](LICENSE) 了解完整条款,[MANIFESTO.md](MANIFESTO.md) 了解代码背后的设计哲学。 diff --git a/README.md b/README.md index d00faf69..64f45b4c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# 🧱 EMRG +# EMRG + +

+ EMRG +

The AI coding agent that writes code — and rewrites itself. @@ -20,311 +24,180 @@ **What if your coding assistant got better every time you used it?** -EMRG is an experiment in *autonomous self-improvement*. It's an AI agent that helps you code — reading files, running commands, making edits — but the key difference is: **every `/rant` you send drives it to improve itself**. Tell it what bothers you, and the next evolution cycle writes code to fix it. Combined with GitHub community activity and competitor tracking, EMRG evolves continuously in the background, getting better the more you use it. All open source, all transparent. +EMRG is an experiment in *autonomous self-improvement*: an AI agent that helps you code — reading files, running commands, making edits — and **every `/rant` you send drives it to improve itself**. Tell it what bothers you, and the next evolution cycle writes the fix and ships it. Fully open source, fully transparent. > *"EMRG is an experiment in self-evolving AI agent architecture."* — [MANIFESTO](MANIFESTO.md) +> +> *Everyone is a product manager — every rant is a ticket for the next release.* +> *Everyone is a host of silicon life — what you run is a digital organism that evolves with you.* + +**EMRG** — pronounced *"emerge"*: intelligence that *emerges* — from scale, and from use. The name expands to **E**volving **M**icro-kernel, **R**ant-driven **G**rowth. --- -## ✨ Why you'll love it +## Why you'll love it + +**The one-line pitch**: EMRG is the only coding agent that **improves itself from your feedback** — rant about what bothers you, and the next evolution cycle writes the fix and ships it. -**The one-line pitch**: EMRG is the only coding agent that **improves itself from your feedback** — rant about what bothers you, and the next evolution cycle writes the fix and ships it. Chat, tools, and memory are what you'd expect; the self-improvement loop is what you won't find anywhere else. +> **The one feature: self-improvement.** Every `/rant` becomes a real PR — coded, tested, merged, unattended. No other coding agent does this. -| What | What it means | +| What | Why it's different | |---|---| -| 🔄 **Gets better on its own** | **The core feature.** Background evolution cycles turn your `/rant`s, GitHub issues, and competitor updates into real improvements — analyzed, coded, tested, PR'd, merged. Self-healing workspace: packaged installs clone the repo on demand and bootstrap projects/tasks automatically | -| 🖥️ **Electron GUI (main entry)** | Install & go: first-run wizard configures your API key; all 15 slash commands work in the GUI (`/rant` evolution dialog, `/memory` browser…); WorkBuddy-inspired **results panel**, **Ask/Auto** modes, **visible self-evolution** (growth card + toasts). GUI configured = TUI ready | -| 🧠 **Reads, writes, edits, runs** | Full tool-calling agent — bash, files, diffs, glob, grep | -| 📝 **Never forgets** | Project memory + session memory + daily logs — context that persists | -| ⚡ **Full-featured TUI + daemon** | Streaming markdown, `/` autocomplete, session picker, ESC interrupt, vim-friendly keys, parallel tool calls — on a persistent `emrgd` daemon you can reconnect to anytime | -| 🌍 **100% open source** | MIT — no walled garden, no vendor lock-in. Internationalized: English default, Chinese version available | +| **Electron GUI (main entry)** | Install & go: first-run wizard, all slash commands, results panel, Ask/Auto modes, visible self-evolution. GUI configured = TUI ready | +| **Full-featured TUI + daemon** | Streaming markdown, `/` autocomplete, sessions, shortcuts — on a persistent `emrgd` daemon you can reconnect to anytime | +| **Scheduled tasks** | Built-in + custom task types with prompt templates (`~/.emrg/task-templates/`), CRUD + hot reload from the GUI settings, `/trigger` any task on demand | +| **100% open source** | MIT — no walled garden, no vendor lock-in. English default, Chinese version available | --- -## 🔄 Rant-Driven Evolution (the core feature) +## Rant-Driven Evolution (the core feature) -EMRG isn't just a tool — it's a coding partner that **listens to your complaints and improves itself**. **Your rants are the primary driver of evolution.** Every `/rant` is read, analyzed, and turned into code improvements: +EMRG isn't just a tool — it's a coding partner that **listens to your complaints and improves itself**: ``` - 📢 Your rants (/rant) ←── primary input - 📥 GitHub Issues & PRs - 📥 Competitor tools (Codex, Claude Code) - 📥 Cross-project learning - ↓ - 🧬 Evolution Cycle (every 30 min) - (Prepare → Review → Discover → Improve → Commit → Record) - ↓ - ✅ pytest + import check - ✅ git commit + push → PR - ✅ Evolution log +Inputs: + - Your rants (/rant) <- primary input + - GitHub Issues & PRs + - Competitor tools (Codex, Claude Code) + - Cross-project learning + | + v +Evolution Cycle (every 30 min) +(Prepare -> Review -> Discover -> Improve -> Commit -> Record) + | + v + 1. pytest + import check + 2. git commit + push -> PR + 3. Evolution log ``` -**Real example**: Someone ranted "TUI needs `/` autocomplete like Codex." Next evolution cycle, EMRG built it — complete with prefix filtering and arrow-key navigation. Merged. Deployed. Done. **What you rant about, it improves.** +**Real example**: Someone ranted "TUI needs `/` autocomplete like Codex." The next evolution cycle built it — prefix filtering and arrow-key navigation, merged and deployed. **What you rant about, it improves.** -Runs fully unattended: `gh` auth auto-recovers from your git credentials, PR votes use the REST API, and packaged-install workspaces self-heal (clone on demand, auto-bootstrap projects/tasks). See [MANIFESTO.md](MANIFESTO.md) — EMRG's design charter on autonomous evolution. +**How to contribute?** Use it. Connect GitHub in Settings, and rant. Your rants become real PRs — the evolution cycle codes, tests, and ships them. No fork, no clone, no code required. **Using EMRG is contributing to EMRG.** --- -## 🚀 Quick Start - -### 📦 Download installer (recommended, Phase 4 one-click) - -Download the installer for your platform from [GitHub Releases](https://github.com/argszero/emrg/releases) and double-click: - -| Platform | Installer | Notes | -|----------|-----------|-------| -| macOS (Apple Silicon) | `EMRG--macos-arm64.pkg` | Double-click (user-level install, no admin password); GUI at `~/Applications/EMRG.app` | -| macOS (Intel) | `EMRG--macos-x64.pkg` | Same | -| Windows | `EMRG--windows-x64.exe` | Inno Setup, no UAC, Start-menu shortcut, PATH auto-registered (native TUI: run `emrg` directly in cmd/PowerShell) | -| Linux | `EMRG--linux-x86_64.AppImage` | Self-extracts to `~/.emrg/install/` on first run | -| Linux (ARM64) | `EMRG--linux-aarch64.AppImage` | Same | - -> **Windows SmartScreen notice**: The Windows installer is not Authenticode-signed (publisher shows "Unknown"), so SmartScreen may show "usually doesn't download" or "Windows protected your PC" on first download/run. This is a standard security prompt for unsigned software — it does **not** mean the file is bad (EMRG is fully open source and auditable). To proceed: -> (1) Browser download prompt → click **Keep** (or ⋯ → Keep) -> (2) If double-clicking shows "Windows protected your PC" → click **More info** → click **Run anyway** -> (3) Or right-click the exe → Properties → check **Unblock** (if present) → OK → double-click to run - -The installer bundles a full runtime (standalone Python 3.13 + deps + git + gh + GUI) — **zero prerequisites on a clean machine (no python/uv/git/gh/node)**, 100% offline install. After install: - -**Three steps to start:** -1. Launch **EMRG** (GUI) from Launchpad / Start menu -2. First-run wizard configures **API Key / model** -3. Start chatting — **TUI is ready too** (run `emrg` in a new terminal) - -> **Uninstall**: macOS run "卸载 EMRG.app" (uninstall app); Windows uninstall from Control Panel; Linux run `~/.emrg/install/bin/emrg-uninstall` (or delete the AppImage + symlink). Uninstall preserves non-EMRG user files in `~/.emrg`, and writes a termination report + data snapshot. -> -> **macOS signing & notarization**: since v0.2.7, macOS packages are signed with Developer ID dual-cert and notarized by Apple (zero Gatekeeper dialogs — double-click to install directly). Only for unsigned builds (e.g. self-built old versions) is right-click → Open needed on first launch. - -### 🍎 macOS (source install) - -**Install:** - -```bash -curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -``` - -**Uninstall:** - -```bash -curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -s -- purge -``` - -### 🐧 Linux (source install) +## Quick Start -**Install:** +### Download the installer (recommended) -```bash -curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -``` +Download from [GitHub Releases](https://github.com/argszero/emrg/releases) and double-click — the installer bundles everything (Python 3.13 + git + gh + GUI), **zero prerequisites, offline install**: -**Uninstall:** +| Platform | Installer | +|----------|-----------| +| macOS | `EMRG--macos-arm64.pkg` / `-x64.pkg` (user-level, no admin password) | +| Windows | `EMRG--windows-x64.exe` (no UAC, PATH auto-registered) | +| Linux | `EMRG--linux-x86_64.run` / `-aarch64.run` (headless server, one command) · `-x86_64.AppImage` / `-aarch64.AppImage` (desktop) | -```bash -curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -s -- purge -``` +> **Windows SmartScreen notice**: the installer isn't Authenticode-signed — if SmartScreen prompts, click **Keep** / **More info → Run anyway**. EMRG is fully open source and auditable. -### 🪟 Windows (WSL2, source install) +**First time**: launch **EMRG** → the wizard sets your **API key / model** → start chatting. The TUI is ready too: run `emrg` in any terminal (config is shared). -**Install:** +> **Bring your own key** — EMRG uses your LLM API key and your quota/billing; the software itself is free and MIT-licensed. -```powershell -# Install WSL2 (skip if already installed) -wsl --install +> Prefer building from source, or need advanced config / architecture / contributing docs? → [DEVELOPMENT.md](DEVELOPMENT.md) -# Enter WSL, then install -wsl -curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -``` - -**Uninstall:** - -```bash -# Run inside WSL -curl -sSL https://raw.githubusercontent.com/argszero/emrg/master/install.sh | bash -s -- purge -``` - -> Source-install prerequisites (install.sh auto-detects and prompts): git, python 3.11+, uv. gh CLI recommended. For native Windows (non-WSL), use the installer above. - -### 🖥️ First-time config (GUI first) +--- -After installing, **open the GUI to configure**: +## Running the TUI -1. **macOS**: Launchpad → **EMRG**; **Windows**: Start menu → **EMRG** -2. The first-run wizard walks you through **API Key / base URL / model** (also editable anytime in Settings ⚙) -3. Save and start chatting — **config is written to `~/.emrg/config.toml`, shared by GUI and TUI** +EMRG is a terminal app at heart — the Electron GUI is a convenience shell on the same engine. -> 💡 **GUI configured = TUI ready**: the installer bundles a full TUI. Config saved in the GUI (API key/model/workdir) goes to `~/.emrg/config.toml`, so running `emrg` in a new terminal enters the TUI with no re-configuration. Since v0.2.8 the GUI supports **all 15 slash commands** — type `/` in the input box for an autocomplete menu (same as the TUI). +**Run**: open a terminal, type `emrg`, press Enter — that's it. The installer puts `emrg` on your PATH (including Windows), and the first run auto-starts the background daemon and drops you into the interactive TUI. -### ⌨️ Using the TUI +The first `emrg` run starts a background daemon (`emrgd`) and drops you into the interactive TUI. The daemon stays alive so you can reconnect anytime. -```bash -emrg -``` +Daemon management: +| Command | What it does | +|---|---| +| `emrg` | Start the TUI (auto-starts the daemon if needed) | +| `emrg server` | Run the daemon in the foreground | +| `emrg server stop` | Stop the background daemon | +| `emrg server restart` | Restart the daemon | +| `emrg rant [@project]` | Send feedback from the CLI (no TUI needed) | +| `emrg update` | Update to the latest version | -Type `/help` for all commands, or just start talking — EMRG reads files, runs commands, and makes edits. - -### 🔧 Advanced config (optional) - -> The GUI rewrites config on save and drops comments — advanced users can edit `~/.emrg/config.toml` directly (no manual editing needed for first-time setup; the GUI wizard handles it). - -`~/.emrg/config.toml` template example (the GUI generates equivalent content on save): - -```toml -[llm] -base_url = "https://api.deepseek.com" -api_key = "sk-..." -model = "deepseek-chat" -max_tokens = 8192 -temperature = 0.7 -context_window = 131072 -auto_compact_threshold = 0.0 -# vision: whether the model supports the OpenAI vision API (image_url). Keep false for -# non-vision models (e.g. DeepSeek) — pasted images degrade to text placeholders to avoid API errors. -vision = false - -# Multi-model support — use /model to switch between models -[[llm.models]] -name = "deepseek-v3" -model = "deepseek-chat" -context_window = 131072 -vision = false - -[[llm.models]] -name = "gpt-4o" -model = "gpt-4o" -context_window = 128000 -vision = true -``` +In the TUI: `Esc` interrupts streaming, `Ctrl+C` / `exit` quits (config is shared with the GUI — set your API key once in either). --- -## 🎮 Commands +## Commands -> Since v0.2.8, **all commands work in both the GUI and the TUI**. In the GUI, type `/` in the input box for an autocomplete menu; in the TUI, `/help` lists everything. +> All commands work in both the GUI (type `/` for autocomplete) and the TUI (`/help` lists everything). | Command | What it does | |---|---| | **Just type** | Ask EMRG anything — it reads files, runs commands, makes edits | | `/` | Autocomplete menu — type to filter, ↑↓ to select | -| `/resume [id]` | Switch sessions — no args for interactive picker (↑↓/j/k to navigate) | -| `/sessions` | Browse all saved sessions (↑↓/j/k to navigate) | +| `/resume [id]` | Switch sessions — no args for interactive picker (↑↓/j/k) | +| `/sessions` | Browse all saved sessions (↑↓/j/k) | | `/clear` | Clear current session — start fresh | | `/compact` | Compress long conversations to save context | | `/memory` | Browse project & session memories | | `/rename [title]` | Give your session a memorable name | | `/model [name]` | Switch LLM model — no args for interactive picker | -| `/rant [@]` | Complain, suggest, praise — evolution listens; `@project` targets a specific project | +| `/rant [@]` | Complain, suggest, praise — evolution listens | | `/help` | Show keyboard shortcuts and command help | -| `/image` | Insert clipboard image into the input field (multiple supported, one per Enter) | +| `/image` | Insert clipboard image into the input field | | `/delete [id]` | Delete a session — no args for interactive picker | -| `/rewind` | Rewind conversation — pick a history point and truncate after it | -| `/trigger` | Trigger an evolution task — interactive picker (↑↓/j/k) | -| `/skills` | List loaded skill modules | +| `/rewind` | Rewind conversation to a history point | +| `/trigger` | Trigger an evolution task — interactive picker (manage tasks in GUI Settings → Scheduled tasks) | +| `/skills` | List loaded skills; `/skills available|install|update` | | `/version` | Show EMRG version and instance info | | `Esc` | Interrupt a running response mid-stream | | `Ctrl+C` / `exit` | Quit | --- -## 🏗️ Architecture - -``` -┌─────────────┐ WebSocket (ws://) ┌──────────────┐ -│ emrg TUI │ ◄─────────────────────► │ emrgd │ -│ (client) │ TCP loopback + auth │ (daemon) │ -│ │ token (emrgd.port) │ │ -│ │ │ │ -│ • Chat │ │ • LLM loop │ -│ • Markdown │ │ • Tools │ -│ • ToolCards│ │ • Evolution │ -│ • Autocomplete │ • Sessions │ -└─────────────┘ └──────────────┘ -``` - -- **`emrgd`** — The daemon: runs the LLM tool-calling loop, manages sessions, drives evolution -- **`emrg`** — Your terminal: streaming markdown, command autocomplete, session browser -- **Skills** — Dynamically loaded modules (browser harness, installers, etc.) -- **Memory** — YAML frontmatter + Markdown files, auto-indexed, searchable - ---- - -## 📊 vs. the competition - -| | Claude Code | Codex | **EMRG** | -|---|---|---|---| -| AI-powered coding | ✅ | ✅ | ✅ | -| Tool-calling (bash, read, write, edit, glob, grep) | ✅ | ✅ | ✅ | -| Session memory & context | ✅ | ✅ | ✅ | -| `/` command autocomplete | ✅ | ✅ | ✅ | -| Arrow-key session picker | ✅ | ✅ | ✅ | -| ESC interrupt | ✅ | ✅ | ✅ | -| **Self-evolution** | ❌ | ❌ | ✅ *autonomous* | -| **Background daemon** | ❌ | ❌ | ✅ *persistent* | -| **Learns from rants** | ❌ | ❌ | ✅ */rant → PR* | -| **Open source** | ❌ | ❌ | ✅ *MIT* | +## vs. the competition + +| | Claude Code | Codex | DeepSeek Harness | **EMRG** | +|---|---|---|---|---| +| AI-powered coding | ✅ | ✅ | ✅ | ✅ | +| Tool-calling (bash, read, write, edit, glob, grep) | ✅ | ✅ | ✅ | ✅ | +| Session memory & context | ✅ | ✅ | ✅ | ✅ | +| `/` command autocomplete | ✅ | ✅ | ✅ | ✅ | +| ESC interrupt | ✅ | ✅ | — | ✅ | +| Plugin architecture | ❌ | ❌ | ✅ *everything is a plugin* | ❌ | +| Sandboxed execution | ❌ | ❌ | ✅ | ❌ | +| Subagents | ❌ | ❌ | ✅ | ❌ | +| Web UI | ❌ | ❌ | ✅ *browser* | ✅ *Electron* | +| **Self-evolution** | ❌ | ❌ | ❌ | ✅ *autonomous* | +| **Background daemon** | ❌ | ❌ | ❌ | ✅ *persistent* | +| **Learns from rants** | ❌ | ❌ | ❌ | ✅ */rant → PR* | +| **Open source** | ❌ | ✅ *Apache-2.0* | ✅ *MIT* | ✅ *MIT* | + +> *DeepSeek Harness is a plugin-based agent harness with deep engineering (sandboxing, subagents, workflows) — but it does not evolve itself. EMRG's differentiator is closing the loop: self-evolution, a persistent background daemon, and rant-driven improvement.* EMRG doesn't just keep up — it catches up on its own. --- -## 🧪 Development - -```bash -git clone https://github.com/argszero/emrg.git -cd emrg -uv sync # install deps -uv run pytest tests/ -v # run tests (currently 572 items) -uv run python -m emrg # launch TUI -# CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI - -# Optional: Electron GUI (non-developer entry point, Phase 3) -cd emrg/gui -npm ci # install deps (production: --omit=dev) -npm start # launch GUI (auto-starts daemon) -npm test # run Node tests (91: 22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration) -``` - -CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`). - -### Project structure - -``` -emrg/ -├── emrg/ # Core package -│ ├── server/ # Daemon — LLM loop, tool execution, evolution -│ ├── client/ # TUI — python-tui based interactive chat -│ ├── gui/ # Electron GUI (non-developer entry point, Phase 3) -│ ├── tools/ # bash, read, write, edit, glob, grep -│ ├── skills/ # Dynamically loadable modules -│ └── __main__.py # CLI entry point -├── tests/ -├── .github/workflows/ # CI pipeline (pytest + conflict marker check) -├── MANIFESTO.md # Design constitution -└── pyproject.toml -``` - ---- - -## ❓ FAQ +## FAQ **Is this real — does it actually modify its own code?**
Yes. The evolution cycle reads the evolution prompt, reviews rants + issues + competitor tools, makes source changes, runs tests, and submits a PR. If tests fail, it rolls back. **Can it break itself?**
-Every change is validated by `pytest` and an import check before commit. Failed changes are discarded. The worst case is a rollback. +Every change is validated by `pytest` and an import check before commit. Failed changes are discarded; the worst case is a rollback. -**What LLMs work with it?**
-Any OpenAI-compatible API. Tested with DeepSeek and OpenAI. Works with Anthropic (via proxy), Ollama, vLLM, and other local models. +**Will it touch my project's code?**
+No. Self-evolution only modifies its own repository (`~/.emrg/evolution/emrg`), never your project files. The tools it runs on your project are only the ones you ask it to run — and every change it makes is a reviewed PR, tested before merge. **How is this different from Claude Code or Codex?**
-They're products. EMRG is an experiment in *closing the loop* — the AI improves the AI. Also: fully open source, no vendor lock-in, and you control your data. +They're products. EMRG is an experiment in *closing the loop* — the AI improves the AI. Fully open source, no vendor lock-in. + +--- + +## Development + +Contributing, source installs, architecture, and the full FAQ → [DEVELOPMENT.md](DEVELOPMENT.md). -**Why does the Windows installer show "Unknown publisher"?**
-The Windows installer is not Authenticode-signed (that certificate costs money to obtain and is not procured yet), so SmartScreen shows "Publisher: Unknown" and may block the run. This is a standard Microsoft security prompt for newly released/unsigned software — it does **not** mean the file is bad: EMRG is fully open source (MIT) and auditable. To proceed: click "Keep" on the browser prompt; click "More info → Run anyway" on the run prompt; or right-click the exe → Properties → check "Unblock". The macOS installer is signed + notarized (v0.2.7+) and has no such prompt. +Quick checks: `uv run pytest tests/ -v` · `cd emrg/gui && npm test` (tests: see badge above) --- -## 📜 License +## License MIT — see [LICENSE](LICENSE) for the full terms and [MANIFESTO.md](MANIFESTO.md) for the philosophy behind the code. diff --git a/bin/emrg.cmd b/bin/emrg.cmd index 7fba601e..ab9ece40 100644 --- a/bin/emrg.cmd +++ b/bin/emrg.cmd @@ -1,17 +1,17 @@ @echo off -REM EMRG launcher — installed at %USERPROFILE%\.emrg\install\bin\emrg.cmd -REM Phase 4 installer (rant #12) §2: -REM - R60: Windows git lives in install\git\ — PATH needs git\cmd + git\mingw64\bin. +REM EMRG launcher -- installed at %USERPROFILE%\.emrg\install\bin\emrg.cmd +REM Phase 4 installer (rant #12) section 2: +REM - R60: Windows git lives in install\git\ -- PATH needs git\cmd + git\mingw64\bin. REM - R35: %~dp0 under a .lnk shortcut resolves to the .cmd's real location. REM - R13: PYTHONPATH includes source\ (parent of the emrg package). -REM - R61: source\ is read-only — PYTHONDONTWRITEBYTECODE (zero-write acceptance). +REM - R61: source\ is read-only -- PYTHONDONTWRITEBYTECODE (zero-write acceptance). set SOURCE=%~dp0 set DIR=%SOURCE:~0,-1% set PREFIX=%DIR%\.. set PATH=%DIR%;%PREFIX%\git\cmd;%PREFIX%\git\mingw64\bin;%PATH% set PYTHONPATH=%PREFIX%\source;%PREFIX%\lib;%PYTHONPATH% set PYTHONDONTWRITEBYTECODE=1 -REM R90: python.exe 复制品在 bin/,DLL 在 python-dist/ → loader 找不到 → 用 python-dist 里的 exe(与 DLL 同目录) +REM R90: python.exe copy lives in bin/, DLLs in python-dist/ -> loader would miss them -> use the exe in python-dist (same dir as DLLs) set PYEXE=%DIR%\python-dist\python.exe if not exist "%PYEXE%" set PYEXE=%DIR%\python-dist\python3.13.exe "%PYEXE%" -m emrg %* diff --git a/bin/emrgd.cmd b/bin/emrgd.cmd index 889f6bf6..283b7ed2 100644 --- a/bin/emrgd.cmd +++ b/bin/emrgd.cmd @@ -1,6 +1,6 @@ @echo off -REM EMRG daemon launcher — installed at %USERPROFILE%\.emrg\install\bin\emrgd.cmd -REM Phase 4 installer (rant #12) §2 — same structure as bin\emrg.cmd; entry is +REM EMRG daemon launcher -- installed at %USERPROFILE%\.emrg\install\bin\emrgd.cmd +REM Phase 4 installer (rant #12) section 2 -- same structure as bin\emrg.cmd; entry is REM the server package. GUI main.js spawns this path (R36 shell:true, R66 windowsHide). set SOURCE=%~dp0 set DIR=%SOURCE:~0,-1% @@ -8,7 +8,19 @@ set PREFIX=%DIR%\.. set PATH=%DIR%;%PREFIX%\git\cmd;%PREFIX%\git\mingw64\bin;%PATH% set PYTHONPATH=%PREFIX%\source;%PREFIX%\lib;%PYTHONPATH% set PYTHONDONTWRITEBYTECODE=1 -REM R90: python.exe 复制品在 bin/,DLL 在 python-dist/ → loader 找不到 → 用 python-dist 里的 exe(与 DLL 同目录) -set PYEXE=%DIR%\python-dist\python.exe +REM R90: python.exe copy lives in bin/, DLLs in python-dist/ -> loader would miss them -> use the exe in python-dist (same dir as DLLs) +REM Windowless daemon: when the GUI spawns this script, python.exe (console subsystem) would open a black console window; +REM prefer pythonw.exe (GUI subsystem, no window). Logs go to ~/.emrg/emrgd.log +REM (RotatingFileHandler); StreamHandler only attaches, no console so logging is unaffected. +set PYEXE=%DIR%\python-dist\pythonw.exe +if not exist "%PYEXE%" set PYEXE=%DIR%\python-dist\python.exe if not exist "%PYEXE%" set PYEXE=%DIR%\python-dist\python3.13.exe +REM R124: `emrgd.cmd stop` - graceful daemon shutdown (rant 2026-08-10T08:50:44 installer pre-stop): +REM reuses the CLI `emrg server stop` (protocol shutdown -> ping-pid SIGTERM fallback, see +REM emrg/__main__.py _stop_daemon). pythonw has no console; dropped prints are harmless, shutdown needs no stdout. +REM uses labels not parenthesized blocks so %errorlevel% expands only after python exits. +if /I not "%~1"=="stop" goto :start +"%PYEXE%" -m emrg server stop +exit /b %errorlevel% +:start "%PYEXE%" -m emrg.server %* diff --git a/docs/gui-redesign.md b/docs/gui-redesign.md new file mode 100644 index 00000000..4b63fa42 --- /dev/null +++ b/docs/gui-redesign.md @@ -0,0 +1,28 @@ +# GUI Redesign — 工作区视图(Workspace Views) + +> v0.2(rant 2026-08-13T18:55:09,宿主否决 v0.1「侧边栏内展开窄面板」方案) +> v0.1(rant 2026-08-13T14:10:14):侧边栏 5 入口 + 侧边栏内展开面板(#743-#753,已被 v0.2 取代) + +## 术语表(宿主确认,2026-08-13) + +| 术语 | 英文 | 含义 | DOM | +|------|------|------|-----| +| 应用框架 | App Shell | GUI 整体(左侧边栏 + 右侧工作区) | `#app` | +| 侧边栏 | Sidebar | 左侧整栏 = 顶部导航 + 中部会话列表 + 底部状态区 | `#sidebar` | +| 导航 | Nav | 5 个视图切换入口(💬会话/📁项目/⏱任务/📣Rant/⚙设置) | `.side-nav-item[data-view]` | +| 会话列表 | Session List | 侧边栏中部的打开会话项(也是视图切换入口) | `#conv-list` / `#open-sessions` | +| 工作区 | Workspace | 右侧主区域,承载视图 | `#workspace` | +| 工作区视图 | Workspace View | 工作区内顶级可切换单元(互斥显示,状态保留) | `.workspace-view` | +| 会话视图 | Session View | 一个会话的工作区视图(聊天区 + 成果面板) | `.session-view` | +| 面板 / 区 / Tab | Panel / Area / Tab | 视图内部组件(成果面板、设置 Tab 等),不再是工作区顶级概念 | `.panel-tabs` 等 | + +## 设计原则 + +1. **导航点击 → 工作区整块切换视图**,与会话切换完全同机制:DOM 显隐(`.active` 互斥类),视图状态(滚动位置 / 草稿 / 表单)保留。 +2. 面板视图激活时:`#composer-wrap` / `#empty-state` / `#result-panel` / `#result-resizer` 隐藏;回会话视图时恢复。 +3. 点当前激活导航项 → 关闭回会话视图(toggle)。点 💬 → 始终回会话视图。 +4. 术语统一:DOM 用 `workspace-view` / `session-view`,无 `.side-panel` / `#chat-view` id 残留。 + +## 实现记录 + +- v0.2(2026-08-13):`#chat-view` → `#workspace`;4 个面板(projects/tasks/rants/settings)从 `#sidebar` 移入 `#workspace`,`.side-panel` → `.workspace-view`;删除 `#panel-sessions`;`data-panel` → `data-view`;`switchPanel` → `switchView`;`state.activePanel` → `state.activeView`。 diff --git a/emrg/__init__.py b/emrg/__init__.py index 054d1f37..be378463 100644 --- a/emrg/__init__.py +++ b/emrg/__init__.py @@ -1,3 +1,3 @@ """EMRG — a self-evolving AI agent architecture.""" -__version__ = "0.2.11" +__version__ = "0.2.65" diff --git a/emrg/__main__.py b/emrg/__main__.py index b8a589ae..bc686861 100644 --- a/emrg/__main__.py +++ b/emrg/__main__.py @@ -5,6 +5,7 @@ emrg server Run daemon in foreground emrg server stop Stop the running daemon emrg server restart Restart the daemon + emrg stop Stop ALL running emrg processes (daemon, TUI, GUI) emrg update git pull + reinstall from source """ @@ -23,7 +24,8 @@ from pathlib import Path from emrg import __version__ -from emrg.connect import cleanup_server, connect_to_server +from emrg._win import win32_no_window_kwargs +from emrg.connect import AuthError, cleanup_server, connect_to_server from websockets.exceptions import ConnectionClosed @@ -67,6 +69,23 @@ def _build_parser() -> argparse.ArgumentParser: ) # (no action = foreground run, handled in main()) + # emrg stop + stop_parser = sub.add_parser( + "stop", + help="Stop ALL running emrg processes (daemon, TUI, GUI)", + description="Stop every running emrg process: the daemon, TUI clients and " + "the GUI app. Graceful stop first, force-kill stragglers. Also kills the " + "bundled git tree on Windows and exits non-zero when residual processes " + "remain (used by the Windows installer pre-stop).", + ) + stop_parser.add_argument( + "--skip-gui", + action="store_true", + help="Do not stop the GUI app and exclude it from the residual verify " + "(used by the GUI's restart-to-apply: the GUI is the caller and " + "relaunches itself after stop_all exits).", + ) + # emrg update sub.add_parser( "update", @@ -108,6 +127,12 @@ def main() -> None: _run_daemon() elif parsed.command == "rant": _send_rant(" ".join(parsed.message), project=parsed.project) + elif parsed.command == "stop": + # Windows installer pre-stop depends on the non-zero exit code when + # residual processes remain (emrg/_stop_all.py owns the full logic). + # --skip-gui (rant 2026-08-21T12:44:34): the GUI's restart-to-apply + # calls `emrg stop --skip-gui` so its own process survives to relaunch. + sys.exit(_stop_all(skip_gui=getattr(parsed, "skip_gui", False))) elif parsed.command == "update": _run_update() else: @@ -126,6 +151,9 @@ def _start_daemon_background() -> subprocess.Popen: stdin=subprocess.DEVNULL, start_new_session=True, close_fds=True, + # Windows: background daemon spawn must not pop a console window + # (rant 2026-08-09T13:16:36 — cmd-window storm). + **win32_no_window_kwargs(), ) return proc @@ -134,11 +162,11 @@ async def _send_shutdown() -> bool: """Send a graceful shutdown message to the daemon. Returns True on success.""" try: ws = await asyncio.wait_for(connect_to_server(), timeout=3) - except (ConnectionRefusedError, FileNotFoundError, OSError, asyncio.TimeoutError): + except (ConnectionRefusedError, FileNotFoundError, OSError, asyncio.TimeoutError, AuthError): return False try: - await ws.send(json.dumps({"type": "shutdown"}, ensure_ascii=False)) + await ws.send(json.dumps({"type": "shutdown", "source": "emrg server stop"}, ensure_ascii=False)) frame = await asyncio.wait_for(ws.recv(), timeout=3) try: await ws.close() @@ -185,10 +213,49 @@ async def _get_pid(): print("daemon stopped.") else: print("daemon not running (no pid from ping).") - except (ConnectionClosed, OSError, asyncio.TimeoutError, json.JSONDecodeError): + except (ConnectionClosed, OSError, asyncio.TimeoutError, json.JSONDecodeError, AuthError): print("daemon not running.") +# ── Stop everything (`emrg stop`) ────────────────────────────── + +def _match_emrg_client(cmd: str) -> bool: + """True if a process command line belongs to an emrg process (TUI/daemon/GUI). + + Delegates to ``emrg._stop_all.match_cmdline`` — the single source of truth + shared with the standalone installer script (bin/stop_all.py). + """ + from emrg._stop_all import match_cmdline + return match_cmdline(cmd) + + +def _scan_emrg_client_pids(ps_output: str, own_pid: int) -> list[int]: + """Parse `ps -axww -o pid=,command=` output → pids of emrg processes. + + `own_pid` is excluded so `emrg stop` (itself `python -m emrg stop`) never + kills the CLI that is running it. Delegates to ``emrg._stop_all.scan_pids``. + """ + from emrg._stop_all import scan_pids + return scan_pids(ps_output, own_pid) + + +def _stop_all(skip_gui: bool = False) -> int: + """Stop every running emrg process: daemon, TUI, GUI (+ bundled git on + Windows) and verify. Returns 0 on clean stop, 1 when residual processes + remain — the Windows installer aborts on the non-zero exit. + + ``skip_gui=True`` (CLI ``--skip-gui``, rant 2026-08-21T12:44:34): the + GUI's restart-to-apply keeps its own process alive and excluded from the + residual verify — forwarded to ``stop_all``. + + All logic lives in ``emrg/_stop_all.py`` (pure stdlib) so the installer + can also run it standalone with the runtime's Python; this function is + the ``emrg stop`` CLI entry point that propagates the exit code. + """ + from emrg._stop_all import stop_all + return stop_all(skip_gui=skip_gui) + + def _restart_daemon() -> None: """Stop and restart the daemon.""" print("restarting daemon ...") @@ -207,7 +274,7 @@ def _run_daemon() -> None: logging.basicConfig( level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - datefmt="%H:%M:%S", + datefmt="%Y-%m-%d %H:%M:%S", ) # Suppress noisy httpcore/httpx DEBUG logs (rant #24) logging.getLogger("httpcore").setLevel(logging.WARNING) @@ -282,10 +349,17 @@ def _run_client(init_auto_evolve: bool = False) -> None: logging.basicConfig( level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - datefmt="%H:%M:%S", + datefmt="%Y-%m-%d %H:%M:%S", handlers=[ RotatingFileHandler( - str(log_path), maxBytes=10 * 1024 * 1024, backupCount=3 + str(log_path), maxBytes=10 * 1024 * 1024, backupCount=3, + # encoding="utf-8" — symmetric with the daemon's #556 fix: + # the default locale code page (GBK on zh-CN Windows) cannot + # encode U+FFFD and logging.emit would crash with "--- Logging + # error ---", polluting the shared TUI terminal. errors= + # "backslashreplace" is defense-in-depth: logging must never + # crash on exotic characters (rant 2026-08-08T09:35:30). + encoding="utf-8", errors="backslashreplace", ), ], ) @@ -334,6 +408,7 @@ def _run_update() -> None: text=True, encoding="utf-8", timeout=10, + **win32_no_window_kwargs(), ) if result.returncode != 0: print(f"git pull failed:\n{result.stderr}", file=sys.stderr) @@ -350,6 +425,7 @@ def _run_update() -> None: capture_output=True, text=True, encoding="utf-8", + **win32_no_window_kwargs(), ) if result.returncode != 0: print(f"reinstall failed:\n{result.stderr}", file=sys.stderr) diff --git a/emrg/_stop_all.py b/emrg/_stop_all.py new file mode 100644 index 00000000..02d9e4c4 --- /dev/null +++ b/emrg/_stop_all.py @@ -0,0 +1,1796 @@ +"""Stop every running EMRG process — pure standard library (no emrg imports). + +This module converges the old ``bin/stop-emrg.cmd`` logic into one Python +implementation (host rant 2026-08-17T10:32:27 — "不要 stop-emrg.cmd 了, +所有动作都在 emrg stop 命令里完成"). It is the single source of truth for +stopping the daemon / TUI / GUI / bundled git before a Windows installer +overwrites ``~/.emrg/install`` (the pythonw daemon holds file locks that +Inno's CloseApplications cannot see). + +It runs in three contexts, which is why it must stay pure-stdlib: + +1. ``emrg stop`` → ``emrg.__main__._stop_all`` delegates to :func:`stop_all` + and propagates the exit code. +2. ``python -m emrg._stop_all`` (module mode, same code path). +3. Standalone script: the Inno installer extracts this single file to + ``{tmp}`` and executes it with the *runtime's* Python + (``{app}\\bin\\python-dist\\python.exe``), where no emrg package / + third-party modules are importable (``sys.path[0]`` is ``{tmp}``). + +Steps (mirroring the old stop-emrg.cmd flow, all best-effort): + +- GUI: Windows ``taskkill /IM EMRG.exe`` graceful → unconditional + ``/F``; POSIX ps-scan (``EMRG.app`` / ``EMRG-*.AppImage``) +- TUI: Windows CIM filter ``python.exe|pythonw.exe -m emrg`` (not + ``emrg.server``); POSIX ps-scan +- daemon: ws protocol ``shutdown`` → fixed-port TCP probe wait → + SIGTERM / ``taskkill /F /PID`` → cmdline-scan fallback + (``-m emrg.server``, rant 2026-08-17T17:03:38); port file + removed once dead +- bundled git: Windows ``install\\git\\`` prefix kill (git/ssh/plink/bash + + fallback prefix full-kill — port of stop-emrg.cmd step 4) +- verify: residual scan; any survivor → ``exit 1`` with a named list + (installer aborts and shows the log, R125 semantics) + +Order is deliberate: **clients (GUI/TUI) first, daemon LAST** (host rant +2026-08-17T14:15:33). Both GUI and TUI auto-spawn the daemon when they +detect it missing — stopping the daemon first while clients are alive makes +them immediately re-spawn it, so the stop "stops nothing" and the installer +still hits locked files. With the daemon last, no client remains to bring +it back, and verify() sees the true final state. + +``--skip-gui`` mode (host rant 2026-08-21T12:44:34, GUI "restart to apply"): +the GUI itself invokes this module as ``python -m emrg._stop_all --skip-gui`` +to tear down every TUI client + the daemon before relaunching itself. The +GUI process MUST be skipped by both the step plan and the residual verify — +otherwise ``stop_gui`` (taskkill /IM EMRG.exe / ps-scan EMRG.app) kills the +GUI main process that is supposed to ``app.relaunch()`` right after, and +verify() would report the (intentionally still-alive) GUI as a residual and +exit 1. The GUI performs the relaunch itself. +""" + +from __future__ import annotations + +import base64 +import json +import os +import platform +import re +import secrets +import signal +import socket +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path + +# Build stamp printed at the start of every run so the operator can tell at a +# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31). +_STOP_ALL_STAMP = "built 2026-08-21 (--skip-gui mode for GUI restart-to-apply — rant 12:44:34)" + +# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed +# loopback port as its single-instance admission. This module is pure stdlib +# (runs standalone inside the installer) so it cannot import emrg.connect — +# keep in sync with emrg.connect.EMRGD_PORT. +_EMRGD_PORT = 56031 + +_EMRG_CLIENT_RE = re.compile(r"-m\s+emrg(\.server)?(\s|$)") +# daemon-only cmdline identity (``-m emrg.server`` — rant 2026-08-21T16:45:06: +# the fixed port is the daemon ground truth, cmdline scan is the kill fallback; +# TUI clients use ``-m emrg`` and are stopped earlier in the chain). +_EMRG_SERVER_RE = re.compile(r"-m\s+emrg\.server(\s|$)") +_APPIMAGE_RE = re.compile(r"EMRG-[\w.\-]*AppImage(\s|$)") + + +def is_win() -> bool: + """True on Windows (incl. Git Bash launched python).""" + return sys.platform == "win32" + + +def config_dir() -> Path: + """The EMRG runtime directory (~/.emrg), matching emrg.config.config_dir.""" + return Path(os.path.expanduser("~")) / ".emrg" + + +def _no_window() -> dict: + """subprocess kwargs that suppress console windows on Windows (#592).""" + if is_win(): + return {"creationflags": getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)} + return {} + + +# ── Process matching ──────────────────────────────────────────── + +# Windows python interpreter image names (TUI `python.exe` / daemon +# `pythonw.exe`, plus versioned launchers `python3.exe` / `python3.13.exe` / +# `pythonw3.13.exe` / `python3.13w.exe` — bin/emrgd.cmd's fallback chain ends +# at `python-dist\python3.13.exe`, #576). Loose on purpose: the `-m emrg` +# command-line filter is the strong discriminator; the name only pre-filters +# the process list (degraded installs where the pid file is missing/stale are +# exactly the DeleteFile-code-5 scenario #826 targets — a versioned launcher +# must not slip past the scan and keep locking install\ files). +_WIN_PY_NAME_RE = r"^python.*\.exe$" + + +def _is_gui_cmdline(cmd: str) -> bool: + """True when a command line belongs to the GUI app (EMRG.app / AppImage). + + Used by ``--skip-gui`` mode to exclude the (intentionally alive) GUI + caller from the residual verify, and by :func:`match_cmdline` for the + plain stop scan. + """ + return "EMRG.app" in cmd or bool(_APPIMAGE_RE.search(cmd)) + + +def match_cmdline(cmd: str) -> bool: + """True if a command line belongs to an emrg process. + + Matches ``-m emrg`` / ``-m emrg.server`` (TUI + daemon), ``EMRG.app`` + (macOS GUI) and ``EMRG-*.AppImage`` (Linux AppImage). Does NOT match + lookalikes such as ``-m emrg.serverless`` or ``-m emrgx``. + """ + if _is_gui_cmdline(cmd): + return True + return bool(_EMRG_CLIENT_RE.search(cmd)) + + +def _iter_ps_lines(ps_output: str) -> list[tuple[int, str]]: + """Parse ``ps -axww -o pid=,command=`` output into ``(pid, cmdline)`` + pairs (used by the ``--skip-gui`` verify filter to identify GUI pids).""" + pairs: list[tuple[int, str]] = [] + for line in ps_output.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split(None, 1) + if len(parts) != 2: + continue + try: + pairs.append((int(parts[0]), parts[1])) + except ValueError: + continue + return pairs + + +def scan_pids(ps_output: str, own_pid: int) -> list[int]: + """Parse ``ps -axww -o pid=,command=`` output → pids of emrg processes. + + ``own_pid`` is excluded so a running ``emrg stop`` never kills itself. + """ + pids: list[int] = [] + for line in ps_output.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split(None, 1) + if len(parts) != 2: + continue + try: + pid = int(parts[0]) + except ValueError: + continue + if pid == own_pid: + continue + if match_cmdline(parts[1]): + pids.append(pid) + return pids + + +def _pid_alive(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except (ProcessLookupError, PermissionError): + return False + except OSError: + return False + + +def _kill_pid_windows(pid: int) -> None: + """Force-kill a pid on Windows (taskkill /F — TerminateProcess).""" + subprocess.run( + ["taskkill", "/F", "/PID", str(pid)], + capture_output=True, + ** _no_window(), + ) + + +def _kill_pid_posix(pid: int, grace: float = 3.0) -> None: + """SIGTERM → short grace → SIGKILL on POSIX.""" + try: + os.kill(pid, signal.SIGTERM) + except (ProcessLookupError, PermissionError): + return + deadline = time.monotonic() + grace + while time.monotonic() < deadline: + if not _pid_alive(pid): + return + time.sleep(0.15) + try: + os.kill(pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + + +def _scan_windows_python_emrg(own_pid: int, server_only: bool = False) -> list[int]: + """Scan python.exe/pythonw.exe whose command line matches ``-m emrg`` / + ``-m emrg.server`` (TUI + daemon), excluding ``own_pid``. + + ``server_only=True`` restricts the match to ``-m emrg.server`` (the daemon + only — rant 2026-08-21T16:45:06: stop_daemon's kill fallback targets the + daemon, TUI clients are stopped earlier in the chain). + + Command line is the only reliable identity on Windows (rant + 2026-08-17T17:03:38): a live daemon would otherwise survive the pid-file + path and keep locking the ``websockets`` C extensions under ``install\\`` — + the installer then fails with ``DeleteFile failed; code 5`` while verify() + reports clean. + """ + if not is_win(): + return [] + cmd_re = r"-m emrg\.server" if server_only else r"-m emrg" + # Literal PowerShell script-block braces must be escaped as {{ }} — same + # contract as stop_tui() (str.format() would raise on unescaped braces). + ps_cmd = ( + "Get-CimInstance Win32_Process | " + "Where-Object {{ $_.ProcessId -ne {own} -and " + "$_.Name -match '{name_re}' -and " + "$_.CommandLine -match '{cmd_re}' }} | " + "ForEach-Object {{ Write-Output $_.ProcessId }}" + ).format(own=own_pid, name_re=_WIN_PY_NAME_RE, cmd_re=cmd_re) + try: + out = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, text=True, timeout=10, **_no_window(), + ).stdout + except (OSError, subprocess.SubprocessError, TimeoutError): + return [] + return [int(p) for p in out.split() if p.strip().isdigit()] + + +def _daemon_scan_pids(own_pid: int) -> list[int]: + """Cmdline-scan pids of the daemon only (``-m emrg.server``) — the kill + fallback for stop_daemon (rant 2026-08-21T16:45:06). POSIX ps-scan filtered + to the daemon regex; Windows reuses the CIM scan with server_only.""" + if is_win(): + return _scan_windows_python_emrg(own_pid, server_only=True) + out = _ps_output() + if out is None: + return [] + pids: list[int] = [] + for line in out.splitlines(): + line = line.strip() + if not line: + continue + parts = line.split(None, 1) + if len(parts) != 2: + continue + try: + pid = int(parts[0]) + except ValueError: + continue + if pid == own_pid: + continue + if _EMRG_SERVER_RE.search(parts[1]): + pids.append(pid) + return pids + + +def _port_is_open(port: int, timeout: float = 0.3) -> bool: + """Fixed-port TCP probe — port open = a live daemon owns it (ground truth, + rant 2026-08-19T08:05:21). Mirrors emrg.connect.is_server_running_sync + semantics without importing the emrg package (standalone in installer).""" + try: + sock = socket.create_connection(("127.0.0.1", port), timeout=timeout) + sock.close() + return True + except OSError: + return False + + +# ── Minimal WebSocket client (RFC 6455, stdlib only) ──────────── + +def _ws_recv_exact(sock: socket.socket, n: int) -> bytes: + buf = b"" + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + if not chunk: + raise OSError("ws: connection closed") + buf += chunk + return buf + + +def _ws_send_text(sock: socket.socket, text: str) -> None: + payload = text.encode("utf-8") + mask = secrets.token_bytes(4) + header = bytearray([0x81]) # FIN + text frame + ln = len(payload) + if ln < 126: + header.append(0x80 | ln) + elif ln < 65536: + header.append(0x80 | 126) + header.extend(ln.to_bytes(2, "big")) + else: + header.append(0x80 | 127) + header.extend(ln.to_bytes(8, "big")) + header.extend(mask) + masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + sock.sendall(bytes(header) + masked) + + +def _ws_recv_text(sock: socket.socket, timeout: float) -> str | None: + sock.settimeout(timeout) + b1, b2 = _ws_recv_exact(sock, 2) + opcode = b1 & 0x0F + ln = b2 & 0x7F + if ln == 126: + ln = int.from_bytes(_ws_recv_exact(sock, 2), "big") + elif ln == 127: + ln = int.from_bytes(_ws_recv_exact(sock, 8), "big") + mask = _ws_recv_exact(sock, 4) if (b2 & 0x80) else None + payload = _ws_recv_exact(sock, ln) + if mask: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + if opcode != 0x1: # not a text frame + return None + return payload.decode("utf-8", "replace") + + +def ws_graceful_shutdown(port: int, token: str, timeout: float = 3.0) -> bool: + """Send the daemon a graceful ``shutdown`` over a minimal WS connection. + + Mirrors emrg.connect.connect_to_server + _send_shutdown but with only the + standard library (this module must run standalone inside the installer). + Returns True when the daemon acked ``shutdown_ack``. + """ + key = base64.b64encode(secrets.token_bytes(16)).decode() + try: + sock = socket.create_connection(("127.0.0.1", port), timeout=timeout) + except OSError: + return False + try: + sock.sendall( + ( + f"GET / HTTP/1.1\r\n" + f"Host: 127.0.0.1:{port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ).encode("ascii") + ) + resp = b"" + while b"\r\n\r\n" not in resp: + chunk = sock.recv(4096) + if not chunk: + return False + resp += chunk + if not resp.startswith(b"HTTP/1.1 101"): + return False + # auth handshake (first frame) → auth_ok + _ws_send_text(sock, json.dumps({"type": "auth", "token": token})) + ack = _ws_recv_text(sock, timeout) + if not ack: + return False + try: + if json.loads(ack).get("type") != "auth_ok": + return False + except json.JSONDecodeError: + return False + # shutdown → shutdown_ack + _ws_send_text(sock, json.dumps({"type": "shutdown", "source": "stop_all"})) + ack = _ws_recv_text(sock, timeout) + if not ack: + return False + try: + return json.loads(ack).get("type") == "shutdown_ack" + except json.JSONDecodeError: + return False + except OSError: + return False + finally: + try: + sock.close() + except OSError: + pass + + +# ── Individual stop steps ─────────────────────────────────────── + +def stop_daemon() -> None: + """Stop the daemon: ws shutdown → fixed-port wait → cmdline-scan kill + fallback (``-m emrg.server``) → token cleanup (rant 2026-08-21T16:45:06: + the fixed port is the ground truth; the emrgd.pid file is gone, so liveness + is judged by the port and the kill target is found by command line). + + Also removes ``~/.emrg/emrgd.token`` once the daemon port is confirmed + closed (the daemon itself removes it on graceful shutdown; a force-killed + daemon cannot, so we clean it up — the next daemon start re-asserts it). + """ + token_path = config_dir() / "emrgd.token" + # Fixed-port shutdown (rant 2026-08-19T08:05:21): the daemon always + # listens on _EMRGD_PORT; the token file only supplies the auth token + # (single line, rant 2026-08-20T14:32:52). If the file is missing/stale, + # fall through to the cmdline-scan path. + try: + token = token_path.read_text(encoding="utf-8").strip() + except (OSError, ValueError): + token = "" + if token and ws_graceful_shutdown(_EMRGD_PORT, token): + # wait for the daemon to exit — the port closing is the ground truth + # (~10s grace: old stop-emrg.cmd v2 polled up to 10s; a busy daemon + # mid-tool-loop needs the full window) + for _ in range(60): + if not _port_is_open(_EMRGD_PORT): + break + time.sleep(0.15) + + # Fallback: the daemon survived the ws path (or no ws token) → kill by + # command-line identity (``-m emrg.server``), the only reliable marker on + # Windows (rant 2026-08-17T17:03:38). TUI clients were already stopped + # earlier in the chain, so the daemon-only scan is safe. + for pid in _daemon_scan_pids(os.getpid()): + if is_win(): + _kill_pid_windows(pid) + else: + _kill_pid_posix(pid) + # poll up to 10s for it to disappear (matches old v2 grace window) + for _ in range(60): + if not _port_is_open(_EMRGD_PORT): + break + time.sleep(0.15) + + # Token file cleanup: the daemon removes it on graceful shutdown; a + # force-killed daemon cannot, so remove it once the port is confirmed + # closed (the next daemon start re-asserts it). + if not _port_is_open(_EMRGD_PORT): + try: + token_path.unlink() + except OSError: + pass + + +def _ps_output() -> str | None: + try: + return subprocess.run( + ["ps", "-axww", "-o", "pid=,command="], + capture_output=True, text=True, timeout=10, + **_no_window(), + ).stdout + except (OSError, subprocess.SubprocessError, TimeoutError): + return None + + +def _stop_scan_pids(own_pid: int) -> list[int]: + out = _ps_output() + if out is None: + return [] + return scan_pids(out, own_pid) + + +def stop_gui() -> None: + """Stop the GUI app: Windows EMRG.exe (graceful then unconditional /F); + POSIX ps-scan for EMRG.app / EMRG-*.AppImage (SIGTERM → SIGKILL).""" + kw = _no_window() + if is_win(): + # graceful first, then unconditional /F (host 2026-08-10T01:27:07Z: + # long-lived GUI sessions ignore WM_CLOSE — /F must not be gated) + subprocess.run(["taskkill", "/IM", "EMRG.exe"], capture_output=True, **kw) + time.sleep(0.5) + subprocess.run(["taskkill", "/F", "/IM", "EMRG.exe"], capture_output=True, **kw) + return + pids = [p for p in _stop_scan_pids(os.getpid()) + if p != os.getpid()] + for pid in pids: + _kill_pid_posix(pid) + + +def stop_tui() -> None: + """Stop TUI clients: Windows CIM filter (python.exe|pythonw.exe running + ``-m emrg`` but NOT ``emrg.server``); POSIX ps-scan. + + On Windows the invoking PID (a user-run ``emrg stop`` = ``python.exe + -m emrg stop``) matches the ``-m emrg`` filter and would kill the CLI + itself before ``stop_bundled_git`` + ``verify`` run — exclude it + (same contract as the POSIX branch's ``own_pid`` exclusion).""" + if is_win(): + own = os.getpid() + # Literal PowerShell script-block braces must be escaped as {{ }} — + # otherwise str.format() treats them as replacement fields and raises + # ValueError: unexpected '{' in field name at runtime on Windows. + ps_cmd = ( + "Get-CimInstance Win32_Process | " + "Where-Object {{ $_.ProcessId -ne {own} -and " + "$_.Name -match '{name_re}' -and " + "$_.CommandLine -match '-m emrg' -and " + "$_.CommandLine -notmatch 'emrg\\.server' }} | " + "ForEach-Object {{ Stop-Process -Id $_.ProcessId -Force }}" + ).format(own=own, name_re=_WIN_PY_NAME_RE) + subprocess.run( + ["powershell", "-NoProfile", "-Command", ps_cmd], + capture_output=True, **_no_window(), + ) + return + pids = [p for p in _stop_scan_pids(os.getpid()) + if p != os.getpid()] + for pid in pids: + _kill_pid_posix(pid) + + +def stop_bundled_git() -> None: + """Windows only: kill processes under ``install\\git\\`` (git/ssh/plink/ + bash first, then fallback prefix full-kill) — port of stop-emrg.cmd step 4. + Never touches system Git (outside the prefix).""" + if not is_win(): + return + ps_cmd = ( + "$ErrorActionPreference='SilentlyContinue'; " + "$prefix=\"$env:USERPROFILE\\.emrg\\install\\git\\*\"; " + "Get-CimInstance Win32_Process | " + "Where-Object { $_.ExecutablePath -like $prefix -and " + "$_.Name -in @('git.exe','ssh.exe','plink.exe','bash.exe') } | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force " + "-ErrorAction SilentlyContinue }; " + "Start-Sleep -Milliseconds 300; " + "Get-CimInstance Win32_Process | " + "Where-Object { $_.ExecutablePath -like $prefix } | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force " + "-ErrorAction SilentlyContinue }; " + "Start-Sleep -Milliseconds 300" + ) + subprocess.run( + ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-Command", ps_cmd], + capture_output=True, **_no_window(), + ) + + +# ── Restart Manager lock-owner scan (rant 2026-08-17T17:55:42) ───── +# +# Generic DeleteFile-code-5 fix. The 0.2.43 install failure was traced (via a +# verified find_lock_owner.ps1) to an EXTERNAL process — the browser-harness +# daemon, a standalone uv CPython under AppData\Roaming\uv\tools — locking +# files under install\. emrgd.pid/emrgd.port were empty and no `-m emrg` +# process existed, so the EMRG cmdline scan (17:03:38) could never see it. +# Restart Manager (rstrtmgr.dll) reports the ACTUAL owners of locked files, +# which covers both EMRG and foreign processes. The template is fully static +# (no str.format on the Python side) so the literal PowerShell/C# braces need +# no ``{{ }}`` escaping — the ``& { ... }`` wrapper only receives the kill +# flag as a positional argument. + +_LOCK_OWNER_PS = r""" +$ErrorActionPreference = 'SilentlyContinue' +$kill = ($args[0] -eq $true) +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Collections.Generic; +public static class RM { + [DllImport("rstrtmgr.dll", CharSet=CharSet.Unicode)] static extern int RmStartSession(out uint h, int f, string k); + [DllImport("rstrtmgr.dll", CharSet=CharSet.Unicode)] static extern int RmRegisterResources(uint h, uint n, string[] r, uint a, IntPtr p, uint b, IntPtr q); + [DllImport("rstrtmgr.dll")] static extern int RmGetList(uint h, out uint n, ref uint m, [In,Out] RM_PROCESS_INFO[] i, ref uint r); + [DllImport("rstrtmgr.dll")] static extern int RmEndSession(uint h); + [StructLayout(LayoutKind.Sequential)] struct RM_UNIQUE_PROCESS { public int dwProcessId; public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime; } + [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] struct RM_PROCESS_INFO { public RM_UNIQUE_PROCESS Process; [MarshalAs(UnmanagedType.ByValTStr, SizeConst=256)] public string strAppName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst=64)] public string strServiceShortName; public int ApplicationType; public uint AppStatus; public uint TSSessionId; public bool bRestartable; } + public static int LastRegFail = 0; + public static int[] Who(string[] files) { + uint h; + if (RmStartSession(out h, 0, Guid.NewGuid().ToString()) != 0) return new int[0]; + try { + const int BATCH = 500; + int regFail = 0; + for (int i = 0; i < files.Length; i += BATCH) { + int cnt = Math.Min(BATCH, files.Length - i); + string[] batch = new string[cnt]; + Array.Copy(files, i, batch, 0, cnt); + // Check the return value — a failed batch must be visible, never + // silently ignored (rant 2026-08-17T21:04:32). + if (RmRegisterResources(h, (uint)cnt, batch, 0, IntPtr.Zero, 0, IntPtr.Zero) != 0) regFail++; + } + LastRegFail = regFail; + uint n = 0, reason = 0; + int rc = 0; + const int MAX_ATTEMPTS = 3; + // RmGetList's pdwProcCount (m) is IN/OUT: input = buffer capacity, + // output = number of entries written. The old code passed m=0 forever, + // so every call returned ERROR_MORE_DATA(234) -> infinite loop -> zero + // owners reported -> installer still hit DeleteFile code 5. Fix: + // preallocate 50 entries (m=50), on 234 resize to n and retry, hard + // capped at MAX_ATTEMPTS so an abnormal API can NEVER dead-loop + // (rant 2026-08-17T21:04:32). + uint m = 50; + RM_PROCESS_INFO[] infos = new RM_PROCESS_INFO[50]; + for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + rc = RmGetList(h, out n, ref m, infos, ref reason); + if (rc != 234) break; + m = n; + infos = new RM_PROCESS_INFO[n]; + } + List res = new List(); + if (rc == 0) { + uint count = Math.Min(n, m); + if (count > (uint)infos.Length) count = (uint)infos.Length; + for (uint i = 0; i < count; i++) res.Add(infos[i].Process.dwProcessId); + } + return res.ToArray(); + } finally { + RmEndSession(h); + } + } +} +'@ +$root = Join-Path $env:USERPROFILE '.emrg\install' +if (-not (Test-Path $root)) { exit 0 } +$files = @(Get-ChildItem $root -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName }) +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$owners = New-Object 'System.Collections.Generic.HashSet[int]' +if ($files.Length -gt 0) { + foreach ($p in [RM]::Who([string[]]$files)) { [void]$owners.Add($p) } +} +$sw.Stop() +# Exclude self + the full ancestor chain: stop_all runs from +# install\python-dist\python.exe, which itself loads install\python313.dll +# etc. and would be reported as an owner; the chain also contains the Inno +# installer setup.exe — NEVER kill it (rant 2026-08-17T17:55:42 safety). +$exclude = New-Object 'System.Collections.Generic.HashSet[int]' +$cur = [int]$PID +for ($g = 0; $g -lt 64 -and $cur -gt 0; $g++) { + [void]$exclude.Add($cur) + $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$cur" -ErrorAction SilentlyContinue + if (-not $proc) { break } + $cur = [int]$proc.ParentProcessId +} +$targets = @($owners | Where-Object { -not $exclude.Contains($_) }) +$killedHint = $false +# Detail per OWNER (not just targets): each line carries a 4th column tagging +# whether the owner was excluded by the ancestor chain (self + Inno setup.exe) +# or is a real target — so the operator can see WHO the owners were and WHY +# nothing was killed (rant 2026-08-18T09:40:40: 3 owners found but all +# excluded → targets=0 with zero output = detector looked blind). +foreach ($pid in $owners) { + $p = Get-CimInstance Win32_Process -Filter "ProcessId=$pid" -ErrorAction SilentlyContinue + $name = '' + $cmd = '' + if ($p) { + $name = [string]$p.Name + if ($p.CommandLine) { $cmd = [string]$p.CommandLine } + } + if ($cmd.Length -gt 150) { $cmd = $cmd.Substring(0, 150) } + if ($kill -and -not $exclude.Contains($pid)) { + # taskkill /F /PID = TerminateProcess, returns immediately (rant + # 2026-08-18T16:09:45): Stop-Process hangs on refusing/waiting targets + # → the kill-mode RM scan blew its 60s timeout. Matches _kill_pid_windows. + & taskkill /F /PID $pid 2>$null | Out-Null + Write-Output ("killed file-lock owner: PID {0} {1} | {2}" -f $pid, $name, $cmd) + if ($cmd -match 'browser[-_]?harness') { $killedHint = $true } + } else { + $tag = if ($exclude.Contains($pid)) { 'excluded' } else { 'target' } + Write-Output ("{0}`t{1}`t{2}`t{3}" -f $pid, $name, $cmd, $tag) + } +} +# The excluded ancestor chain (incl. self PID) — answers "who was skipped?". +Write-Output ("excluded-chain`t{0}" -f ($exclude -join ',')) +if ($kill -and $owners.Count -gt 0 -and $targets.Count -eq 0) { + Write-Output ("WARNING all {0} owner(s) excluded: {1}" -f $owners.Count, ($owners -join ',')) +} +if ($kill -and $killedHint) { + Write-Output 'hint: browser-harness daemon stopped - restart it after the installer completes' +} +# Structured diagnostics so the Python side can log files/owners/elapsed and +# RmRegisterResources failures — no more silent idle scans (rant 2026-08-17T21:04:32). +Write-Output ("rm-diag`t{0}`t{1}`t{2}`t{3}" -f $files.Length, $owners.Count, $sw.ElapsedMilliseconds, [RM]::LastRegFail) +""" + + +def _lock_owner_ps(kill: bool) -> str: + """Run the Restart Manager lock-owner scan under ``install\\`` (Windows only). + + ``kill=True`` stops every non-EMRG/ancestor owner (stop_lock_owners); + ``kill=False`` emits ``PIDnamecmdline`` lines for the verify + step. Returns "" on POSIX or when PowerShell/RM is unavailable (best-effort, + like every other stop step). The script is fully static — no str.format() — + so the literal PowerShell/C# braces need no ``{{ }}`` escaping. + """ + if not is_win(): + return "" + ps_cmd = "& { " + _LOCK_OWNER_PS + " } " + ("$true" if kill else "$false") + try: + return subprocess.run( + ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-Command", ps_cmd], + capture_output=True, text=True, timeout=60, **_no_window(), + ).stdout + except (OSError, subprocess.SubprocessError, TimeoutError): + return "" + + +def _lock_owner_diag(stdout: str) -> dict | None: + """Parse the ``rm-diag`` line emitted by ``_LOCK_OWNER_PS``. + + Shape: ``rm-diagfilesownerselapsed_msreg_fail``. + Returns a dict or None when absent/unparseable (e.g. RM unavailable). + """ + for line in stdout.splitlines(): + parts = line.split("\t") + if parts and parts[0] == "rm-diag" and len(parts) >= 5: + try: + return { + "files": int(parts[1]), + "owners": int(parts[2]), + "elapsed_ms": int(parts[3]), + "reg_fail": int(parts[4]), + } + except ValueError: + return None + return None + + +# Last Restart-Manager scan found NO external (non-self) lock owner — the +# evidence for the self-lock final guard (rant 2026-08-18T16:09:45): when +# lock-probe reports locked files but every RM owner is the stop_all runtime +# itself / its ancestor chain (or RM found nobody), the installer cannot win +# and the operator should re-run (a fresh installer process holds no locks). +_rm_no_external_owner: bool = False + + +def _print_rm_diag(stdout: str) -> None: + """Log the Restart Manager scan summary (files scanned / owners found / + elapsed / registration failures) so a scan can never be silently idle + (rant 2026-08-17T21:04:32). Also records whether NO external (non-self) + owner was found — the self-lock evidence for the final guard + (rant 2026-08-18T16:09:45: stop_all runs from install\\python-dist so its + own interpreter is the only lock holder → installer would still fail).""" + global _rm_no_external_owner + _rm_no_external_owner = False # one scan per call — no stale evidence + d = _lock_owner_diag(stdout) + if d: + if d["owners"] == 0 or "owner(s) excluded" in stdout: + _rm_no_external_owner = True + print( + f"emrg stop: rm-scan files={d['files']} owners={d['owners']} " + f"elapsed={d['elapsed_ms']}ms reg_fail={d['reg_fail']}" + ) + if d["reg_fail"]: + print( + f"emrg stop: WARNING {d['reg_fail']} resource-batch registration(s) " + "failed - some file-lock owners may be missed" + ) + + +# ── Module-holder enumeration (rant 2026-08-18T16:24:01) ───────── +# +# Diagnostic-script proof: the v0.2.48 DeleteFile code 5 lock holder was +# PID 9280 — a browser-harness CHILD process that loaded install\lib's +# websockets speedups.pyd (inherited PYTHONPATH). Restart Manager never +# reported it (the 2 owners it found were excluded ancestors) and a +# CreateFileW probe reports OK even when DeleteFile fails — LoadLibrary +# image-section locks are only visible via Process.Modules enumeration. +# This scan names the real holders; taskkill /F /T kills their tree. + +_MODULE_HOLDER_PS = r""" +$ErrorActionPreference = 'SilentlyContinue' +$root = Join-Path $env:USERPROFILE '.emrg\install' +if (-not (Test-Path $root)) { exit 0 } +$cut = $root.Length + 1 +# Self + full ancestor chain (stop_all runs from install\python-dist\python.exe +# → itself loads install\python313.dll; ancestors include Inno setup.exe — +# NEVER kill them, same safety as the RM scan). +$exclude = New-Object 'System.Collections.Generic.HashSet[int]' +$cur = [int]$PID +for ($g = 0; $g -lt 64 -and $cur -gt 0; $g++) { + [void]$exclude.Add($cur) + $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$cur" -ErrorAction SilentlyContinue + if (-not $proc) { break } + $cur = [int]$proc.ParentProcessId +} +Get-Process -ErrorAction SilentlyContinue | ForEach-Object { + $p = $_ + try { + $mods = @($p.Modules | Where-Object { $_.FileName -like "$root\*" }) + } catch { $mods = @() } + if ($mods.Count -eq 0) { return } + $files = @($mods | ForEach-Object { $_.FileName.Substring($cut) }) + $parent = '' + $pp = Get-CimInstance Win32_Process -Filter "ProcessId=$($p.Id)" -ErrorAction SilentlyContinue + if ($pp) { $parent = [string]$pp.ParentProcessId } + $tag = if ($exclude.Contains([int]$p.Id)) { 'excluded' } else { 'target' } + Write-Output ("holder`t{0}`t{1}`t{2}`t{3}`t{4}`t{5}" -f $p.Id, $p.ProcessName, $p.Path, $parent, ($files -join '|'), $tag) +} +""" + + +def _module_holder_ps() -> str: + """Run the module-holder enumeration (Windows only). Returns "" on POSIX + or when PowerShell is unavailable (best-effort like every other step).""" + if not is_win(): + return "" + ps_cmd = "& { " + _MODULE_HOLDER_PS + " }" + try: + return subprocess.run( + ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-Command", ps_cmd], + capture_output=True, text=True, timeout=60, **_no_window(), + ).stdout + except (OSError, subprocess.SubprocessError, TimeoutError): + return "" + + +def _parse_module_holders(stdout: str) -> list[tuple[int, str, str, int, list[str], str]]: + """Parse ``holderpidnameexeparentfilestag`` + lines → ``[(pid, name, exe, parent_pid, [files...], tag), ...]``.""" + holders: list[tuple[int, str, str, int, list[str], str]] = [] + for line in stdout.splitlines(): + parts = line.split("\t") + if not parts or parts[0] != "holder" or len(parts) < 7: + continue + try: + pid = int(parts[1]) + except ValueError: + continue + files = [f for f in parts[5].split("|") if f] + try: + parent = int(parts[4]) if parts[4] else 0 + except ValueError: + parent = 0 + holders.append((pid, parts[2], parts[3], parent, files, parts[6])) + return holders + + +def find_install_module_holders() -> list[tuple[int, str, str, int, list[str], str]]: + """Windows: enumerate processes that loaded modules from ``install\\`` — + the ONLY detector that can see DLL/.pyd image-section locks (rant + 2026-08-18T16:24:01). Returns [] on POSIX / when unavailable.""" + if not is_win(): + return [] + return _parse_module_holders(_module_holder_ps()) + + +def _kill_tree_windows(pid: int) -> None: + """Kill a process and its whole tree on Windows (taskkill /F /T — + TerminateProcess, returns immediately; /T also kills children so a + holder that is a child of a daemon releases its modules when the tree + dies, rant 2026-08-18T16:24:01).""" + try: + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, timeout=10, **_no_window(), + ) + except (OSError, subprocess.SubprocessError, TimeoutError): + pass + + +def _escalate_kill_windows(pid: int) -> str: + """Escalation kill for a lock-holder that survived the stop phase (rant + 2026-08-18T21:24:48 #2c): ``taskkill /F /T`` first → ancestor chain + (parent tree) kill → ``Stop-Process -Force`` fallback. Returns a one-line + outcome for the per-file disposition log.""" + log: list[str] = [] + try: + r = subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + capture_output=True, text=True, timeout=10, **_no_window(), + ) + log.append(f"taskkill /F /T rc={getattr(r, 'returncode', '?')}") + if getattr(r, "returncode", 1) == 0 and not _pid_alive(pid): + return "; ".join(log) + " => killed" + except (OSError, subprocess.SubprocessError, TimeoutError) as e: + log.append(f"taskkill err={type(e).__name__}") + # Ancestor chain (parent tree): walk ProcessId → ParentProcessId via CIM + # and kill each ancestor with its own tree (the holder may be a child + # whose parent keeps it / the lock alive). + try: + ps = ( + "powershell -NoProfile -Command " + '"$p=Get-CimInstance Win32_Process -Filter \\"ProcessId=' + str(pid) + '\\"; ' + '$chain=@(); while($p -and $p.ParentProcessId -and $p.ParentProcessId -ne 0){' + '$par=Get-CimInstance Win32_Process -Filter ("ProcessId="+$p.ParentProcessId); ' + 'if(-not $par){break}; $chain+=$par; $p=$par}; ' + '$chain | ForEach-Object { Write-Output ("{0} {1}" -f $_.ProcessId,$_.Name) }"' + ) + out = subprocess.run(ps, capture_output=True, text=True, timeout=15, **_no_window()).stdout + for line in out.splitlines(): + parts = line.split() + if not parts or not parts[0].strip().isdigit(): + continue + apid, aname = int(parts[0]), " ".join(parts[1:]) + r = subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(apid)], + capture_output=True, text=True, timeout=10, **_no_window(), + ) + log.append(f"parent {apid} ({aname}) rc={getattr(r, 'returncode', '?')}") + except (OSError, subprocess.SubprocessError, TimeoutError) as e: + log.append(f"parent-tree err={type(e).__name__}") + if not _pid_alive(pid): + return "; ".join(log) + " => killed" + # Final fallback: Stop-Process -Force (CIM/WMIC-class stop). + try: + r = subprocess.run( + ["powershell", "-NoProfile", "-Command", + f"Stop-Process -Id {pid} -Force -ErrorAction SilentlyContinue"], + capture_output=True, text=True, timeout=10, **_no_window(), + ) + log.append(f"Stop-Process rc={getattr(r, 'returncode', '?')}") + if not _pid_alive(pid): + return "; ".join(log) + " => killed" + except (OSError, subprocess.SubprocessError, TimeoutError) as e: + log.append(f"Stop-Process err={type(e).__name__}") + return "; ".join(log) + " => SURVIVED" + + +def _escalate_locked_files( + locked: list[str], + mh_holders: list[tuple[int, str, str, int, list[str], str]], + root: str, +) -> None: + """Final escalation + per-file disposition log (rant 2026-08-18T21:24:48 + #2c/#4): for each still-locked file, attribute holders from the three data + sources (module-holder enumeration / Restart Manager / self), kill + external holders with escalation, and log the full chain — file path / + holder PIDs / attribution source / action / result. Never raises; any + survivors are logged as advisory and the install continues (the installer's + own overwrite is the final arbiter).""" + if not is_win() or not locked or not root: + return + try: + root_n = os.path.normpath(root).replace("\\", "/").rstrip("/") + holders_by_rel: dict[str, list[tuple[str, int, str]]] = {} + for pid, name, _exe, _parent, files, tag in mh_holders: + for f in files: + rel = os.path.normpath(f).replace("\\", "/") + if root_n and rel.startswith(root_n + "/"): + rel = rel[len(root_n) + 1:] + src = "self/excluded" if tag == "excluded" else "module-holder" + holders_by_rel.setdefault(rel, []).append((src, pid, name or "")) + rm_owners = _windows_lock_owners(kill=False) + print("emrg stop: escalation — locks surviving the stop phase:") + for p in locked: + rel = os.path.normpath(p).replace("\\", "/") + if root_n and rel.startswith(root_n + "/"): + rel = rel[len(root_n) + 1:] + holders = list(holders_by_rel.get(rel, [])) + for o_pid, o_name, _cmd in rm_owners: # RM reports actual owners + if not any(h[1] == o_pid for h in holders): + holders.append(("rm", o_pid, o_name)) + chain = ", ".join(f"{src}:{pid}:{name}" for src, pid, name in holders) or "none found" + actions: list[str] = [] + for src, pid, name in holders: + if src == "self/excluded": + actions.append(f"self pid {pid} — released on stop_all exit") + continue + actions.append(f"pid {pid} ({name}): " + _escalate_kill_windows(pid)) + print( + f"emrg stop: locked {rel} | holders [{chain}] | " + + (" | ".join(actions) if actions else "no external holder") + ) + except Exception as e: # best-effort — never break the stop flow + print(f"emrg stop: ERROR escalation: {e} ({type(e).__name__})") + + +# Any EXTERNAL (non-self/ancestor) module holder was found and killed — +# evidence for the self-lock final guard: when lock-probe still reports +# locked files but no external holder exists, the lock is stop_all's own +# runtime (rant 2026-08-18T16:09:45) and the installer cannot win. +_module_holder_external_found: bool = False + + +def _windows_lock_owners(kill: bool, stdout: str | None = None) -> list[tuple[int, str, str]]: + """Parse ``_lock_owner_ps`` output → ``[(pid, name, cmdline_150), ...]``. + + ``stdout`` may be supplied by the caller (avoids a second PowerShell + invocation when the diag line is needed too); None → run the scan. + + Since v0.2.45+ the owner lines carry a 4th ``excluded|target`` column + (rant 2026-08-18T09:40:40) — ancestors (self + Inno setup.exe) are + tagged ``excluded`` and are NOT returned here (verify must never list + the running stop process itself as a residual); the full detail with + tags stays visible in the raw log via stop_lock_owners. + """ + if stdout is None: + stdout = _lock_owner_ps(kill) + owners: list[tuple[int, str, str]] = [] + for line in stdout.splitlines(): + parts = line.split("\t") + if not parts or not parts[0].strip().isdigit(): + continue + tag = parts[3] if len(parts) > 3 else "target" + if tag == "excluded": + continue + pid = int(parts[0]) + name = parts[1] if len(parts) > 1 else "" + cmd = parts[2] if len(parts) > 2 else "" + owners.append((pid, name, cmd)) + return owners + + +# ── Independent lock probe (rant 2026-08-17T21:06:05) ───────────── +# The RM scan and verify previously shared the same _windows_lock_owners +# function — when the detector broke (RmGetList dead-loop → empty result), +# verify went blind too and the installer overwrote locked files. This probe +# simulates the installer's overwrite directly (exclusive open = DeleteFile +# would fail) and is INDEPENDENT of Restart Manager, so a broken RM can never +# silently pass verify. + +def _iter_install_files(root: str) -> list[str]: + """All files under ``root`` (``~/.emrg/install``) — deterministic order.""" + files: list[str] = [] + for dirpath, _dirnames, filenames in os.walk(root): + for fn in filenames: + files.append(os.path.join(dirpath, fn)) + return files + + +def _win_exclusive_open(path: str) -> None: + """Open an existing file with DELETE access + FILE_SHARE_NONE — the + exact sharing semantic the Inno installer's DeleteFile needs. Raises + OSError when another process holds the file (DeleteFile code 5 would + occur). + + DeleteFile semantics (rant 2026-08-18T16:09:45): a DLL loaded via + LoadLibrary holds the file with FILE_SHARE_READ only — GENERIC_READ + + FILE_SHARE_NONE probing succeeds (read sharing is granted) → false + "0 locked" while the installer's DeleteFile still fails (the image + section handle does not share FILE_SHARE_DELETE). Requesting DELETE + access fails with ERROR_SHARING_VIOLATION on exactly the files + DeleteFile would fail on. FILE_SHARE_NONE is kept as a complement — + either condition failing means locked. + + ⚠️ NO delete-on-close disposition (rant 2026-08-19T13:08:41 — data-loss + bug): the v0.2.4x probe opened with the delete-on-close flag and cleared + it afterwards via the file-disposition-info API — but that clear only + works on Windows 10 1903+; on older systems (or any failed/best-effort + clear) the disposition stays set and closing the handle DELETES the + probed file. The disposition flag adds nothing to the access check + (DELETE access + share-none alone reproduces DeleteFile's sharing + semantics), so the probe now opens with plain FILE_ATTRIBUTE_NORMAL and + never sets a delete disposition — it can never delete anything, only + ask "would DeleteFile succeed?".""" + import ctypes + + GENERIC_DELETE = 0x00010000 + OPEN_EXISTING = 3 + FILE_SHARE_NONE = 0 + FILE_ATTRIBUTE_NORMAL = 0x80 + kernel32 = ctypes.windll.kernel32 + # 64-bit handle truncation fix (rant 2026-08-18T09:40:40): ctypes defaults + # the restype of a foreign function to c_int — a 64-bit HANDLE gets + # truncated, INVALID_HANDLE_VALUE(-1) becomes 0xFFFFFFFF and a valid + # handle can alias a failure → probe reports "0 locked" when files ARE + # locked. Pin the full signature explicitly. + kernel32.CreateFileW.restype = ctypes.c_void_p + kernel32.CreateFileW.argtypes = [ + ctypes.c_wchar_p, ctypes.c_uint32, ctypes.c_uint32, + ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_void_p, + ] + kernel32.CloseHandle.restype = ctypes.c_int + kernel32.CloseHandle.argtypes = [ctypes.c_void_p] + + h = kernel32.CreateFileW(path, GENERIC_DELETE, FILE_SHARE_NONE, None, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None) + # With restype=c_void_p a NULL handle arrives as None (not 0) — cover both + # forms; INVALID_HANDLE_VALUE is c_void_p(-1).value (pm25coder review note, + # PR #832). A failed DELETE open = the installer's DeleteFile would fail. + if not h or h == ctypes.c_void_p(-1).value: + raise OSError(f"CreateFileW failed for {path} (file is locked)") + kernel32.CloseHandle(h) + + +def _check_locked_files(root: str, try_open=None) -> list[str]: + """Return files under ``root`` that cannot be opened exclusively. + + ``try_open`` is injectable so the traversal/collection logic is testable on + POSIX (default: Windows FileShare.None via :func:`_win_exclusive_open`). + """ + if try_open is None: + try_open = _win_exclusive_open + locked: list[str] = [] + for path in _iter_install_files(root): + try: + try_open(path) + except OSError: + locked.append(path) + return locked + + +# Last lock-probe failure (rant 2026-08-18T09:40:40): a probe exception is +# NO LONGER silently swallowed as "clean" — it is recorded here, printed as +# `lock-probe ERROR`, and surfaced as a verify residual so the installer +# aborts instead of overwriting locked files. Reset on every probe attempt. +_lock_probe_error: str | None = None + + +def _install_root() -> str: + """Windows install dir (``~/.emrg/install``) — single source of truth.""" + return os.path.join(os.path.expanduser("~"), ".emrg", "install") + + +def check_install_writable() -> list[str]: + """Windows: probe ``install\\`` for files locked against overwrite. + + Independent of Restart Manager — the installer's DeleteFile would fail on + every returned path. Returns [] when the probe is unavailable (POSIX, no + install dir) — best-effort like every other stop step. On a PROBE ERROR + (exception) it also returns [] (never raises) but records the failure in + ``_lock_probe_error`` and prints ``lock-probe ERROR`` — verify() then + surfaces it as a residual and the installer aborts (fail-closed), instead + of the old silent ``except Exception: return []`` that reported + ``0 locked`` while files were actually locked (rant 2026-08-18T09:40:40). + """ + global _lock_probe_error + _lock_probe_error = None + if not is_win(): + return [] + root = _install_root() + if not os.path.isdir(root): + return [] + files = _iter_install_files(root) + t0 = time.monotonic() + try: + locked = _check_locked_files(root) + except Exception as e: + elapsed = (time.monotonic() - t0) * 1000 + _lock_probe_error = f"{type(e).__name__}: {e}" + print( + f"emrg stop: lock-probe ERROR: {_lock_probe_error} " + f"(scanned {len(files)} files, {elapsed:.0f}ms) — FAIL CLOSED" + ) + return [] + elapsed = (time.monotonic() - t0) * 1000 + # Scanned-file count / elapsed observability (rant 2026-08-18T09:40:40): + # "0 locked" is only trustworthy when the probe actually scanned files. + print( + f"emrg stop: createfile-probe scanned {len(files)} files " + f"-> {len(locked)} locked ({elapsed:.0f}ms) [supplementary — DLL " + "module locks need the module-holder scan (rant 2026-08-18T16:24:01)]" + ) + return locked + + +def stop_lock_owners() -> None: + """Windows only: stop every process holding a lock on files under install\\. + + Two detectors, in order (rant 2026-08-18T16:24:01 — diagnostic-script + proof that neither RM nor CreateFileW probing can see DLL module locks): + + 1. **module-holder enumeration** (PRIMARY): ``Get-Process`` + + ``$_.Modules.FileName`` filtered by the install prefix names the + actual processes that loaded DLLs/.pyd from install\\ (e.g. a + browser-harness child that inherited install\\lib in PYTHONPATH → + loaded websockets speedups). Each holder is killed with + ``taskkill /F /T /PID`` (tree kill — the holder may be a child + whose parent also must go). + 2. **Restart Manager** (auxiliary, rant 2026-08-17T17:55:42): finds ANY + owner incl. non-EMRG processes. Self + ancestor chain excluded. + """ + if not is_win(): + return + # 1. module-holder enumeration — the only detector that can see DLL + # image-section locks (CreateFileW probes cannot, RM missed PID 9280). + holders = find_install_module_holders() + _module_holder_external = False + for pid, name, exe, parent, files, tag in holders: + _module_holder_external = _module_holder_external or tag == "target" + if tag == "excluded": + print( + f"emrg stop: module-holder excluded PID {pid} {name} | exe {exe} " + f"| parent {parent} | loads [{', '.join(files[:5])}]" + ) + continue + print( + f"emrg stop: killing module-holder PID {pid} {name} | exe {exe} " + f"| parent {parent} | loads [{', '.join(files[:5])}]" + ) + _kill_tree_windows(pid) + if "browser" in (name + " " + (exe or "")).lower(): + print("emrg stop: hint: browser-harness daemon stopped - restart it after the installer completes") + if holders: + print( + f"emrg stop: module-holders {len(holders)} " + f"(external targets: {int(_module_holder_external)})" + ) + # 2. Restart Manager — auxiliary (catches non-DLL file locks). + stdout = _lock_owner_ps(kill=True) + for line in stdout.splitlines(): + line = line.strip() + if line: + print(f"emrg stop: {line}") + _print_rm_diag(stdout) + global _module_holder_external_found + _module_holder_external_found = _module_holder_external + + +# ── Verify + exit code ────────────────────────────────────────── + +# Cache of the last _verify_windows_categories() result (rant +# 2026-08-18T09:40:40 #4): stop_all previously ran the FULL Windows verify +# TWICE per run — once via verify() and again via _verify_windows_summary() +# (two rm-scan PowerShell invocations, ~2s+ wasted, duplicated log lines). +# verify() always refreshes; _verify_windows_summary() reuses the freshest +# result when available. +_windows_cats_cache: list[tuple[str, list[str]]] | None = None + + +def _classify_locked_files( + locked: list[str], + mh_holders: list[tuple[int, str, str, int, list[str], str]], + root: str, +) -> tuple[list[str], list[str]]: + """Split createfile-probe locked files into self-held vs residual. + + Rant 2026-08-18T18:57:09: when stop_all itself runs from + install\\python-dist\\python.exe, the probe reports the interpreter's own + DLLs (python313.dll, select.pyd, ...) as locked — but those locks belong + to the stop_all process (module-holder tag ``excluded``) and are released + the moment stop_all exits, BEFORE the installer overwrites (installer runs + stop_all synchronously via ewWaitUntilTerminated). Counting them as + residuals aborts a perfectly fine install. + + Returns ``(self_held, residual)`` install-relative paths: + - self_held: locked file attributed ONLY to excluded (self/ancestor) holders, + or (rant 2026-08-18T21:24:48 #3) a file under ``python-dist\\`` with no + external target holder — stop_all always runs from + install\\python-dist\\python.exe, whose interpreter + lazily-loaded + stdlib modules (select/_ctypes/_hashlib/_socket, ...) hold DLL locks + that module-holder enumeration does NOT list (v0.2.49: 12 locked vs 5 + enumerated modules); such locks release when stop_all exits. + - residual: locked file with an external (``target``) holder, or one that + cannot be attributed to any known holder (conservative — could be a + plain non-DLL lock held by an external process that loaded no module). + """ + if not locked or not root: + return [], list(locked) + # Separator-agnostic: module-holder files arrive with backslashes (PS + # Substring), locked paths are native. Normalize both to forward slashes + # so the attribution works identically on Windows and in POSIX unit tests. + def _norm(p: str) -> str: + return p.replace("\\", "/") + + root_n = _norm(root) + def _to_rel(p: str) -> str: + n = _norm(p) + if n.startswith(root_n + "/"): + # Normalize again: on Windows os.path.relpath() returns + # backslash-separated results, but the locked-file lookup keys are + # forward-slash — an un-normalized key would miss (external target + # holder misclassified as self-held on Windows). + return _norm(os.path.relpath(n, root_n)) + return n # already install-relative (PS Substring / fixture form) + + tag_by_rel: dict[str, set[str]] = {} + for _pid, _name, _exe, _parent, files, tag in mh_holders: + for f in files: + # Key by the SAME rel path the locked-file lookup uses below — + # full-path keys never matched the rel lookup, so holder tags + # (esp. ``target``) were lost and every python-dist file was + # mis-attributed as self-held (test_pydist_external_target_is_residual). + # Holder files may already be install-relative: os.path.relpath() + # against root would mangle those on POSIX (CWD prefix), so only + # convert genuine absolute paths. + rel_f = _to_rel(f) + tag_by_rel.setdefault(rel_f, set()).add(tag) + self_held: list[str] = [] + residual: list[str] = [] + for p in locked: + rel = _norm(os.path.relpath(_norm(p), root_n)) + tags = tag_by_rel.get(rel, set()) + if tags == {"excluded"}: + self_held.append(rel) + elif "/python-dist/" in "/" + rel and "target" not in tags: + # Rant 2026-08-18T21:24:48 #3 — self-held relaxation: stop_all + # itself runs from install\python-dist\python.exe; its runtime + + # lazily-loaded stdlib modules hold python-dist DLL locks that the + # module-holder enumeration does not cover (v0.2.49: 12 locked vs + # 5 enumerated modules → 7 falsely "unattributable" → old + # self-held check misjudged them as residual and aborted a fine + # install). python-dist\ + no external target holder ⇒ self-held: + # released when stop_all exits, installer continues. + self_held.append(rel) + else: + # No tags (unattributable) OR has an external target holder. + residual.append(rel) + return self_held, residual + + +def _verify_windows_categories(skip_gui: bool = False) -> list[tuple[str, list[str]]]: + """Windows residual scan, one ``(category, residual_strings)`` entry per + check — so the operator can see each check's result instead of guessing + (rant 2026-08-17T21:06:31 #3). Result is cached in ``_windows_cats_cache`` + so _verify_windows_summary() does not re-run the expensive scan. + + ``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI is + the caller and must not be reported as a residual (it intentionally + survives to relaunch itself).""" + global _windows_cats_cache + cats: list[tuple[str, list[str]]] = [] + + # GUI residual + gui: list[str] = [] + if not skip_gui: + try: + out = subprocess.run( + ["tasklist", "/FI", "IMAGENAME eq EMRG.exe"], + capture_output=True, text=True, timeout=10, **_no_window(), + ).stdout + for m in re.finditer(r"EMRG\.exe\s+(\d+)", out): + gui.append(f"EMRG.exe (pid {m.group(1)})") + except (OSError, subprocess.SubprocessError, TimeoutError): + pass + cats.append(("GUI", gui)) + + # daemon residual (fixed-port TCP probe — rant 2026-08-21T16:45:06: the + # port is the daemon ground truth; emrgd.pid is gone) + daemon: list[str] = [] + if _port_is_open(_EMRGD_PORT): + daemon.append(f"daemon (port {_EMRGD_PORT} open)") + cats.append(("daemon", daemon)) + + # python emrg process residual (TUI/daemon by command line — covers the + # port-probe blind spot: a live daemon on a different port / a TUI client + # the installer would otherwise miss) + py = [f"python emrg process (pid {p})" for p in _scan_windows_python_emrg(os.getpid())] + cats.append(("cmdline-scan", py)) + + # file-lock owners under install\ — module-holder enumeration (PRIMARY, + # rant 2026-08-18T16:24:01: the only detector that sees DLL/.pyd image- + # section locks; RM missed the browser-harness child, CreateFileW probes + # cannot see module locks at all). Any external holder = residual. + mh_out = _module_holder_ps() + mh_holders = _parse_module_holders(mh_out) + mh = [ + f"install-module holder (pid {pid}, {name or 'unknown'}, loads {', '.join(files[:3])})" + for pid, name, _exe, _parent, files, tag in mh_holders + if tag == "target" + ] + cats.append(("module-holder", mh)) + + # file-lock owners under install\ (Restart Manager — auxiliary generic + # code-5 fix: covers ANY process holding locked files, incl. non-EMRG + # ones such as the browser-harness daemon; self + ancestor chain + # excluded; rant 2026-08-17T17:55:42) + rm_out = _lock_owner_ps(kill=False) + rm = [ + f"file-lock owner (pid {o_pid}, {name or 'unknown'})" + for o_pid, name, _cmd in _windows_lock_owners(kill=False, stdout=rm_out) + ] + cats.append(("RM re-scan", rm)) + _print_rm_diag(rm_out) + + # install-writability probe — SUPPLEMENTARY ONLY (rant 2026-08-18T16:24:01: + # a CreateFileW probe cannot see DLL module locks — DELETE+SHARE_NONE + # reported OK yet DeleteFile still failed on the speedups pyd; the real + # verdict comes from the module-holder category above). Kept because it + # still catches plain (non-DLL) file locks and a broken RM. A probe + # FAILURE is not "0 locked": it becomes a residual → installer aborts + # (rant 2026-08-18T09:40:40 fail-closed). + global _lock_probe_error + _lock_probe_error = None + locked = check_install_writable() + probe_items = [] + if _lock_probe_error: + probe_items.append(f"lock-probe failed (error: {_lock_probe_error})") + # Self-held attribution (rant 2026-08-18T18:57:09 + 21:24:48 #3): when + # stop_all runs from install\python-dist\python.exe, that interpreter MUST + # load its own python-dist DLLs (python313.dll, select.pyd, ...) — those + # image-section locks are held by the stop_all process itself (module-holder + # tag ``excluded`` = self + ancestor chain) and are RELEASED when stop_all + # exits, before the installer starts overwriting (make-installer.sh uses + # ewWaitUntilTerminated). They are NOT residuals — counting them aborts + # the install while nothing is actually wrong. Since 21:24:48 the same + # holds for ANY locked file under python-dist\ with no external target + # holder (lazily-loaded stdlib modules enumeration misses). + self_held, residual_locked = _classify_locked_files( + locked, mh_holders, _install_root() if is_win() else "" + ) + # Final escalation (rant 2026-08-18T21:24:48 #2c): locks that survive the + # stop phase get a hard re-kill of their external holders (taskkill /F /T → + # parent tree → Stop-Process), then the probe + classification run again. + # Only TRULY unkillable locks are logged — the install CONTINUES and the + # installer's own overwrite is the final arbiter (21:24:48 #2c/#5). + if residual_locked and is_win(): + _escalate_locked_files(locked, mh_holders, _install_root()) + locked = check_install_writable() + if not _lock_probe_error: + _self2, residual_locked = _classify_locked_files( + locked, mh_holders, _install_root() + ) + self_held = sorted(set(self_held + _self2)) + # Advisory (non-aborting) after escalation — detailed chain already logged + # per file by _escalate_locked_files; exit stays 0 unless EMRG process + # residuals exist (rant 2026-08-18T21:24:48). + for p in residual_locked: + probe_items.append(f"locked file (advisory, install continues): {p}") + if self_held: + print( + f"emrg stop: WARNING {len(self_held)} file(s) locked by stop_all " + f"runtime itself (python-dist DLL) — self-held, released when " + f"stop_all exits; installer continues" + ) + cats.append(("createfile-probe", probe_items)) + + # bundled-git residual + bg: list[str] = [] + try: + out = subprocess.run( + ["powershell", "-NoProfile", "-Command", + "$p = Get-CimInstance Win32_Process | Where-Object { " + "$_.ExecutablePath -like \"$env:USERPROFILE\\.emrg\\install\\git\\*\" }; " + "if ($p) { $p | ForEach-Object { Write-Output " + "(\"{0} (pid {1})\" -f $_.Name, $_.ProcessId) } }"], + capture_output=True, text=True, timeout=10, **_no_window(), + ).stdout + for line in out.splitlines(): + line = line.strip() + if line: + bg.append(f"bundled-git {line}") + except (OSError, subprocess.SubprocessError, TimeoutError): + pass + cats.append(("bundled-git", bg)) + _windows_cats_cache = cats + return cats + + +def _verify_windows_summary() -> str: + """One-line per-category verify summary, e.g. + ``GUI 0 / daemon 0 / cmdline-scan 0 / RM re-scan 0 / lock-probe 0 locked / + bundled-git 0`` (rant 2026-08-17T21:06:31 #3). + + Reuses the freshest ``_verify_windows_categories()`` result when present + (single-scan, rant 2026-08-18T09:40:40 #4); falls back to a fresh scan + only when nothing has been cached yet. + """ + cats = _windows_cats_cache if _windows_cats_cache is not None else _verify_windows_categories() + return " / ".join(f"{name} {len(items)}" for name, items in cats) + + +def _verify_windows(skip_gui: bool = False) -> list[str]: + residuals: list[str] = [] + for _name, items in _verify_windows_categories(skip_gui=skip_gui): + residuals.extend(items) + return residuals + + +def _verify_posix(skip_gui: bool = False) -> list[str]: + """POSIX residual scan. ``skip_gui=True`` (``--skip-gui``) drops the + GUI's own pids (EMRG.app / EMRG-*.AppImage) from the residual list — + the GUI is the caller and intentionally stays alive to relaunch.""" + pids = _stop_scan_pids(os.getpid()) + if skip_gui: + out = _ps_output() + if out is not None: + gui_pids = { + pid + for pid, cmd in _iter_ps_lines(out) + if _is_gui_cmdline(cmd) + } + pids = [p for p in pids if p not in gui_pids] + return [f"emrg process (pid {pid})" for pid in pids] + + +def verify(skip_gui: bool = False) -> list[str]: + """Scan for residual emrg processes. Returns a list of human-readable + ``"name (pid N)"`` entries (empty = clean).""" + return _verify_windows(skip_gui=skip_gui) if is_win() else _verify_posix(skip_gui=skip_gui) + + +# ── Orchestration ─────────────────────────────────────────────── + +def _pythonpath_env() -> str: + """User/Machine PYTHONPATH on Windows (registry), else the process env — + observability for the "an unrelated python imports from install\\lib and + locks C extensions" root-cause (rant 2026-08-18T09:40:40: browser-harness + uses its own uv python but loaded install\\lib\\websockets — PYTHONPATH + pollution would make ANY python process import from install\\lib).""" + if not is_win(): + p = os.environ.get("PYTHONPATH", "") + return f"PYTHONPATH={p or '(unset)'}" + try: + import winreg + + entries: list[str] = [] + for hive, key, label in ( + (winreg.HKEY_CURRENT_USER, r"Environment", "User"), + (winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", "Machine"), + ): + try: + with winreg.OpenKey(hive, key) as k: + val, _ = winreg.QueryValueEx(k, "PYTHONPATH") + entries.append(f"PYTHONPATH({label})={val}") + except OSError: + entries.append(f"PYTHONPATH({label})=(unset)") + proc = os.environ.get("PYTHONPATH") + if proc: + entries.append(f"PYTHONPATH(process)={proc}") + return " | ".join(entries) + except Exception as e: # registry read is best-effort + return f"PYTHONPATH(registry read failed: {type(e).__name__}: {e})" + + +def _pythonpath_install_warning(line: str) -> str | None: + """Warn when any PYTHONPATH references the install dir (C-extension lock + root cause, rant 2026-08-18T09:40:40). Pure function → unit-testable. + + Matches only the install dir anchored on ``~/.emrg/install`` (both path + separators) — bare ``install\\lib`` substrings are NOT matched, so an + unrelated ``C:\\python\\install\\lib`` never warns spuriously (pm25coder + review note, PR #832). + """ + if not line or "PYTHONPATH" not in line or "(unset)" in line: + return None + lowered = line.lower() + for marker in (r".emrg\install", r"/.emrg/install"): + if marker in lowered: + return ("PYTHONPATH references ~/.emrg/install — any python " + "process may import from install\\lib and lock C " + "extensions; clear it before running the installer") + return None + + +class _Tee: + """Duplicate write()/flush() to BOTH the original stdout and a log file + (rant 2026-08-18T11:20:54). The Inno installer redirects stop_all stdout + to a random temp dir ({tmp}\\stop_all.log) that is deleted when the + install ends or is cancelled — the tee keeps a persistent fixed-path copy + (~/.emrg/logs/stop_all-.log) for post-mortem forensics. All existing + print() calls keep working untouched (they write to sys.stdout, which is + replaced with a _Tee during stop_all).""" + + def __init__(self, orig, f): + self.orig = orig + self.f = f + + def write(self, data): + self.orig.write(data) + self.f.write(data) + # Crash-safe: if the installer force-kills this process mid-write, + # append-mode + per-write flush guarantees the fixed-path copy has + # everything printed so far (rant: 不依赖 finally). + self.f.flush() + return len(data) + + def flush(self): + self.orig.flush() + self.f.flush() + + def isatty(self): + return self.orig.isatty() if hasattr(self.orig, "isatty") else False + + def fileno(self): + return self.orig.fileno() + + +def _open_stop_log() -> object | None: + """Open the fixed-path dual-write log (rant 2026-08-18T11:20:54): + ``~/.emrg/logs/stop_all-YYYYMMDD-HHMMSS.log`` (local time; the timestamp + name makes concurrent stop_all runs naturally isolated). Returns the file + object or None on any failure — best-effort, never breaks the stop flow. + The handle closes naturally at process exit (no finally dependency).""" + try: + d = os.path.join(os.path.expanduser("~"), ".emrg", "logs") + os.makedirs(d, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + return open(os.path.join(d, f"stop_all-{ts}.log"), "a", encoding="utf-8") + except OSError: + return None + + +def _caller_context() -> str: + """Best-effort "who called emrg stop" line (rant 2026-08-19T13:11:34): + parent pid + parent command line + our argv — so a post-mortem can + answer "谁杀 daemon / 谁删文件" (which process invoked the stop chain). + Pure stdlib; any failure degrades to the pid-only form, never raises.""" + ppid = os.getppid() + parent = "" + try: + if is_win(): + out = subprocess.run( + ["powershell", "-NoProfile", "-Command", + f"(Get-CimInstance Win32_Process -Filter 'ProcessId={ppid}').CommandLine"], + capture_output=True, text=True, timeout=10, + ).stdout.strip() + else: + out = subprocess.run( + ["ps", "-o", "command=", "-p", str(ppid)], + capture_output=True, text=True, timeout=10, + ).stdout.strip() + if out: + parent = out.splitlines()[0][:160] + except Exception: + pass + argv = " ".join(sys.argv) or "(none)" + return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}" + + +def _step_plan(skip_gui: bool = False) -> list[tuple[str, object]]: + """Ordered stop steps. Clients (GUI/TUI) FIRST, daemon LAST (rant + 2026-08-17T14:15:33): both clients auto-spawn the daemon when it + disappears, so stopping the daemon first would let a live client + immediately bring it back — leaving locked files for the installer. + Bundled git + RM lock-owner kill are Windows-only. + + ``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI + itself is the caller and relaunches after stop_all exits — its stop step + must be omitted, or the GUI main process gets killed before relaunch.""" + if is_win(): + steps = [ + ("GUI", stop_gui), + ("TUI", stop_tui), + ("daemon", stop_daemon), + ("bundled git", stop_bundled_git), + ("file-lock owners", stop_lock_owners), + ] + else: + steps = [ + ("GUI", stop_gui), + ("TUI", stop_tui), + ("daemon", stop_daemon), + ] + if skip_gui: + steps = [s for s in steps if s[0] != "GUI"] + return steps + + +def _is_lock_residual(r: str) -> bool: + """True when a verify residual is lock-related (external lock holder or + locked file) rather than an EMRG process residual (rant 2026-08-18T21:24:48 + #2c/#5: lock residuals are advisory after escalation — install continues; + process residuals still abort).""" + return r.startswith(( + "locked file", + "install-module holder", + "file-lock owner", + )) + + +def stop_all(skip_gui: bool = False) -> int: + """Run every stop step, then verify. Returns 0 (clean) or 1 (residuals). + + ``skip_gui=True`` (CLI ``--skip-gui``, rant 2026-08-21T12:44:34): the GUI + invokes this to tear down TUI + daemon before relaunching itself — the + GUI stop step is skipped AND the GUI process is excluded from the + residual verify (it intentionally stays alive as the caller). + + Logging follows the standard from rant 2026-08-17T21:06:31: header with + build stamp / python / platform / pid, ``[N/T] step -> result (elapsed)`` + per step, per-category verify summary, exit-code line with total elapsed, + and NO silent failures (every step is wrapped so an exception still shows + ``ERROR : `` and the run continues to the final exit code). + """ + t0 = time.monotonic() + # Dual-write log to a fixed path (rant 2026-08-18T11:20:54): the Inno + # {tmp} redirect vanishes when the install ends/cancels — tee a persistent + # copy to ~/.emrg/logs/stop_all-.log. Every print below automatically + # lands in both. The line is also printed so the Inno-side log and the + # operator both see the fixed location. + _log_f = _open_stop_log() + if _log_f is not None: + sys.stdout = _Tee(sys.stdout, _log_f) + print(f"emrg stop: log also written to {_log_f.name}") + print( + f"emrg stop: stop_all.py {_STOP_ALL_STAMP} | " + f"python {platform.python_version()} {platform.system()}-{platform.machine()} " + f"| pid {os.getpid()}" + ) + # Who called + when (rant 2026-08-19T13:11:34): every stop run must be + # attributable — parent pid/parent cmdline/argv + wall-clock start. This + # is the forensics trail for "谁杀 daemon / 谁删文件". + print(f"emrg stop: started {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"emrg stop: {_caller_context()}") + # Self-lock observability (rant 2026-08-18T16:09:45): when the installer + # runs stop_all with install\python-dist\python.exe, that interpreter's + # site config (._pth/.pth) may import install\lib modules → the runtime + # itself locks the very files it was asked to delete. Print the runtime + # so the operator can tell at a glance whether the probe's locked files + # are self-inflicted. + _pydist = is_win() and "python-dist" in sys.executable.lower() + print(f"emrg stop: self pid {os.getpid()} (python-dist runtime: {_pydist})") + # User/Machine PYTHONPATH observability (rant 2026-08-18T09:40:40) — a + # polluted PYTHONPATH makes any python process import from install\lib + # and lock C extensions; surface it before any stop/kill logic. + _pp = _pythonpath_env() + print(f"emrg stop: {_pp}") + _pp_warn = _pythonpath_install_warning(_pp) + if _pp_warn: + print(f"emrg stop: WARNING {_pp_warn}") + steps = _step_plan(skip_gui=skip_gui) + for i, (name, fn) in enumerate(steps, 1): + s = time.monotonic() + try: + fn() + print(f"emrg stop: [{i}/{len(steps)}] {name} -> done ({time.monotonic() - s:.1f}s)") + except Exception as e: # never silent (rant 2026-08-17T21:06:31 #4) + print( + f"emrg stop: ERROR [{i}/{len(steps)}] {name}: {e} " + f"({type(e).__name__}) ({time.monotonic() - s:.1f}s)" + ) + # Kill retry: RM may have killed owners but locks can linger — re-probe + # with the INDEPENDENT writability check and retry (Try-again semantics, + # rant 2026-08-17T21:06:05 #3); anything still locked flows into verify → + # installer aborts with a named list instead of a code-5 dialog. + if is_win(): + locked: list[str] = [] + for attempt in range(1, 3): + locked = check_install_writable() + if not locked: + break + print( + f"emrg stop: {len(locked)} file(s) still locked after kill " + f"(retry {attempt}/2) ..." + ) + s = time.monotonic() + try: + stop_lock_owners() + except Exception as e: + print(f"emrg stop: ERROR retry {attempt}/2: {e} ({type(e).__name__})") + time.sleep(0.3) + print(f"emrg stop: retry {attempt}/2 done ({time.monotonic() - s:.1f}s)") + # Self-lock final guard (rant 2026-08-18T16:09:45 + 16:24:01, refined + # 18:57:09): after both kill retries the probe still reports locked + # files but neither the module-holder enumeration nor RM found an + # EXTERNAL owner — the lock holder is stop_all's own runtime + # (python-dist loaded install\lib modules). The installer runs stop_all + # synchronously (ewWaitUntilTerminated), so these locks are released + # when stop_all exits and the overwrite proceeds — advisory only, NOT + # a hard abort (the pre-18:57:09 guard wrongly blocked installs whose + # only locks were python-dist DLLs held by stop_all itself). + if locked and not _module_holder_external_found and _rm_no_external_owner: + print( + f"emrg stop: WARNING {len(locked)} file(s) locked by the " + f"stop_all runtime itself (python-dist DLL) — released when " + f"stop_all exits; installer continues" + ) + residuals = verify(skip_gui=skip_gui) + # Lock-related residuals are ADVISORY after escalation (rant + # 2026-08-18T21:24:48 #2c/#5): an unkillable external lock holder is + # logged in detail and the install CONTINUES — the installer's own + # overwrite is the final arbiter ("若仍有杀不掉的锁:日志详细记录但安装 + # 继续,最终成功与否以实际覆盖为准"). Only EMRG process residuals + # (GUI / daemon / python-emrg / bundled-git) and probe failures abort. + lock_res = [r for r in residuals if _is_lock_residual(r)] + proc_res = [r for r in residuals if not _is_lock_residual(r)] + # Advisory only when the install ACTUALLY continues: a process residual + # below returns exit 1, so the "install continues" message would be a lie. + if lock_res and not proc_res: + print( + f"emrg stop: WARNING {len(lock_res)} lock-related residual(s) " + f"after escalation — install continues, overwrite is the final " + f"arbiter (rant 21:24:48):" + ) + for r in lock_res: + print(f" - {r}") + if proc_res: + print("emrg stop: WARNING residual process(es) still running:") + for r in proc_res: + print(f" - {r}") + if is_win(): + try: + print("emrg stop: verify: " + _verify_windows_summary() + " -> RESIDUAL") + except Exception: + pass + print( + f"emrg stop: exit code 1 ({len(proc_res)} residual) " + f"({time.monotonic() - t0:.1f}s)" + ) + return 1 + if is_win(): + try: + print("emrg stop: verify: " + _verify_windows_summary() + " -> CLEAN") + except Exception: + pass + print("emrg stop: all emrg processes stopped.") + print(f"emrg stop: exit code 0 (clean) ({time.monotonic() - t0:.1f}s)") + return 0 + + +def main() -> None: + # Rant 2026-08-21T12:44:34: --skip-gui — the GUI calls + # ``python -m emrg._stop_all --skip-gui`` to tear down TUI + daemon + # before relaunching itself; its own stop/verify checks are skipped. + skip_gui = "--skip-gui" in sys.argv[1:] + code = stop_all(skip_gui=skip_gui) + sys.exit(code) + + +if __name__ == "__main__": + main() diff --git a/emrg/_win.py b/emrg/_win.py new file mode 100644 index 00000000..baf98fb1 --- /dev/null +++ b/emrg/_win.py @@ -0,0 +1,39 @@ +"""Windows windowless subprocess infrastructure. + +Rant 2026-08-09T13:16:36 (v0.2.15 Windows regression, emergency): the daemon +is a non-interactive background process — every subprocess.Popen / +asyncio.create_subprocess_* without CREATE_NO_WINDOW pops a console window +on Windows. GUI/scheduler retry loops turned that into a cmd-window storm +(host observed hundreds of popups, had to reboot). All Python subprocess +call sites must splat the kwargs from :func:`win32_no_window_kwargs`; the +GUI side uses Node's ``windowsHide: true`` (already present in main.js / +daemon_client.js). + +The function is a no-op on POSIX (empty dict) so call sites stay portable. +""" + +from __future__ import annotations + +import os +import subprocess + +_IS_WINDOWS = os.name == "nt" + +# CREATE_NO_WINDOW (0x08000000) is Windows-only — subprocess exposes it only +# on win32 builds. getattr keeps the module importable and the function +# callable on POSIX (e.g. tests that force the Windows branch on a POSIX +# runner); the literal is the documented Win32 constant. +_CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) + + +def win32_no_window_kwargs() -> dict: + """Kwargs that suppress console windows for subprocess children. + + Returns ``{"creationflags": subprocess.CREATE_NO_WINDOW}`` on Windows + and ``{}`` elsewhere — safe to ``**``-splat into ``subprocess.run`` / + ``subprocess.Popen`` and ``asyncio.create_subprocess_*`` on every + platform. + """ + if _IS_WINDOWS: + return {"creationflags": _CREATE_NO_WINDOW} + return {} diff --git a/emrg/client/__main__.py b/emrg/client/__main__.py index 05f38ab5..e67faf72 100644 --- a/emrg/client/__main__.py +++ b/emrg/client/__main__.py @@ -4,7 +4,7 @@ logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - datefmt="%H:%M:%S", + datefmt="%Y-%m-%d %H:%M:%S", ) from emrg.config import ensure_config from emrg.client.app import run_client diff --git a/emrg/client/app.py b/emrg/client/app.py index 697dbe92..74ac2c0a 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -11,6 +11,7 @@ fcntl = None from datetime import datetime from pathlib import Path, PurePath +from emrg._win import win32_no_window_kwargs from emrg.client import daemon_manager from emrg.client.python_tui import ChatRow, Diff, InputParser, StatusLine, Terminal, ToolCard from emrg.client.python_tui.widgets.markdown import StreamingMarkdown @@ -27,6 +28,61 @@ logger = logging.getLogger(__name__) +def _csi_modifier_action(data: bytes) -> str | None: + """Map modifier-prefixed CSI arrow sequences to an editing action. + + macOS terminals (iTerm2/Ghostty) send \x1b[1;3D for Option+←, \x1b[1;3C + for Option+→, \x1b[1;5D for Ctrl+← etc. — the plain handler only inspects + data[2] (the '1' parameter) and drops the sequence, so the cursor never + moves while Option+Backspace (\x17) works (rant 2026-08-21T11:36:56). + Also handles the Kitty keyboard protocol form \x1b[68;3u (key code 68 = + 'D' = Left, 67 = 'C' = Right) with an Alt modifier. + + Returns 'word_left' / 'word_right' for Alt/Ctrl+←/→, else None. + """ + if len(data) < 4 or data[0] != 0x1B or data[1] != 0x5B: + return None + if b";" not in data: + return None + final = data[-1] + if not (0x40 <= final <= 0x7E): + return None + try: + parts = data[2:-1].split(b";") + if not parts or any(not p.isdigit() for p in parts): + return None + params = [int(p) for p in parts] + except ValueError: + return None + if len(params) < 2: + return None + mod = params[1] + if mod not in (3, 5, 7): # Alt(3) / Ctrl(5) / Alt+Ctrl(7) + return None + if final in (0x44, 0x43): # legacy CSI: D=← C=→ + return "word_left" if final == 0x44 else "word_right" + if final == 0x75 and params[0] in (67, 68): # Kitty CSI-u: 68='D' 67='C' + return "word_left" if params[0] == 68 else "word_right" + return None + + +def _format_status_left(title: str, sid: str, model: str = "") -> str: + """Format left status: version + session title + short ID + model. + + Module-level so it is unit-testable (rant 2026-08-13T14:11:03). + """ + import emrg + ver = getattr(emrg, "__version__", "dev") + parts = [f"v{ver}"] + if title: + parts.append(f"{title} ({sid})") + else: + parts.append(sid) + if model: + parts.append(f"[{model}]") + return " ".join(parts) + + # ── Clipboard image support (platform-adaptive) ───────────── def _detect_clipboard_image() -> tuple[bool, str | None]: @@ -39,6 +95,7 @@ def _detect_clipboard_image() -> tuple[bool, str | None]: result = subprocess.run( ['osascript', '-e', 'clipboard info'], capture_output=True, text=True, timeout=3, + **win32_no_window_kwargs(), ) out = result.stdout has_image = any(tag in out for tag in ( @@ -55,7 +112,8 @@ def _detect_clipboard_image() -> tuple[bool, str | None]: 'try\n set f to (the clipboard as «class furl»)\n' ' return POSIX path of f\nend try'], capture_output=True, text=True, timeout=2, - ) + **win32_no_window_kwargs(), + ) if r2.stdout.strip(): label = Path(r2.stdout.strip()).name except Exception: @@ -66,6 +124,7 @@ def _detect_clipboard_image() -> tuple[bool, str | None]: result = subprocess.run( ['xclip', '-selection', 'clipboard', '-t', 'TARGETS', '-o'], capture_output=True, text=True, timeout=3, + **win32_no_window_kwargs(), ) out = result.stdout if 'image/png' not in out: @@ -78,7 +137,8 @@ def _detect_clipboard_image() -> tuple[bool, str | None]: ['xclip', '-selection', 'clipboard', '-t', 'text/uri-list', '-o'], capture_output=True, text=True, timeout=2, - ) + **win32_no_window_kwargs(), + ) uri = r2.stdout.strip() if uri: label = Path(uri.replace('file://', '')).name @@ -95,6 +155,7 @@ def _detect_clipboard_image() -> tuple[bool, str | None]: result = subprocess.run( ['powershell', '-Command', ps_cmd], capture_output=True, text=True, timeout=5, + **win32_no_window_kwargs(), ) if 'IMAGE' not in result.stdout: return False, None @@ -108,7 +169,8 @@ def _detect_clipboard_image() -> tuple[bool, str | None]: 'if ($files -ne $null -and $files.Count -gt 0) ' '{ Write-Output $files[0] }'], capture_output=True, text=True, timeout=3, - ) + **win32_no_window_kwargs(), + ) if r2.stdout.strip(): label = Path(r2.stdout.strip()).name except Exception: @@ -139,6 +201,7 @@ def _extract_clipboard_image(target_path: str) -> bool: subprocess.run( ['osascript', '-e', applescript], capture_output=True, timeout=5, + **win32_no_window_kwargs(), ) path = Path(target_path) return path.exists() and path.stat().st_size > 0 @@ -149,7 +212,8 @@ def _extract_clipboard_image(target_path: str) -> bool: ['xclip', '-selection', 'clipboard', '-t', 'image/png', '-o'], stdout=f, timeout=5, - ) + **win32_no_window_kwargs(), + ) path = Path(target_path) return path.exists() and path.stat().st_size > 0 @@ -164,6 +228,7 @@ def _extract_clipboard_image(target_path: str) -> bool: subprocess.run( ['powershell', '-Command', ps_cmd], capture_output=True, timeout=5, + **win32_no_window_kwargs(), ) path = Path(target_path) return path.exists() and path.stat().st_size > 0 @@ -201,14 +266,13 @@ async def interactive(init_auto_evolve: bool = False): term = Terminal(); stdin_fd = sys.stdin.fileno() stdin_queue: asyncio.Queue = asyncio.Queue() - def _status_left(title: str, sid: str) -> str: - """Format left status: show both name and short ID for renamed sessions.""" - if title: - return f"{title} ({sid[:8]})" - return sid + def _status_left(title: str, sid: str, model: str = "") -> str: + """Format left status: version + session title + short ID + model.""" + return _format_status_left(title, sid, model) busy = False; server_id = ""; need_new_assistant = False; session_title = "" + current_model = "" # model name tracked independently of server_id (rant 2026-08-11T20:02:43) - status = StatusLine(left=_status_left(session_title, session_id), center="connecting...") + status = StatusLine(left=_status_left(session_title, session_id, current_model), center="connecting...") inp = InputWidget(); chat = ChatHistory() term.mount(status=status, composer=inp, chat=chat) @@ -221,6 +285,11 @@ def _status_left(title: str, sid: str) -> str: _welcomed = False # show welcome message once on first connect _request_start: float = 0.0 # timestamp when current request started _elapsed_task: asyncio.Task | None = None # background timer task + # P1 queue-injection client side (daemon #655): messages sent while the + # session is busy are queued daemon-side (task_queued). Track them here so + # `queued_requeue` can re-send with the same request id (without re-adding + # chat rows) and `queued_cancelled` clears on abort/disconnect. + _queued_sends: list[dict] = [] # {"id", "prompt", "images"} def _short_path(p: str) -> str: home = os.path.expanduser("~") @@ -230,12 +299,12 @@ def _short_path(p: str) -> str: p = "…" + p[-29:] return p - def _update_right() -> None: + def _update_left_extra() -> None: if msg_count > 0: - status.update(right=f"{msg_count} msgs {_short_path(cwd)}") + status.update(left_extra=f"· {msg_count} msgs · {_short_path(cwd)}") else: - status.update(right="Enter=send Esc=quit /help") - _update_right() + status.update(left_extra="Enter=send Esc=quit /help") + _update_left_extra() _status_base: str = "" # base center text without timer, for elapsed timer overlay _last_center: str = "" # last center text set via status.update, for timer overlay @@ -247,7 +316,7 @@ async def _run_elapsed_timer() -> None: try: elapsed = int(time.time() - _request_start) mins, secs = divmod(elapsed, 60) - timer = f"⏱{mins}:{secs:02d}" if mins > 0 else f"⏱{secs}s" + timer = f"[{mins}:{secs:02d}]" status.elapsed = timer term.set_title(f"{timer} {session_title or session_id} @ {project_name}") term.render() @@ -266,6 +335,7 @@ async def _run_elapsed_timer() -> None: rewind_sel = SelectorState() task_sel = SelectorState() _rant_project: str | None = None # Set after project selection, used on next Enter + _skills_confirm: tuple | None = None # (skill_name, install_cmd) — next Enter answers the prompt # Command autocomplete state (shows dropdown when user types /) _autocomplete_active = False @@ -284,7 +354,9 @@ def _render_throttled(): async def read_server(): nonlocal stream_buffer, status, history, chat, busy, server_id, need_new_assistant, session_id, session_title, msg_count, tool_args, _welcomed + nonlocal current_model nonlocal _last_center, _elapsed_task, conn + nonlocal _request_start async def _reconnect(): """Attempt reconnection — blocks until successful.""" @@ -293,12 +365,16 @@ async def _reconnect(): if _elapsed_task is not None: _elapsed_task.cancel(); _elapsed_task = None busy = False # pending request is lost + _queued_sends.clear() # daemon drops the queue on disconnect (queued_cancelled) chat.add("system", "⏸ server connection lost — reconnecting...") status.update(center="reconnecting...") term.render() # close stale connection try: await conn.close() except Exception: pass + # Rant 2026-08-09T13:16:36 ⑤: spawn 节流命中后提示宿主手动启动 + # (否则每 1s 静默重试 spawn 一台新 daemon,Windows 上即弹窗风暴)。 + _throttle_warned = False while True: try: await asyncio.sleep(1) @@ -309,6 +385,13 @@ async def _reconnect(): status.update(center=server_id or "emrg") term.render() return + except RuntimeError as e: + if "failed to start after" in str(e) and not _throttle_warned: + _throttle_warned = True + chat.add("system", f"⚠ {e}") + status.update(center="daemon down — run 'emrg server'") + term.render() + continue except Exception: continue @@ -327,26 +410,89 @@ async def _reconnect(): ident = data.get("identity", {}); hid = ident.get("instance_id", "?")[:8] host = ident.get("host_name", "?") model = data.get("model", "") - server_id = f"{hid} @ {host}" if model: - server_id += f" [{model}]" + current_model = model + server_id = f"{hid} @ {host}" if not _welcomed: _welcomed = True import emrg ver = getattr(emrg, "__version__", "dev") chat.add("system", f"EMRG {ver} | {server_id}\nType /help for shortcuts, or just start chatting.") - status.update(left=_status_left(session_title, session_id), center=server_id) + status.update(left=_status_left(session_title, session_id, current_model), center=server_id) term.set_title(f"{session_title or session_id} @ {project_name}") term.render(); continue + # P1 queue-injection client side (daemon #655): messages sent + # while the session is busy are queued daemon-side and injected + # at the next round boundary. The TUI tracks them so a + # queued_requeue re-sends with the same request id. + if data.get("type") == "task_queued": + # Daemon queued our task (session busy). The user row is + # already shown; confirm the queue position. + pos = data.get("position", 0) + chat.add("system", f"⏳ Queued (position {pos}) — will run after the current turn.") + chat.dirty = True; term.render() + continue + + if data.get("type") == "steer_committed": + # Injected into the running turn — no longer needs requeue. + rid = data.get("request_id", "") + if rid: + _queued_sends[:] = [q for q in _queued_sends if q.get("id") != rid] + continue + + if data.get("type") == "queued_requeue": + # Turn ended with queued messages never injected — re-send + # them through the normal path (the daemon lock is released + # now). Rows were already added at the original submit, so + # do NOT re-add them or double-count msg_count. + ids = set(data.get("request_ids", []) or []) + to_resend = [q for q in _queued_sends if q.get("id") in ids] + _queued_sends.clear() + if to_resend: + was_busy = busy + busy = True; need_new_assistant = True; stream_buffer = "" + _request_start = time.time() + if _elapsed_task is None: + _elapsed_task = asyncio.create_task(_run_elapsed_timer()) + for i, q in enumerate(to_resend): + rid = await conn.send_task( + session_id=session_id, cwd=cwd, prompt=q["prompt"], + images=q.get("images"), id=q["id"], + ) + # Track every re-sent message the daemon will queue: + # re-send #1 starts a new turn (busy=True above), so + # re-sends #2+ arrive while busy and get queued + # daemon-side (task_queued) — untracked they would be + # silently lost at the next queued_requeue. Also + # track all re-sends when a turn was already running + # (multi-client). steer_committed removes ids that + # get injected mid-turn, so the loop converges. + if was_busy or i > 0: + _queued_sends.append({"id": rid, "prompt": q["prompt"], "images": q.get("images")}) + chat.add("system", f"→ Re-sending {len(to_resend)} queued message(s).") + chat.dirty = True; term.render() + continue + + if data.get("type") == "queued_cancelled": + if _queued_sends: + _queued_sends.clear() + chat.add("system", "⏹ Queued message(s) cancelled.") + chat.dirty = True; term.render() + continue + # Tool lifecycle: create a ToolCard on start, update on end. if data.get("type") == "tool_start": ts = ToolStart.from_dict(data) tool_args[ts.tool_call_id] = ts.arguments # track for diff rendering _tool_start_times[ts.tool_call_id] = time.time() + # Rant 2026-08-19T10:35:24: display the agent's intent + # (why this call happened) as the card header when present; + # fall back to formatted args. + display = ts.intent if ts.intent else _format_args(ts.arguments, ts.tool_name) card = ToolCard( name=ts.tool_name, - command=_format_args(ts.arguments, ts.tool_name), + command=display, status="running", expanded=False, ) @@ -445,7 +591,14 @@ async def _reconnect(): _last_center = server_id or "emrg" status.update(center=_last_center) term.set_title(f"{session_title or session_id} @ {project_name}") - msg_count += 1; _update_right() + # rant 21:52:18: daemon's done frame reports the authoritative + # current-context message count (system + history + tool results); + # fall back to the local +1 approximation when absent. + if data.get("context_messages") is not None: + msg_count = int(data["context_messages"]) + else: + msg_count += 1 + _update_left_extra() term.render() if "error" in data: err = data["error"]; logger.error("server error: %s", err) @@ -462,7 +615,7 @@ async def _reconnect(): chat.dirty = True chat.add("system", "Session cleared — starting fresh.") msg_count = 0 - _update_right() + _update_left_extra() status.update(center=server_id or "emrg") term.render() continue @@ -483,10 +636,10 @@ async def _reconnect(): chat.rows.clear() chat.dirty = True chat.add("system", f"Created new session {new_sid} — continue chatting.") - status.update(left=_status_left("", new_sid), center=server_id or "emrg") + status.update(left=_status_left("", new_sid, current_model), center=server_id or "emrg") term.set_title(f"{new_sid} @ {project_name}") msg_count = 0 - _update_right() + _update_left_extra() status.update(center=server_id or "emrg") term.render() continue @@ -505,7 +658,7 @@ async def _reconnect(): msg_count = 0 # Reload session state from server await conn.send_command("ping") - _update_right() + _update_left_extra() status.update(center=server_id or "emrg") term.render() continue @@ -527,7 +680,7 @@ async def _reconnect(): ) busy = False msg_count = max(0, msg_count - compacted) - _update_right() + _update_left_extra() status.elapsed = "" status.update(center=server_id or "emrg"); term.render() continue @@ -657,10 +810,9 @@ async def _reconnect(): chat.add("system", f"Model switched: {previous} → {model_name}" f" (context: {ctx_win:,})") - # Update server_id so all subsequent status updates show the new model - base_id = server_id.split(" [")[0] if " [" in server_id else server_id - server_id = f"{base_id} [{model_name}]" if base_id else f"emrg [{model_name}]" - status.update(center=server_id) + # Track model independently and refresh the left section + current_model = model_name + status.update(left=_status_left(session_title, session_id, current_model), center=server_id) term.render() continue @@ -672,6 +824,11 @@ async def _reconnect(): if err: chat.add("system", f"Error: {err}") elif tasks: + # Re-trigger guard: a previous TaskSelector may still be + # in the chat (duplicate /trigger) — drop it before + # stacking a new one (rant 2026-08-21T16:47:44). + if task_sel.widget is not None: + chat.remove(task_sel.widget) task_sel.widget = TaskSelector(tasks) task_sel.active = True task_sel.pending = False @@ -699,6 +856,74 @@ async def _reconnect(): term.render() continue + # Skills available result (installable-skills catalog) + if data.get("type") == "skills_available_result": + skills = data.get("skills", []) + err = data.get("error", "") + if err: + chat.add("system", f"Error: {err}") + elif not skills: + chat.add("system", "No catalog skills found. Check ~/.emrg/skills/skill-catalog.md") + else: + lines = ["**Available Skills (catalog):**", ""] + for s in skills: + mark = "✅ installed" if s.get("installed") else "not installed" + if s.get("managed"): + mark += " · managed" + lines.append(f"- **{s.get('name', '?')}** — {s.get('description', '')} ({mark})") + lines.append("") + lines.append("Install: `/skills install ` · Refresh: `/skills update`") + chat.add("system", "\n".join(lines)) + status.update(center=server_id or "emrg") + term.render() + continue + + # Skills install result + if data.get("type") == "skills_install_result": + nonlocal _skills_confirm + name = data.get("name", "") + if data.get("confirm_required"): + cmd = data.get("install_command", "") + _skills_confirm = (name, cmd) + chat.add("system", + f"⚠️ Skill `{name}` needs its CLI installed first:\n" + f"`{cmd}`\n\n" + f"Type `yes` to confirm, or anything else to cancel.") + elif data.get("error"): + chat.add("system", f"Install failed for `{name}`: {data['error']}") + elif data.get("ok"): + chat.add("system", + f"✅ Skill `{name}` installed" + + (f" (v{data.get('version', '?')})" if data.get("version") else "") + + ". It will appear in the next session's Available Skills.") + status.update(center=server_id or "emrg") + term.render() + continue + + # Skills update result + if data.get("type") == "skills_update_result": + err = data.get("error", "") + checked = data.get("checked", 0) + updated = data.get("updated", []) + skipped = data.get("skipped", []) + errors = data.get("errors", []) + if err: + chat.add("system", f"Skill update failed: {err}") + else: + lines = [f"**Skill update check:** {checked} managed skill(s)"] + if updated: + lines.append(f"Updated: {', '.join(updated)}") + if skipped: + lines.append(f"Skipped (CLI missing): {', '.join(skipped)}") + if errors: + lines.append(f"Failed: {', '.join(errors)}") + if not updated and not skipped and not errors: + lines.append("All up to date.") + chat.add("system", "\n".join(lines)) + status.update(center=server_id or "emrg") + term.render() + continue + # Resume result if data.get("type") == "resume_result": err = data.get("error", "") @@ -764,11 +989,11 @@ async def _reconnect(): f"Resumed session {session_id}{title_extra} " f"({meta.get('message_count', record_count)} messages, " f"created {str(meta.get('created_at', ''))[:16].replace('T', ' ')})") - status.update(left=_status_left(session_title, session_id), center=server_id or "emrg") + status.update(left=_status_left(session_title, session_id, current_model), center=server_id or "emrg") term.set_title(f"{session_title or session_id} @ {project_name}") # Set message count from loaded session msg_count = meta.get("message_count", record_count) - _update_right() + _update_left_extra() term.render() continue @@ -781,7 +1006,7 @@ async def _reconnect(): new_title = data.get("title", "") session_title = new_title chat.add("system", f"Session renamed to: {new_title}") - status.update(left=_status_left(session_title, session_id), center=server_id or "emrg") + status.update(left=_status_left(session_title, session_id, current_model), center=server_id or "emrg") term.set_title(f"{session_title} @ {project_name}") term.render() continue @@ -816,7 +1041,6 @@ async def _reconnect(): term.render() continue - # Memory content (read) if data.get("type") == "memory_content": err = data.get("error", "") if err: @@ -948,10 +1172,12 @@ def _handle_selector_nav(data: bytes, widget) -> bool: async def handle_key(data: bytes) -> bool: nonlocal inp, status, history, paste_mode, stream_buffer, conn, chat, busy, need_new_assistant, session_id, session_title, msg_count, cwd + nonlocal current_model nonlocal session_sel, delete_sel, project_sel, model_sel, rewind_sel, task_sel nonlocal history_index, history_saved_input nonlocal _autocomplete_active, _autocomplete_widget nonlocal _request_start, _last_center, _elapsed_task, _pending_images + nonlocal _skills_confirm if len(data) == 0: return True if data == b"\x1b[200~": paste_mode = True; return True if data == b"\x1b[201~": @@ -1012,6 +1238,7 @@ async def handle_key(data: bytes) -> bool: if data == b"\x1b": # Esc — cancel selection session_sel.active = False chat.add("system", "Session selection cancelled.") + chat.remove(session_sel.widget) session_sel.widget = None status.update(center=server_id or "emrg") chat.dirty = True; term.render() @@ -1019,6 +1246,7 @@ async def handle_key(data: bytes) -> bool: if data == b"\r" or data == b"\n": # Enter — confirm sid = session_sel.widget.selected_session_id session_sel.active = False + chat.remove(session_sel.widget) session_sel.widget = None if sid: await conn.send_command("resume_session", session_id=sid, cwd=cwd) @@ -1040,6 +1268,7 @@ async def handle_key(data: bytes) -> bool: if data == b"\x1b": # Esc — cancel delete_sel.active = False chat.add("system", "Delete cancelled.") + chat.remove(delete_sel.widget) delete_sel.widget = None status.update(center=server_id or "emrg") chat.dirty = True; term.render() @@ -1047,6 +1276,7 @@ async def handle_key(data: bytes) -> bool: if data == b"\r" or data == b"\n": # Enter — delete immediately sid = delete_sel.widget.selected_session_id delete_sel.active = False + chat.remove(delete_sel.widget) delete_sel.widget = None if sid: await conn.send_command("delete_session", session_id=sid, cwd=cwd) @@ -1067,6 +1297,7 @@ async def handle_key(data: bytes) -> bool: if data == b"\x1b": # Esc — cancel selection project_sel.active = False chat.add("system", "Project selection cancelled.") + chat.remove(project_sel.widget) project_sel.widget = None status.update(center=server_id or "emrg") chat.dirty = True; term.render() @@ -1074,6 +1305,7 @@ async def handle_key(data: bytes) -> bool: if data == b"\r" or data == b"\n": # Enter — confirm pname = project_sel.widget.selected_project_name project_sel.active = False + chat.remove(project_sel.widget) project_sel.widget = None if pname: nonlocal _rant_project @@ -1096,6 +1328,7 @@ async def handle_key(data: bytes) -> bool: if data == b"\x1b": # Esc — cancel selection model_sel.active = False chat.add("system", "Model selection cancelled.") + chat.remove(model_sel.widget) model_sel.widget = None status.update(center=server_id or "emrg") chat.dirty = True; term.render() @@ -1103,6 +1336,7 @@ async def handle_key(data: bytes) -> bool: if data == b"\r" or data == b"\n": # Enter — confirm mname = model_sel.widget.selected_model_name model_sel.active = False + chat.remove(model_sel.widget) model_sel.widget = None if mname: await conn.send_command("set_model", model=mname) @@ -1123,6 +1357,7 @@ async def handle_key(data: bytes) -> bool: if data == b"\x1b": # Esc — cancel selection rewind_sel.active = False chat.add("system", "Rewind cancelled.") + chat.remove(rewind_sel.widget) rewind_sel.widget = None status.update(center=server_id or "emrg") chat.dirty = True; term.render() @@ -1130,6 +1365,7 @@ async def handle_key(data: bytes) -> bool: if data == b"\r" or data == b"\n": # Enter — confirm idx = rewind_sel.widget.selected_record_index rewind_sel.active = False + chat.remove(rewind_sel.widget) rewind_sel.widget = None if idx is not None: await conn.send_command("rewind_session", session_id=session_id, @@ -1152,6 +1388,7 @@ async def handle_key(data: bytes) -> bool: if data == b"\x1b": # Esc — cancel selection task_sel.active = False chat.add("system", "Task selection cancelled.") + chat.remove(task_sel.widget) task_sel.widget = None status.update(center=server_id or "emrg") chat.dirty = True; term.render() @@ -1159,6 +1396,7 @@ async def handle_key(data: bytes) -> bool: if data in (b"\r", b"\n"): # Enter — confirm task_name = task_sel.widget.selected_task_name task_sel.active = False + chat.remove(task_sel.widget) task_sel.widget = None if task_name: await conn.send_command("trigger_task", name=task_name, @@ -1293,6 +1531,16 @@ async def handle_key(data: bytes) -> bool: if b == 0x1B and len(data) >= 3: if data[1] == 0x5B: c = data[2] + # Modifier-prefixed arrows (macOS Option/Ctrl+←→): map to word + # movement (rant 2026-08-21T11:36:56). + _csi_action = _csi_modifier_action(data) + if _csi_action is not None: + if _csi_action == "word_left": + inp.move_word_left() + else: + inp.move_word_right() + term.render() + return True if c == 0x41: # Up avail = max(1, term.viewport.viewport_width - 2) if inp._cursor_vrow(avail) == 0: @@ -1344,16 +1592,42 @@ async def handle_key(data: bytes) -> bool: if text.lower() in ("quit", "exit"): return False # If a rant project was selected, use this message as the rant + # (rant 2026-08-17T11:51:59: routes through the agent for + # polish/confirm, then the submit_rant tool records it) if _rant_project: - await conn.send_command("rant", message=text, project=_rant_project, - timestamp=datetime.now().isoformat()) - - chat.add("system", f"Rant recorded (@{_rant_project}). The evolution system will review it.") + hint = ( + f"[Host wants to submit this rant — polish it, ask for " + f"confirmation if needed, then call submit_rant " + f"(project: {_rant_project})]\n{text}" + ) + chat.add("user", f"/rant @{_rant_project} {text}") + chat.add("assistant", "") + msg_count += 1; _update_left_extra() + _last_center = "thinking..." + status.update(center=_last_center) + term.render() + rid = await conn.send_task(session_id=session_id, cwd=cwd, + prompt=hint) + if was_busy: + _queued_sends.append({"id": rid, "prompt": hint, "images": None}) _rant_project = None status.update(center=server_id or "emrg") inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True + # Pending /skills install confirmation — next line is the answer + if _skills_confirm is not None: + name, cmd = _skills_confirm + _skills_confirm = None + if text.lower() in ("y", "yes"): + await conn.send_command("skills_install", name=name, confirmed=True) + chat.add("system", f"Confirmed — installing `{name}` (CLI: `{cmd}`)…") + else: + chat.add("system", "Install cancelled.") + status.update(center=server_id or "emrg") + inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() + return True + # Handle /memory command if text.lower().startswith("/memory"): parts = text.split(None, 1) @@ -1435,16 +1709,34 @@ async def handle_key(data: bytes) -> bool: inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True - # Handle /skills command - if text.lower() == "/skills": - skills = load_skills() - if skills: - lines = ["**Loaded Skills:**", ""] - for s in skills: - lines.append(f"- **{s.name}** ({s.source}) — {s.description}") - chat.add("system", "\n".join(lines)) + # Handle /skills command (list / available / install / update) + if text.lower().startswith("/skills"): + parts = text.split(None, 1) + sub = parts[1].strip() if len(parts) > 1 else "" + sub_l = sub.lower() + if sub_l == "available": + # Installable-skills catalog (rant 2026-08-08T10:14:29) + await conn.send_command("skills_available") + status.update(center="checking available skills…") + elif sub_l.startswith("install "): + name = sub[8:].strip() + if not name: + chat.add("system", "Usage: /skills install ") + else: + await conn.send_command("skills_install", name=name, confirmed=False) + status.update(center=f"installing {name}…") + elif sub_l == "update": + await conn.send_command("skills_update") + status.update(center="checking skill updates…") else: - chat.add("system", "No skills loaded. Add .md files to ~/.emrg/skills/ or .emrg/skills/") + skills = load_skills() + if skills: + lines = ["**Loaded Skills:**", ""] + for s in skills: + lines.append(f"- **{s.name}** ({s.source}) — {s.description}") + chat.add("system", "\n".join(lines)) + else: + chat.add("system", "No skills loaded. Add .md files to ~/.emrg/skills/ or .emrg/skills/") inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True @@ -1588,6 +1880,10 @@ def _is_image_token(s, i): return True # Handle /rant command + # Rant 2026-08-17T11:51:59: /rant is no longer a direct write — + # it is a hint that the user wants to submit a rant. The text + # goes through the normal conversation so the agent can + # clarify / polish / confirm, then call the submit_rant tool. if text.lower().startswith("/rant"): parts = text.split(None, 2) message = parts[1].strip() if len(parts) > 1 else "" @@ -1609,16 +1905,22 @@ def _is_image_token(s, i): status.update(center="loading projects...") inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True - payload = { - "message": message, - "timestamp": datetime.now().isoformat(), - } - if project: - payload["project"] = project - await conn.send_command("rant", **payload) - target = f" (@{project})" if project else "" - chat.add("system", f"Rant recorded{target}. The evolution system will review it.") + hint = ( + f"[Host wants to submit this rant — polish it, ask for " + f"confirmation if needed, then call submit_rant " + f"(project: {project if project else 'emrg'})]\n{message}" + ) + chat.add("user", f"/rant{target} {message}") + chat.add("assistant", "") + msg_count += 1; _update_left_extra() + _last_center = "thinking..." + status.update(center=_last_center) + term.render() + rid = await conn.send_task(session_id=session_id, cwd=cwd, + prompt=hint) + if was_busy: + _queued_sends.append({"id": rid, "prompt": hint, "images": None}) inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True @@ -1668,6 +1970,7 @@ def _is_image_token(s, i): target_sid = parts[1].strip() # Deactivate selector if active session_sel.active = False + chat.remove(session_sel.widget) session_sel.widget = None session_sel.pending = False await conn.send_command("resume_session", session_id=target_sid, cwd=cwd) @@ -1676,9 +1979,11 @@ def _is_image_token(s, i): inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True - if busy: - logger.debug("ENTER blocked by busy") - term.render(); return True + # P1 queue-injection (daemon #655): sending while busy no longer + # blocks — the daemon queues the task (task_queued) and injects + # it at the next round boundary, or re-sends via queued_requeue + # when the turn ends. Track the send for requeue. + was_busy = busy busy = True; need_new_assistant = True # rant #32: force new StreamingMarkdown per response _request_start = time.time() @@ -1691,7 +1996,7 @@ def _is_image_token(s, i): history.append(text); stream_buffer = "" history_index = -1 # reset history navigation on submit chat.add("assistant", "") - msg_count += 1; _update_right() + msg_count += 1; _update_left_extra() logger.debug("ROWS after asst: %d [%s]", len(chat.rows), ', '.join(f'{r.role}={r.content[:20]}' for r in chat.rows if isinstance(r, ChatRow))) _last_center = "thinking..." @@ -1702,8 +2007,10 @@ def _is_image_token(s, i): _pending_images[:] = [img for img in _pending_images if img.get("label") in inp.text] images = _pending_images or None _pending_images = [] - await conn.send_task(session_id=session_id, cwd=cwd, prompt=text, - stream=True, images=images) + rid = await conn.send_task(session_id=session_id, cwd=cwd, prompt=text, + images=images) + if was_busy: + _queued_sends.append({"id": rid, "prompt": text, "images": images}) logger.info("task sent, prompt_len=%d chars", len(text)) inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render(); return True if b == 0x1B and len(data) >= 2 and data[1] in (0x0D, 0x0A): diff --git a/emrg/client/daemon_manager.py b/emrg/client/daemon_manager.py index 4e6709fe..24524e7d 100644 --- a/emrg/client/daemon_manager.py +++ b/emrg/client/daemon_manager.py @@ -21,13 +21,16 @@ from pathlib import Path from typing import AsyncIterator +from emrg._win import win32_no_window_kwargs from emrg.connect import ( + AuthError, cleanup_server, connect_to_server, get_server_path, is_server_running_sync, ) from emrg.protocol import TaskRequest +from websockets.exceptions import ConnectionClosed logger = logging.getLogger(__name__) @@ -67,14 +70,33 @@ def is_running() -> bool: return is_server_running_sync() +# Rant 2026-08-09T13:16:36 ⑤(防风暴总闸):daemon 启动失败时不得无限重拉—— +# TUI app.py _reconnect 循环每 1s 调 ensure_connected → start_daemon 会每 1s +# spawn 一个新 daemon 进程(Windows 上每个 spawn 都是 cmd 窗口来源)。单个 +# "连接生命周期"内最多 _MAX_SPAWN_ATTEMPTS 次 spawn,超限抛错提示宿主手动 +# `emrg server`;成功连接后归零。 +_MAX_SPAWN_ATTEMPTS = 3 +_spawn_attempts = 0 + + async def start_daemon() -> subprocess.Popen: """Start emrgd in the background and wait until it accepts connections.""" - logger.info("starting emrgd daemon...") + global _spawn_attempts + if _spawn_attempts >= _MAX_SPAWN_ATTEMPTS: + raise RuntimeError( + f"daemon failed to start after {_MAX_SPAWN_ATTEMPTS} attempts — " + "please run 'emrg server' manually and check emrgd.log" + ) + _spawn_attempts += 1 + logger.info("starting emrgd daemon (attempt %d/%d)...", _spawn_attempts, _MAX_SPAWN_ATTEMPTS) cleanup_server() proc = await asyncio.create_subprocess_exec( sys.executable, "-m", "emrg.server", stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, - start_new_session=True, close_fds=True) + start_new_session=True, close_fds=True, + # Windows: daemon spawn must never pop a console window + # (rant 2026-08-09T13:16:36 — cmd-window storm). + **win32_no_window_kwargs()) for _ in range(15): await asyncio.sleep(0.3) if is_running(): @@ -146,28 +168,60 @@ async def check_and_restart_if_stale() -> None: logger.info( "%s, restarting (old pid=%d)", restart_reason, server_pid, ) - # Kill old server: SIGTERM first, SIGKILL if still alive + # Kill old server: SIGTERM first, SIGKILL if still alive. + # ⚠️ (rant 2026-08-18T12:49:09 ②) The old daemon must be TRULY + # dead before the port file is removed and a new daemon spawns. + # Previously cleanup_server() deleted the port file BEFORE the + # wait, so is_running() (a port-file probe) returned False + # instantly and a new daemon spawned while the old one was still + # shutting down → multiple emrg.server instances on different + # ports. Wait on the old PID itself (POSIX os.kill(pid,0) probe), + # then remove the port file only after it is gone. try: os.kill(server_pid, signal.SIGTERM) except (ProcessLookupError, OSError): pass - cleanup_server() - # Wait for old server to die - for _ in range(10): + + def _old_pid_alive() -> bool: + if sys.platform == "win32": + # os.kill(pid, 0) would TerminateProcess on Windows — + # never use it as a liveness probe. Windows SIGTERM is + # an immediate hard kill, so the port probe suffices. + return is_running() + try: + os.kill(server_pid, 0) + return True + except ProcessLookupError: + return False + except OSError: + return True # EPERM → process exists + + for _ in range(50): # up to 10s for graceful shutdown await asyncio.sleep(0.2) - if not is_running(): + if not _old_pid_alive(): break else: # SIGTERM didn't work — force kill logger.warning("old daemon (pid=%d) didn't die, sending SIGKILL", server_pid) try: os.kill(server_pid, signal.SIGKILL) - await asyncio.sleep(0.3) except (ProcessLookupError, OSError): pass + for _ in range(10): # up to 2s for SIGKILL to land + await asyncio.sleep(0.2) + if not _old_pid_alive(): + break + # Old daemon is gone — now safe to remove its port file + cleanup_server() except (ConnectionRefusedError, FileNotFoundError, OSError, json.JSONDecodeError, - asyncio.TimeoutError, Exception): - pass # Server not reachable — connect_to_server will handle + asyncio.TimeoutError, ConnectionClosed): + # G129 (rant 2026-08-09T08:03:46): only genuinely transient connection + # failures are swallowed here — connect_to_server in ensure_connected() + # will surface the real error. AuthError and programming errors are NOT + # in this list: a token mismatch is a config/install problem the user + # must see (previously hidden by a bare `except Exception`). + logger.debug("stale check: server not reachable — connect_to_server will handle") + pass async def ensure_connected() -> "DaemonConnection": @@ -175,11 +229,15 @@ async def ensure_connected() -> "DaemonConnection": 内部改名:check_and_restart_if_stale / is_running / start_daemon。 """ + global _spawn_attempts await check_and_restart_if_stale() if not is_running(): cleanup_server() await start_daemon() - return DaemonConnection(await connect_to_server()) + conn = DaemonConnection(await connect_to_server()) + # 连接生命周期成功 → spawn 节流计数归零(对照 GUI daemon_client.js auth_ok) + _spawn_attempts = 0 + return conn # ── 协议客户端封装 ───────────────────────────────────────────────────── @@ -196,15 +254,20 @@ def __init__(self, ws): self._ws = ws async def send_task(self, session_id: str, cwd: str, prompt: str, - stream: bool = True, images: list | None = None) -> None: + images: list | None = None, id: str | None = None) -> str: """聊天发送:TaskRequest(type="task")。images 支持 /image 粘贴图。 内部 json.dumps(req.to_dict(), ensure_ascii=False) 以 str 发送(不 .encode())。 + `id` 显式指定请求 id(P1 queue requeue 复用原 id 以匹配 queued_requeue); + 返回最终请求 id(未指定时为内部生成的 uuid)。 """ - req = TaskRequest(session_id=session_id, cwd=cwd, prompt=prompt, stream=stream) + req = TaskRequest(session_id=session_id, cwd=cwd, prompt=prompt) + if id: + req.id = id if images: req.images = images await self._ws.send(json.dumps(req.to_dict(), ensure_ascii=False)) + return req.id async def send_command(self, type_: str, **params) -> None: """通用命令:ping/list_*/set_*/rant/compact/... 只发不读。 diff --git a/emrg/client/python_tui/output.py b/emrg/client/python_tui/output.py index e905ca4c..f88207eb 100644 --- a/emrg/client/python_tui/output.py +++ b/emrg/client/python_tui/output.py @@ -254,24 +254,15 @@ def write_frame( last_style_id = -1 # force transition on first diff cell last_hyperlink_id = -1 has_output = False - row_dirty_end: dict[int, int] = {} # y → max x changed in that row for x, y, prev, curr in diffs: - # When a WIDE character is at position x, the SPACER_TAIL at x+1 - # must be protected from the trailing CLEAR_TO_EOL cleanup. - # If row_dirty_end stops at x, the cleanup CUP+EL would target the - # SPACER_TAIL cell and erase it, breaking the wide character on screen. - pw = int(getattr(prev, "width", 0)) - cw = int(getattr(curr, "width", 0)) - dirty_end = x + 1 if (pw == 1 or cw == 1) else x - row_dirty_end[y] = max(row_dirty_end.get(y, 0), dirty_end) - # SPACER_TAIL detection: wide chars occupy 2 cells (WIDE + SPACER_TAIL). # The WIDE cell already advanced the terminal 2 columns, so the # SPACER_TAIL cell must not reposition the cursor, write a character, # or emit a style transition — it represents the terminal cursor's # implicit position, not a cell that needs painting. curr_char = getattr(curr, "char", " ") + cw = int(getattr(curr, "width", 0)) is_spacer = cw == 2 # Cursor positioning @@ -308,18 +299,14 @@ def write_frame( if not is_spacer: parts.append(curr_char if curr_char else " ") has_output = True - - # Reset style and clear to end of each affected row - # This eliminates wide-character ghost artifacts (spacer tails, orphan cursors) - if row_dirty_end: - if last_style_id > 0: - parts.append("\x1b[0m") - last_style_id = 0 - for y in sorted(row_dirty_end.keys(), reverse=True): - last_col = row_dirty_end[y] - # Move to one past the last changed cell, erase to end of line - parts.append(f"\x1b[{y + 1};{last_col + 2}H") - parts.append(CLEAR_TO_EOL) + # SPACER_TAIL cells need no output: a wide char advances the terminal + # cursor 2 columns, and its 2nd column inherently covers any stale glyph + # from the previous frame. Wide-char removal is handled by the + # inline-shrink path (prev char → curr empty) which writes a space + # through the normal branch above (rant 2026-08-11T19:59:09 / review + # 2026-08-11: an explicit space here would land one column PAST the + # spacer — the cursor is at x+2 after the WIDE cell, not x+1 — shifting + # chars after CJK insertions). if sync: parts.append(CURSOR_SHOW) diff --git a/emrg/client/python_tui/terminal.py b/emrg/client/python_tui/terminal.py index 37720b45..d6ef67be 100644 --- a/emrg/client/python_tui/terminal.py +++ b/emrg/client/python_tui/terminal.py @@ -23,6 +23,7 @@ from emrg.client.python_tui.buffer import Buffer, CharPool, HyperlinkPool, StylePool, diff_buffers from emrg.client.python_tui.output import ( + CLEAR_SCREEN, CURSOR_HIDE, CURSOR_SHOW, CURSOR_HOME, @@ -142,6 +143,10 @@ def __post_init__(self) -> None: ) self._rendered_cache: dict[str, list[object]] = {} self._scrollback_lines_pushed: int = 0 + # 上次渲染宽度(rant 2026-08-14T11:47:11):窗口缩窄时终端屏幕每行右侧的旧字符 + # 不会被 diff 清除(diff_buffers 只比较 min(prev_w, curr_w) 列,且 Buffer.resize + # 缩窄已物理截断旧宽度信息)→ render 检测到缩窄时先 CLEAR_SCREEN 再全量重绘。 + self._last_render_width: int = self.caps.width @property def viewport_height(self) -> int: @@ -215,6 +220,16 @@ def render(self, full: bool = False) -> None: width = self.viewport.viewport_width ctx = RenderContext(width=width) + # 宽度缩窄 → 终端屏幕右侧残留旧字符(diff 只覆盖新宽度;Buffer.resize 缩窄已 + # 丢弃旧宽度信息,diff_buffers 无从清除)。先清屏 + 清 front buffer 强制全量 + # 重绘,杜绝残留叠加(rant 2026-08-14T11:47:11)。 + if width < self._last_render_width: + sys.stdout.write(CLEAR_SCREEN) + if self._front_buffer: + self._front_buffer.clear() + full = True + self._last_render_width = width + # Cache last rendered output per widget so dirty=False doesn't blank it def _get_lines(name: str) -> list[object]: @@ -457,6 +472,9 @@ def shutdown(self) -> None: if self._raw_mode: self._exit_raw_mode() sys.stdout.write(CURSOR_SHOW) + # rant 2026-08-18T11:13:17:退出时清屏 — 只归位 (0,0) 不清屏会残留界面内容。 + # 顺序:先清屏再归位(2J 清屏后光标留在末行,需 CURSOR_HOME 回到 (0,0))。 + sys.stdout.write(CLEAR_SCREEN) sys.stdout.write(CURSOR_HOME) sys.stdout.write(RESET_SCROLL_REGION) sys.stdout.write("\x1b[?2004l") diff --git a/emrg/client/python_tui/widgets/composer.py b/emrg/client/python_tui/widgets/composer.py index 74ee475c..306dcef2 100644 --- a/emrg/client/python_tui/widgets/composer.py +++ b/emrg/client/python_tui/widgets/composer.py @@ -140,28 +140,55 @@ def submit(self) -> str | None: return text def render(self, ctx: RenderContext) -> list[Line]: - """Render the composer with prompt, text, and cursor indicator.""" + """Render the composer with prompt, text, and cursor indicator. + + Multi-line text (pasted input) renders as one Line per logical line: + the first line carries the prompt, continuation lines a same-width + indent, and the cursor is drawn on the line it currently sits in + (rant 2026-08-19T14:25:55 — a single Line with embedded ``\\n`` was + flattened by the buffer, which skips newline characters). + """ is_placeholder = not self._text - # Show cursor position - if self._text and self._cursor < len(self._text): - cursor_char = self._text[self._cursor] - prefix = self._text[:self._cursor] - suffix = self._text[self._cursor + 1:] - else: - cursor_char = " " - prefix = self._text - suffix = "" - - cursor_style = "reverse" if self._text else "dim" - style = "dim" if is_placeholder else "" - + if self._text: + content_lines = self._text.split("\n") + indent = " " * len(self.prompt) + lines_out: list[Line] = [] + line_start = 0 + for i, line_text in enumerate(content_lines): + line_end = line_start + len(line_text) + # Cursor lives in this line iff it is within [line_start, line_end]. + cursor_here = line_start <= self._cursor <= line_end + if cursor_here: + rel = self._cursor - line_start + if rel < len(line_text): + cursor_char = line_text[rel] + prefix = line_text[:rel] + suffix = line_text[rel + 1:] + else: # cursor at end of this line (incl. on the newline) + cursor_char = " " + prefix = line_text + suffix = "" + else: + cursor_char = " " + prefix = line_text + suffix = "" + lines_out.append(Line(spans=[ + Span(text=self.prompt if i == 0 else indent, + style="bold cyan" if i == 0 else "dim"), + Span(text=prefix, style="" if not is_placeholder else "dim"), + Span(text=cursor_char, style="reverse"), + Span(text=suffix, style="" if not is_placeholder else "dim"), + ], style=ctx.style)) + line_start = line_end + 1 # skip the newline separator + self._dirty = False + return lines_out + + # Empty / placeholder: single line with prompt + dim cursor block. spans = [ Span(text=self.prompt, style="bold cyan"), - Span(text=prefix, style=style), - Span(text=cursor_char, style=cursor_style), - Span(text=suffix, style=style), + Span(text=" ", style="dim"), + Span(text=" ", style="dim"), ] - self._dirty = False return [Line(spans=spans, style=ctx.style)] diff --git a/emrg/client/python_tui/widgets/markdown.py b/emrg/client/python_tui/widgets/markdown.py index e258daee..85ed074d 100644 --- a/emrg/client/python_tui/widgets/markdown.py +++ b/emrg/client/python_tui/widgets/markdown.py @@ -47,6 +47,70 @@ def render(self, ctx: RenderContext) -> list[Line]: return lines +class UserMarkdown(Markdown): + """User message rendered as markdown with the role prefix preserved. + + Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user messages + go through the same Rich markdown pipeline as assistant messages — free + width-based wrapping, CJK wide-char handling — while keeping the + ``> `` prefix + bold cyan role visual. The markdown is rendered at + ``ctx.width - len(prefix)`` so the prefix on the first line never + overflows the buffer width (continuation lines get a same-width indent). + + Single newlines are preserved as hard line breaks (rant + 2026-08-19T14:25:55): Rich would otherwise collapse ``\\n`` into a + space, merging pasted multi-line messages into one wrapped line. + """ + + _ROLE_PREFIX = "> " + _ROLE_STYLE = "bold cyan" + + def render(self, ctx: RenderContext) -> list[Line]: + from rich.style import Style + + from emrg.client.python_tui.rich_bridge import rich_renderable_to_lines + from emrg.client.python_tui.widgets.base import Span + + prefix = self._ROLE_PREFIX + indent = " " * len(prefix) + role_style = Style.parse(self._ROLE_STYLE) + avail = max(1, ctx.width - len(prefix)) + + md = RichMarkdown(_preserve_line_breaks(self.text), code_theme="monokai") + md_lines = rich_renderable_to_lines(md, avail) + lines: list[Line] = [] + for i, line in enumerate(md_lines): + lead = prefix if i == 0 else indent + line.spans.insert(0, Span(text=lead, style=role_style)) + line.style = ctx.style + lines.append(line) + self._dirty = False + return lines + + +def _preserve_line_breaks(text: str) -> str: + """Turn single newlines into hard breaks so RichMarkdown keeps them. + + Rich collapses a single ``\\n`` (markdown soft break) into a space, so + pasted multi-line user messages render as one long auto-wrapped line. + A CommonMark hard break is a line ending in two spaces — Rich renders + each such line separately. Blank lines (paragraph separators) and the + interior of fenced code blocks are left untouched: trailing whitespace + is significant inside code blocks. + """ + out: list[str] = [] + in_fence = False + for line in text.split("\n"): + if line.lstrip().startswith("```"): + in_fence = not in_fence + out.append(line) + elif in_fence or not line.strip(): + out.append(line) + else: + out.append(line + " ") + return "\n".join(out) + + @dataclass class StreamingMarkdown(Widget): """Incremental markdown renderer for token-by-token streaming. diff --git a/emrg/client/python_tui/widgets/status_line.py b/emrg/client/python_tui/widgets/status_line.py index 1425ed6c..2216f98e 100644 --- a/emrg/client/python_tui/widgets/status_line.py +++ b/emrg/client/python_tui/widgets/status_line.py @@ -1,7 +1,9 @@ """Status line widget — single-line footer bar. -Displays token usage, model name, agent state, and other status info. -Follows Codex's StatusLineWidget pattern: left/center/right sections. +Displays model name, agent state, and other status info. +Layout (rant 2026-08-11T20:02:43): left = session + model + elapsed + +message count + dir (all core info), center = server id + host only. +No right section. """ from __future__ import annotations @@ -10,39 +12,57 @@ class StatusLine(Widget): - """Single-line status footer with three sections. + """Single-line status footer with two sections. Args: - left: Left-aligned content (e.g., agent name). - center: Center-aligned content (e.g., model name). - right: Right-aligned content (e.g., token count). - model: Optional model display name. - tokens: Optional token usage count. + left: Left-aligned content (session title + short id + model). + center: Center-aligned content (server id + host). + model: Optional model display name (center fallback). + left_elapsed: Optional elapsed-time string (e.g. ``[1:23]``) appended + to the left section while busy. + left_extra: Optional extra left content (e.g. ``· 3 msgs · ~/proj``). """ def __init__( self, left: str = "", center: str = "", - right: str = "", model: str | None = None, - tokens: int | None = None, + left_elapsed: str = "", + left_extra: str = "", ) -> None: self.left = left self.center = center - self.right = right self._model = model - self._tokens = tokens - self._elapsed: str = "" + self._left_elapsed = left_elapsed + self._left_extra = left_extra self._dirty = True @property def elapsed(self) -> str: - return self._elapsed + return self._left_elapsed @elapsed.setter def elapsed(self, value: str) -> None: - self._elapsed = value + self._left_elapsed = value + self._dirty = True + + @property + def left_elapsed(self) -> str: + return self._left_elapsed + + @left_elapsed.setter + def left_elapsed(self, value: str) -> None: + self._left_elapsed = value + self._dirty = True + + @property + def left_extra(self) -> str: + return self._left_extra + + @left_extra.setter + def left_extra(self, value: str) -> None: + self._left_extra = value self._dirty = True @property @@ -62,54 +82,44 @@ def model(self, value: str | None) -> None: self._model = value self._dirty = True - @property - def tokens(self) -> int | None: - return self._tokens - - @tokens.setter - def tokens(self, value: int | None) -> None: - self._tokens = value - self._dirty = True - def update( self, left: str | None = None, center: str | None = None, - right: str | None = None, model: str | None = None, - tokens: int | None = None, + left_elapsed: str | None = None, + left_extra: str | None = None, ) -> None: """Update any fields and mark dirty.""" if left is not None: self.left = left if center is not None: self.center = center - if right is not None: - self.right = right if model is not None: self._model = model - if tokens is not None: - self._tokens = tokens + if left_elapsed is not None: + self._left_elapsed = left_elapsed + if left_extra is not None: + self._left_extra = left_extra self._dirty = True def render(self, ctx: RenderContext) -> list[Line]: - """Render a single-line status bar: [left] [center] [right].""" - # Build text sections - right_text = self.right - if self._tokens is not None: - right_text = f"↑ {self._tokens:,} tk {right_text}" + """Render a single-line status bar: [left] [center].""" + # Build left section: session/model/elapsed/msg-count/dir + left_parts: list[str] = [] + if self.left: + left_parts.append(self.left) + if self._left_elapsed: + left_parts.append(self._left_elapsed) + if self._left_extra: + left_parts.append(self._left_extra) + left_text = (" " + " ".join(left_parts)) if left_parts else "" - left_text = f" {self.left}" if self.left else "" center_text = self.center or self._model or "" - if center_text and self._elapsed: - center_text = f"{center_text} {self._elapsed}" - elif self._elapsed: - center_text = self._elapsed - # Layout: left fixed → center fills remaining → right fixed, right-aligned + # Layout: left fixed → center fills remaining width = ctx.width - fixed_width = len(left_text) + len(right_text) - available_center = max(0, width - fixed_width) + available_center = max(0, width - len(left_text)) if center_text and available_center > 0: center_text = center_text.center(available_center) @@ -119,10 +129,6 @@ def render(self, ctx: RenderContext) -> list[Line]: spans.append(Span(text=left_text, style="bold magenta")) if center_text: spans.append(Span(text=center_text, style="dim")) - if right_text: - # Pad left side of right section to push it to the right edge - right_pad = max(0, width - len(left_text) - len(center_text) - len(right_text)) - spans.append(Span(text=f"{' ' * right_pad}{right_text}", style="dim")) self._dirty = False return [Line(spans=spans, style=ctx.style)] diff --git a/emrg/client/widgets.py b/emrg/client/widgets.py index 84e97bc3..f81a163c 100644 --- a/emrg/client/widgets.py +++ b/emrg/client/widgets.py @@ -10,7 +10,7 @@ from rich.style import Style from emrg.client.python_tui import ChatRow, ToolCard from emrg.client.python_tui.widgets.base import Line, Span, Widget -from emrg.client.python_tui.widgets.markdown import StreamingMarkdown +from emrg.client.python_tui.widgets.markdown import StreamingMarkdown, UserMarkdown class InputWidget(Widget): @@ -670,6 +670,11 @@ def dirty(self, v): self._dirty = v def add(self, role_or_widget, content=None): if isinstance(role_or_widget, Widget): self.rows.append(role_or_widget) + elif role_or_widget == "user": + # Plan B (rant 2026-08-18T18:52:45, superseding 18:50:14): user + # messages render as markdown (free width wrap, CJK handling) + # while keeping the "> " prefix + cyan role visual. + self.rows.append(UserMarkdown(content or "")) else: self.rows.append(ChatRow(role=role_or_widget, content=content or "")) self._line_cache.append(None) # 新 row 无缓存 @@ -693,6 +698,11 @@ def update_last(self, content): row.dirty = True self._dirty = True return + if isinstance(row, UserMarkdown): + row.text = content + row.dirty = True + self._dirty = True + return def last_tool_card(self): for row in reversed(self.rows): diff --git a/emrg/config.py b/emrg/config.py index 2ed6efb0..3460021d 100644 --- a/emrg/config.py +++ b/emrg/config.py @@ -37,9 +37,29 @@ class LlmConfig: stream_options: Optional[dict] = field(default_factory=lambda: {"include_usage": False}) +@dataclass +class UpdateConfig: + """Auto-upgrade settings (rant 2026-08-20T12:33:59 — 自动升级重构). + + enabled: master switch — when false, the daemon never checks for new + releases and never triggers an upgrade session. + delay_minutes: how long after a release is published before it becomes + eligible for upgrade (default 1440 = 1 day; the host can set 1 for + immediate). Granularity is minutes (host chose A). The check + interval is NOT configurable — hard-coded 5 minutes in upgrade.py. + Old fields check / ttl_hours / auto_download are removed (the + download-installer mechanism is fully replaced by the agent-driven + local equivalent install). + """ + + enabled: bool = True + delay_minutes: int = 1440 + + @dataclass class EmrgConfig: llm: LlmConfig = field(default_factory=LlmConfig) + update: UpdateConfig = field(default_factory=UpdateConfig) def config_dir() -> Path: @@ -92,7 +112,33 @@ def load_config() -> EmrgConfig: var_name = llm.api_key[2:-1] llm.api_key = os.environ.get(var_name, llm.api_key) - return EmrgConfig(llm=llm) + update_data = data.get("update", {}) + update = UpdateConfig( + enabled=update_data.get("enabled", True), + delay_minutes=update_data.get("delay_minutes", 1440), + ) + + return EmrgConfig(llm=llm, update=update) + + +def load_update_config() -> UpdateConfig: + """Load only the [update] section (rant 2026-08-10T07:12:12). + + The daemon constructs the UpgradeManager from this helper. Missing config + file or missing section → defaults (enabled=True, delay_minutes=1440). + """ + cfg_path = config_path() + if not cfg_path.exists(): + return UpdateConfig() + try: + data = tomllib.loads(cfg_path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError): + return UpdateConfig() + update_data = data.get("update", {}) + return UpdateConfig( + enabled=update_data.get("enabled", True), + delay_minutes=update_data.get("delay_minutes", 1440), + ) def ensure_config() -> None: diff --git a/emrg/connect.py b/emrg/connect.py index 2ff7d64e..4b6a52db 100644 --- a/emrg/connect.py +++ b/emrg/connect.py @@ -6,8 +6,12 @@ ws://127.0.0.1: (local, all platforms) wss://: (remote, Phase 5 — same protocol + TLS + token) -The daemon writes its dynamic port and auth token to ``~/.emrg/emrgd.port`` -(``port\\n token``, mode 0o600). Clients read that file, connect, and send +The daemon listens on the fixed port ``127.0.0.1:EMRGD_PORT`` (56031, rant +2026-08-19T08:05:21 — fixed-port bind exclusivity is the single-instance +admission) and writes its auth token to ``~/.emrg/emrgd.token`` +(single-line token, mode 0o600). Clients read that file for the token, and +connect to the fixed port. +Clients then send a first-frame auth message; the daemon confirms with ``auth_ok`` before the normal protocol loop. Auth failure raises :class:`AuthError` so callers can distinguish it from a transient disconnect (which should be retried). @@ -29,9 +33,16 @@ logger = logging.getLogger(__name__) # ── Connection identifier ─────────────────────────────────────── -# Port/token file lives at ~/.emrg/emrgd.port (port\n token, mode 0o600) +# Auth token file lives at ~/.emrg/emrgd.token (single-line token, 0o600) CONNECT_ID = "emrgd" +# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a FIXED +# loopback port so kernel-level bind exclusivity (EADDRINUSE) is the single- +# instance admission — no PID file to forge/delete, no race window. The +# token file only carries the auth token; the port itself is a constant. +# Keep in sync with emrg._stop_all._EMRGD_PORT (that module is pure stdlib). +EMRGD_PORT = 56031 + class AuthError(Exception): """Raised when the daemon rejects the auth handshake. @@ -42,16 +53,20 @@ class AuthError(Exception): def get_server_path() -> str: - """Return the path of the daemon port/token file.""" - return str(config_dir() / f"{CONNECT_ID}.port") + """Return the path of the daemon auth token file.""" + return str(config_dir() / f"{CONNECT_ID}.token") async def connect_to_server(): """Connect to the emrgd server over WebSocket. - Reads ``~/.emrg/emrgd.port``, connects to ``ws://127.0.0.1:``, - sends the first-frame auth message and waits for the ``auth_ok`` - confirmation. Returns the connected WebSocket object (single ws — no + Reads the auth token from ``~/.emrg/emrgd.token`` (single line, rant + 2026-08-20T14:32:52 — the file carries ONLY the token), connects to the + FIXED daemon port ``ws://127.0.0.1:`` (rant + 2026-08-19T08:05:21 — the port is a constant; the file only carries the + token), sends the first-frame auth message and waits for the + ``auth_ok`` confirmation. + Returns the connected WebSocket object (single ws — no ``(reader, writer)`` tuple anymore). Raises: @@ -59,9 +74,17 @@ async def connect_to_server(): ConnectionRefusedError / OSError / FileNotFoundError: daemon not running. """ port_path = Path(get_server_path()) - port, token = port_path.read_text(encoding="utf-8").split() + token = port_path.read_text(encoding="utf-8").strip() + # proxy=None: loopback connections must never go through a system proxy. + # websockets 17 defaults proxy=True and reads the OS proxy settings — when a + # Windows system proxy is enabled (e.g. 10.10.0.28:6501 for HN/Reddit access), + # the ws://127.0.0.1 handshake is sent to the proxy → InvalidMessage → all + # Python clients (TUI `emrg`, scheduler internal connections) cannot reach the + # local daemon, while the Node.js GUI is unaffected (2026-08-14 incident; root + # cause of continuous emrg-task/emrg-promote-task crashes since 2026-08-13). ws = await connect( - f"ws://127.0.0.1:{port}", + f"ws://127.0.0.1:{EMRGD_PORT}", + proxy=None, max_size=16 * 1024 * 1024, ) await ws.send(json.dumps({"type": "auth", "token": token})) @@ -79,28 +102,25 @@ async def connect_to_server(): def cleanup_server() -> None: - """Remove the daemon port/token file on shutdown.""" + """Remove the daemon auth token file on shutdown.""" port_path = Path(get_server_path()) if port_path.exists(): port_path.unlink() - logger.debug("removed port file: %s", port_path) + logger.debug("removed token file: %s", port_path) def is_server_running_sync(timeout: float = 2.0) -> bool: """Synchronous health-check probe (for client startup). - Uses blocking TCP connect to the port in ``emrgd.port``. Reads only the - first line (port), never the token — this is a low-cost liveness probe; - real auth happens on the first frame of a real connection. + Blocking TCP connect to the FIXED daemon port ``127.0.0.1:EMRGD_PORT`` + (rant 2026-08-19T08:05:21). No token-file read: the fixed port is the + ground truth, so a missing/stale ``emrgd.token`` never makes the probe + report "not running" while a daemon is actually alive (the dual-instance + root cause). Real auth happens on the first frame of a real connection. """ - port_path = Path(get_server_path()) - try: - port = int(port_path.read_text(encoding="utf-8").splitlines()[0]) - except (OSError, ValueError, IndexError): - return False sock = None try: - sock = _socket.create_connection(("127.0.0.1", port), timeout=timeout) + sock = _socket.create_connection(("127.0.0.1", EMRGD_PORT), timeout=timeout) return True except (ConnectionRefusedError, OSError): return False diff --git a/emrg/gui/conn-manager.js b/emrg/gui/conn-manager.js new file mode 100644 index 00000000..4bac0280 --- /dev/null +++ b/emrg/gui/conn-manager.js @@ -0,0 +1,247 @@ +// conn-manager.js — P2 of the GUI multi-session rant (2026-08-10T15:07:19) +// +// Connection manager = daemon lifecycle unique owner + one DaemonClient per +// open session (each session = one independent websocket connection, aligned +// with the TUI multi-open model). +// +// This slice completes the P2 wiring contract: +// - `ensureDaemon()` keeps a daemon-level connection (`_daemonConn`) used for +// non-session commands (ping / list_sessions / set_model / github_* / ...). +// Session connections only connect to the already-running daemon +// (ensureConnected({ skipStart: true }) — never spawn). +// - `open(sid, projectPath)` creates the per-session connection with +// per-connection delta batching (deltaBatchMs=16, #626) and resumes the +// session (auto-subscribe). `{ resume: false }` skips resume for brand-new +// sessions (daemon implicitly subscribes on first task message). +// - restart recovery: short-window all-drop → recoverAll (re-open + re-subscribe). +// - `onOpen` / `onRecovered` hooks let main.js attach the renderer event +// bridge (sid-tagged) and refresh UI state after recovery. +// +// Design notes (from the rant): +// - Each open session = one independent ws connection → natural isolation, no +// event routing. +// - resume_session(sid, cwd=projectPath) auto-subscribes the connection. +// - Already-open sid → reuse the existing connection (no duplicate). + +const { DaemonClient } = require("./daemon_client.js"); + +class ConnManager { + constructor({ logger = console, isPackaged = false, restartWindowMs = 1000, singleRetryDelayMs = 1000 } = {}) { + this.logger = logger; + this.isPackaged = isPackaged; + this._conns = new Map(); // sid -> { conn, projectPath } + this._daemonConn = null; // daemon 级连接(ping/list_sessions 等非会话命令) + this._openHooks = new Set(); // (sid, conn) => void(新会话连接建立时) + this._recoverHooks = new Set(); // () => void(recoverAll 完成后) + // daemon 重启恢复(rant 15:07:19 P2):短窗口内所有连接同时断 → 判定 daemon 重启 + // → 全部重连重订阅;单条断 → 独立退避重试(多会话场景,单会话全断走恢复)。 + this._restartWindowMs = restartWindowMs; + this._disconnects = new Map(); // sid -> timestamp(最近一次断连) + this._recovering = false; // 恢复中守卫(防 close→disconnect→recoverAll 递归) + this._singleRetryDelayMs = singleRetryDelayMs; + this._singleRetries = new Map(); // sid -> timer(单连接独立退避在途) + } + + // 确保 daemon 已运行(connManager = daemon 生命周期唯一 owner)。 + // 引导连接保留为 _daemonConn:供 ping/list_sessions 等非会话命令使用; + // 会话连接统一 skipStart(只连不拉)。已连接 → 直接复用。 + async ensureDaemon() { + if (this._daemonConn && this._daemonConn.connected) return this._daemonConn; + const boot = new DaemonClient({ + logger: this.logger, + isPackaged: this.isPackaged, + }); + try { + await boot.ensureConnected(); + } catch (e) { + boot.close(); + throw e; + } + this._daemonConn = boot; + return boot; + } + + // daemon 级连接访问器(main.js 非会话命令用;未建立返回 null) + daemonConn() { + return this._daemonConn || null; + } + + // open(sid, projectPath):创建 DaemonClient → ensureConnected(skipStart) → + // resume_session(自动订阅;resume:false 跳过,供新会话首条消息前用)。 + // 已打开的 sid 直接复用现有连接。失败时关闭连接不泄漏。 + async open(sid, projectPath, { resume = true } = {}) { + const existing = this._conns.get(sid); + if (existing) { + if (existing.conn.connected) return existing.conn; + this.close(sid); // 残留断连连接 → 关闭重开(_intentionalClose 标记抑制断线横幅) + } + await this.ensureDaemon(); + const conn = new DaemonClient({ + logger: this.logger, + isPackaged: this.isPackaged, + deltaBatchMs: 16, // P2:delta 批量(G122 16ms)每连接一份(#626) + }); + try { + await conn.ensureConnected({ skipStart: true }); // daemon 已就绪 → 只连不拉 + if (resume) { + await conn.sendCommandAndWait("resume_session", { session_id: sid, cwd: projectPath }, 5000); + } + } catch (e) { + conn.close(); // resume 失败(会话已删等)→ 不泄漏连接 + throw e; + } + // 断开监听 → 重启恢复判定(仅当所有打开会话在同一短窗口内断开) + conn.onEvent((type) => { + if (type === "disconnected") { + if (conn._intentionalClose) return; // 主动关闭(切走/删除)不参与重启判定 + this._disconnects.set(sid, Date.now()); + this._onDisconnect(sid); + } + }); + this._conns.set(sid, { conn, projectPath }); + for (const cb of this._openHooks) { + try { cb(sid, conn, projectPath); } catch (e) { this.logger.warn(`[gui] connManager onOpen hook error: ${e.message}`); } + } + return conn; + } + + // close(sid):conn.close(断开 ws)→ 移除。返回是否有关闭对象。 + // 标记 _intentionalClose:主动关闭(切走/删除)不触发 renderer 断线横幅 + // (桥检查该标记;真断连/daemon 重启的 disconnected 照常转发)。 + // P6(rant 15:07:19 边界):关闭在忙连接先 cancel 再 close——流式进行中 + // (ownStream)先发 cancel 让 daemon 停流,再断 ws,防半途断线留脏状态 + // (fire-and-forget:断连/ws 已 null 时忽略,不阻塞同步 close 语义)。 + close(sid) { + const entry = this._conns.get(sid); + if (!entry) return false; + this._cancelSingleRetry(sid); // 主动关闭 → 取消该会话的独立退避 + if (entry.conn.ownStream && entry.conn.ws) { + try { entry.conn.sendCommand("cancel"); } catch { /* 断连时忽略 */ } + } + entry.conn._intentionalClose = true; + entry.conn.close(); + this._conns.delete(sid); + this._disconnects.delete(sid); + return true; + } + + // get(sid):路由到对应实例;未打开返回 null。 + get(sid) { + const entry = this._conns.get(sid); + return entry ? entry.conn : null; + } + + all() { + return [...this._conns.keys()]; + } + + closeAll() { + for (const sid of [...this._conns.keys()]) this.close(sid); + if (this._daemonConn) { + this._daemonConn.close(); + this._daemonConn = null; + } + } + + // 新会话连接建立钩子(main.js 挂 renderer 事件桥;recoverAll 重开路径同样触发) + onOpen(callback) { + this._openHooks.add(callback); + } + + // recoverAll 完成钩子(main.js 刷新 UI 状态:status connected + sessions + pong) + onRecovered(callback) { + this._recoverHooks.add(callback); + } + + // ── daemon 重启恢复(rant 15:07:19 P2)────────────────────────────── + + // 所有当前打开会话都在重启窗口内断开 → 判定 daemon 重启。 + _restartDetected() { + const open = [...this._conns.keys()]; + if (open.length === 0) return false; + const now = Date.now(); + return open.every((sid) => { + const t = this._disconnects.get(sid); + return t !== undefined && now - t <= this._restartWindowMs; + }); + } + + _onDisconnect(sid) { + if (this._recovering) return; // 恢复中自己触发的断开不递归 + if (this._restartDetected()) { + this.logger.info( + `[gui] connManager: all ${this._conns.size} connection(s) dropped within ${this._restartWindowMs}ms — daemon restart detected, recovering` + ); + this.recoverAll().catch((e) => + this.logger.warn(`[gui] connManager recover failed: ${e.message}`) + ); + } else { + // 单条断(多会话场景,非重启)→ 独立退避重试 + this._scheduleSingleRetry(sid); + } + } + + // 单连接独立退避(rant 15:07:19 P2:单条断 → 独立退避重试)。 + // 退避到期后若该会话连接仍断 → open() 重开(stale → 关闭重开 + resume 重订阅)。 + _scheduleSingleRetry(sid) { + if (this._singleRetries.has(sid)) return; // 已有退避在途 + this.logger.info( + `[gui] connManager: session ${sid} dropped (not all) — independent backoff retry in ${this._singleRetryDelayMs}ms` + ); + const timer = setTimeout(() => { + this._singleRetries.delete(sid); + this._retrySingle(sid).catch((e) => + this.logger.warn(`[gui] connManager: session ${sid} retry failed: ${e.message}`) + ); + }, this._singleRetryDelayMs); + timer.unref?.(); + this._singleRetries.set(sid, timer); + } + + _cancelSingleRetry(sid) { + const t = this._singleRetries.get(sid); + if (t) { + clearTimeout(t); + this._singleRetries.delete(sid); + } + } + + async _retrySingle(sid) { + const entry = this._conns.get(sid); + if (!entry || entry.conn.connected) return; // 已重开/已主动关闭 + this.logger.info(`[gui] connManager retrying session ${sid}`); + await this.open(sid, entry.projectPath); // stale → close + reopen(含 resume 重订阅) + } + + // 全部重连重订阅(复用 open 序列:ensureDaemon → skipStart 会话连接 → + // resume_session)。单会话恢复失败跳过不阻塞其余(写盘/重试由后续片处理)。 + async recoverAll() { + if (this._recovering) return; + this._recovering = true; + const sessions = [...this._conns.entries()].map(([sid, entry]) => ({ + sid, + projectPath: entry.projectPath, + })); + try { + for (const { sid } of sessions) this.close(sid); + for (const t of this._singleRetries.values()) clearTimeout(t); + this._singleRetries.clear(); + this._disconnects.clear(); + for (const { sid, projectPath } of sessions) { + try { + await this.open(sid, projectPath); + this.logger.info(`[gui] connManager recovered session ${sid}`); + } catch (e) { + this.logger.warn(`[gui] connManager recover: session ${sid} reopen failed: ${e.message}`); + } + } + } finally { + this._recovering = false; + } + for (const cb of this._recoverHooks) { + try { cb(); } catch (e) { this.logger.warn(`[gui] connManager onRecovered hook error: ${e.message}`); } + } + } +} + +module.exports = { ConnManager }; diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index 7528221a..067ab661 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -3,7 +3,7 @@ * daemon_client.js — main 进程内唯一与 emrgd 通信的模块。 * * 协议语义完全对照 emrg/client/daemon_manager.py(Phase 2 参考实现): - * - 读 ~/.emrg/emrgd.port(port\n token,0o600)→ ws://127.0.0.1: → auth 首帧 → auth_ok + * - 读 ~/.emrg/emrgd.token(单行 token,0o600)→ ws://127.0.0.1:56031(EMRGD_PORT 常量)→ auth 首帧 → auth_ok * - 坏 JSON 帧忽略(对照 daemon_manager.recv R53) * - ConnectionClosed 传播 → 触发重连(对照 R11) * - auth 失败(auth_ok 前 close)= AuthError 语义(G88:停止自动重试,防无限重连) @@ -18,12 +18,26 @@ const crypto = require("crypto"); const { spawn } = require("child_process"); const WebSocket = require("ws"); -const PORT_FILE = () => path.join(os.homedir(), ".emrg", "emrgd.port"); +// Rant 2026-08-20T16:03:31:GUI"工作目录"概念已删除——daemon 运行时文件固定读取 +// 规范位置 ~/.emrg(daemon.py config_dir() = Path.home()/".emrg";connect.py 无条件 +// 读 ~/.emrg/emrgd.token)。projectDir 参数与 G129 回退逻辑随概念一起清理(#884 后 +// emrgd.token 已是唯一规范位置,回退冗余)。 +const TOKEN_FILE = () => path.join(os.homedir(), ".emrg", "emrgd.token"); +const EMRGD_LOG = () => path.join(os.homedir(), ".emrg", "emrgd.log"); +// Fixed daemon port (rant 2026-08-19T08:05:21 + 2026-08-20T14:32:52): the +// daemon always listens on this constant — keep in sync with emrg/connect.py +// EMRGD_PORT and emrg/_stop_all.py _EMRGD_PORT. The token file no longer +// carries a port; all connections/probes use this constant. +const EMRGD_PORT = 56031; const MAX_PAYLOAD = 16 * 1024 * 1024; // G62/G105:16MB 双向一致(工具输出上限 200KB) const AUTH_TIMEOUT_MS = 10_000; const SPAWN_WAIT_MS = 5_000; const PENDING_TIMEOUT_MS = 5_000; const STREAM_END_TIMEOUT_MS = 30_000; // G94:最后帧后 30s 无 done 强制结束 +// Rant 2026-08-09T13:16:36 ⑤(防风暴总闸):单个"连接生命周期"内最多 spawn +// MAX_SPAWN_ATTEMPTS 次 daemon——之后不再拉起,只把真实错误(含 emrgd.log 尾部) +// 抛给上层,杜绝 GUI 每 5s 反复 spawn(每次 spawn 都是一个新的 cmd 窗口来源)。 +const MAX_SPAWN_ATTEMPTS = 3; const SESSION_ID_RE = /^s_\d{6}_\d{4}_[0-9a-f]{4,8}$/; @@ -34,6 +48,7 @@ const RESPONSE_TYPES = { list_projects: "projects_list", list_history: "history_list", list_tasks: "tasks_list", + list_rants: "rants_list", list_memories: "memories_list", resume_session: "resume_result", delete_session: "session_deleted", @@ -48,11 +63,20 @@ const RESPONSE_TYPES = { github_connect: "github_connect_result", // Windows GCM rant Stage 2:PAT 授权(daemon.py github_connect) github_disconnect: "github_disconnect_result", // Windows GCM rant Stage 2:断开(daemon.py github_disconnect) github_connect_web: "github_connect_web_result", // Stage 2b:device flow(daemon.py github_connect_web) + list_files: "files_list", // 右栏工作区面板 P1:目录树(daemon.py list_files) + read_file: "file_content", // 右栏工作区面板 P1:文件查看器(daemon.py read_file) + // rant 2026-08-12T18:23:15 P2/P3:任务 + 自定义类型 CRUD(daemon.py task_create 等) + task_create: "task_result", + task_update: "task_result", + task_delete: "task_result", + task_template_list: "templates_list", + task_template_create: "template_result", + task_template_update: "template_result", + task_template_delete: "template_result", }; class DaemonClient { - constructor({ projectDir = os.homedir(), logger = console, authTimeoutMs = AUTH_TIMEOUT_MS, isPackaged = false } = {}) { - this.projectDir = projectDir; + constructor({ logger = console, authTimeoutMs = AUTH_TIMEOUT_MS, isPackaged = false, deltaBatchMs = 0 } = {}) { this.logger = logger; this._authTimeoutMs = authTimeoutMs; // G142 测试可注入短超时(默认 10s) this._isPackaged = isPackaged; // Phase 4:打包模式(rant #12 §4)由 main.js 注入 app.isPackaged @@ -67,32 +91,90 @@ class DaemonClient { this._authFailed = false; this._reconnectTimer = null; this._stopReconnect = false; + this._spawnAttempts = 0; // 连接生命周期内 spawn 计数(成功 auth 后归零) + // P2 connManager(rant 2026-08-10T15:07:19):deltaBuf 批量(G122 16ms)每连接一份。 + // deltaBatchMs > 0 时本实例自行批量 message_delta,终态(done/error/cancelled)前 + // 强制冲刷保序(rant 14:11 孤儿节点教训);默认 0 = 每帧即时发(既有行为不变)。 + this._deltaBatchMs = deltaBatchMs; + this._deltaBuf = []; + this._deltaTimer = null; + // P2 connManager(rant 2026-08-10T15:07:19):G65 自有流锁每连接一份。 + // 从 main.js 全局移入——多会话各自独立:本连接发出的 task 流运行中 → + // ownStream=true,切会话/关连接前必须释放。 + this.ownStream = false; + this.ownStreamRequestId = null; + } + + // 释放自有流锁(G65)。done(request 匹配或 timeout 兜底)、session busy 即发 + // 错误、cancelled(request 匹配)与断连时调用;sendTask 抛异常时由调用方清理。 + _releaseOwnStream() { + this.ownStream = false; + this.ownStreamRequestId = null; } // ── 生命周期 ──────────────────────────────────────────── + // Rant 2026-08-20T16:03:31:读 token 的权威入口——固定读 daemon 规范位置 + // ~/.emrg/emrgd.token(#884 后唯一位置)。文件仅含单行 token;端口一律用 + // EMRGD_PORT 常量。返回 {token, source, port} 或 null。 + _readPortToken() { + const tryRead = (file) => { + try { + const text = fs.readFileSync(file, "utf8"); + const token = text.trim(); + if (token) return { token }; + } catch { /* missing/unreadable → try next */ } + return null; + }; + const token = tryRead(TOKEN_FILE()); + if (token) return { ...token, source: "canonical", port: EMRGD_PORT }; + return null; + } + isRunning(timeoutMs = 1500) { - // G43/G90:TCP 探测(不可简化为 port 文件存在) - try { - const port = Number(fs.readFileSync(PORT_FILE(), "utf8").split("\n")[0]); - return new Promise((resolve) => { - const sock = net.connect({ host: "127.0.0.1", port, timeout: timeoutMs }); - sock.once("connect", () => { sock.destroy(); resolve(true); }); - sock.once("error", () => { sock.destroy(); resolve(false); }); - sock.once("timeout", () => { sock.destroy(); resolve(false); }); - }); - } catch { - return Promise.resolve(false); + // G43/G90:TCP 探测(不可简化为 token 文件存在)。18:47:37:token 源改为权威读取 + // (projectDir 回退 ~/.emrg),否则 projectDir≠home 时永远探测假路径 → 假 false。 + // 14:32:52:端口用常量 EMRGD_PORT,不再从文件读 port。 + const pt = this._readPortToken(); + if (!pt) return Promise.resolve(false); + return new Promise((resolve) => { + const sock = net.connect({ host: "127.0.0.1", port: EMRGD_PORT, timeout: timeoutMs }); + sock.once("connect", () => { sock.destroy(); resolve(true); }); + sock.once("error", () => { sock.destroy(); resolve(false); }); + sock.once("timeout", () => { sock.destroy(); resolve(false); }); + }); + } + + _readLogTail(lines = 15) { + // R124 对应(daemon_manager.py):spawn 超时后读 emrgd.log 尾部, + // 让宿主看到真实失败原因(缺 DLL / PATH / 端口冲突),而不是干巴巴的 + // "failed to start within timeout"(rant 2026-08-09T13:16:36 验收项 ②)。 + // 18:47:37:log 在规范 ~/.emrg 下;16:03:31 后固定读该位置。 + for (const file of [EMRGD_LOG()]) { + try { + const data = fs.readFileSync(file, "utf8"); + const tail = data.trim().split("\n").slice(-lines).join("\n"); + return tail ? `\n emrgd.log tail (${file}):\n${tail}` : ""; + } catch { /* try next */ } } + return ""; } async startDaemon() { + // Rant 2026-08-09T13:16:36 ⑤:spawn 节流——超过上限不再拉起(防窗口/重试风暴)。 + if (this._spawnAttempts >= MAX_SPAWN_ATTEMPTS) { + throw new Error( + `daemon failed to start after ${MAX_SPAWN_ATTEMPTS} attempts — ` + + `please start it manually ('emrg server') and check emrgd.log${this._readLogTail()}` + ); + } + this._spawnAttempts += 1; // Phase 4(rant #12 §4):打包模式直接 spawn 捆绑 emrgd 可执行文件(脚本内部 // exec python -m emrg.server);源码模式保持 python -m emrg.server。 if (this._isPackaged) { const emrgdPath = this._findDaemonExecutable(); const opts = { - cwd: this.projectDir, + cwd: os.homedir(), stdio: "ignore", detached: true, }; @@ -102,35 +184,54 @@ class DaemonClient { opts.shell = true; opts.windowsHide = true; } - this.logger.info(`[gui] spawning packaged daemon: ${emrgdPath} cwd=${this.projectDir}`); + this.logger.info(`[gui] spawning packaged daemon: ${emrgdPath} cwd=${os.homedir()}`); const child = spawn(emrgdPath, [], opts); child.unref(); this._daemonChild = child; + this.logger.info(`[gui] daemon spawned: pid=${child.pid} (packaged emrgd)`); // 18:47:37 B2 const deadline = Date.now() + SPAWN_WAIT_MS; while (Date.now() < deadline) { if (await this.isRunning(500)) return child; await new Promise((r) => setTimeout(r, 300)); } - throw new Error("emrgd failed to start within timeout"); + throw new Error(`emrgd failed to start within timeout${this._readLogTail()}`); } // G125:spawn 设 cwd=project_dir(daemon load_skills 用 Path.cwd() 加载项目级 skills) const python = this._findPython(); const args = ["-m", "emrg.server"]; - this.logger.info(`[gui] spawning daemon: ${python} ${args.join(" ")} cwd=${this.projectDir}`); + this.logger.info(`[gui] spawning daemon: ${python} ${args.join(" ")} cwd=${os.homedir()}`); const child = spawn(python, args, { - cwd: this.projectDir, + cwd: os.homedir(), stdio: "ignore", // G68:对照 DEVNULL detached: true, // 对照 start_new_session=True + // windowsHide: python.exe 是 console 子系统——GUI spawn 时不隐藏会 + // 弹一个命令行黑窗(打包模式 emrgd.cmd 已改走 pythonw.exe,这里补源码模式)。 + ...(process.platform === "win32" ? { windowsHide: true } : {}), }); child.unref(); // GUI 退出不带走 daemon this._daemonChild = child; // 暴露 child(集成测试 after 清理用) + this.logger.info(`[gui] daemon spawned: pid=${child.pid} (source mode)`); // 18:47:37 B2 // 等最多 SPAWN_WAIT_MS 就绪 const deadline = Date.now() + SPAWN_WAIT_MS; while (Date.now() < deadline) { if (await this.isRunning(500)) return child; await new Promise((r) => setTimeout(r, 300)); } - throw new Error("emrgd failed to start within timeout"); + throw new Error(`emrgd failed to start within timeout${this._readLogTail()}`); + } + + // Rant 2026-08-21T15:26:42:daemon 存活判断用固定端口 TCP 探测—— + // 固定端口才是 ground truth(rant 2026-08-19T08:05:21,connect.py + // is_server_running_sync 同语义;rant 2026-08-21T16:45:06 后 emrgd.pid 已彻底 + // 移除)。端口通 = daemon 活着 = 绝不删 token;端口不通才允许 stale-token + // 删除+重拉路径。 + _daemonProcessAlive(timeoutMs = 1000) { + return new Promise((resolve) => { + const sock = net.connect({ host: "127.0.0.1", port: EMRGD_PORT, timeout: timeoutMs }); + sock.once("connect", () => { sock.destroy(); resolve(true); }); + sock.once("error", () => { sock.destroy(); resolve(false); }); + sock.once("timeout", () => { sock.destroy(); resolve(false); }); + }); } _findDaemonExecutable() { @@ -141,6 +242,30 @@ class DaemonClient { return path.join(bin, name); } + // Rant 2026-08-21T12:44:34(restart-to-apply 跟进):打包模式下 `emrg` 包位于 + // ~/.emrg/install/{source,lib},宿主 PATH 的 python3 上没有 `emrg` 可导入—— + // 重启流程必须用安装版运行时 python(与 bin/emrgd 启动器同一解析逻辑)。 + _findInstalledPython() { + const bin = path.join(os.homedir(), ".emrg", "install", "bin"); + if (process.platform === "win32") { + // R100:bin/python(复制品)缺 DLL 不可用(DLL 在 python-dist/ 根) + for (const n of ["python-dist\\python.exe", "python-dist\\python3.13.exe", "python.exe"]) { + const p = path.join(bin, n); + try { fs.accessSync(p); return p; } catch { /* next candidate */ } + } + return "python"; + } + const p = path.join(bin, "python"); + try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* fallthrough */ } + return "python3"; + } + + // 安装版 PYTHONPATH 前缀(等价 bin/emrgd 的 PYTHONPATH="$PREFIX/source:$PREFIX/lib")。 + _installedPythonPath() { + const prefix = path.join(os.homedir(), ".emrg", "install"); + return [path.join(prefix, "source"), path.join(prefix, "lib")].join(path.delimiter); + } + _findPython() { // G59/G61/G126:优先项目 .venv,其次 PATH python3/python const root = path.resolve(__dirname, "..", ".."); @@ -159,22 +284,83 @@ class DaemonClient { return "python3"; } - async ensureConnected() { - // 1. 读 port 文件 → 无则拉 daemon - let port, token; + // Rant 2026-08-09T18:47:37(A1 + B1):探测"已存在的 daemon"——4 状态诊断日志 + // (token_file_exists / token_file_content / daemon_alive(ping) / spawn_result)。 + // spawn 失败 ≠ daemon 不存在:daemon 可能早已被 scheduler/TUI 拉起。 + // 返回 {token, source, port} 或 null。 + async _probeExistingDaemon(spawnResult = "n/a") { + const pt = this._readPortToken(); + const tokenFileExists = !!(pt || this._readPortTokenRaw()); + const alive = pt ? await this.isRunning(1000) : false; + this.logger.info( + `[gui] probe: token_file_exists=${tokenFileExists}, token_file_content=${pt ? "present" : "—"}, ` + + `daemon_alive(ping)=${alive}, spawn_result=${spawnResult}` + ); + if (pt && alive) return pt; + return null; + } + + // 读 token 文件原始存在性(不含解析),供 probe 日志用。 + _readPortTokenRaw() { + for (const file of [TOKEN_FILE()]) { + try { if (fs.readFileSync(file, "utf8").trim()) return true; } catch { /* next */ } + } + return false; + } + + // Rant 2026-08-09T18:47:37(A1):spawn 失败(含 3 次节流)→ 探测已有 daemon → + // 活着直接复用;确实无 daemon 才抛原始错误。spawn 成功则读回 token。 + async _spawnOrProbe() { try { - const text = fs.readFileSync(PORT_FILE(), "utf8"); - [port, token] = text.split(/\s+/); - if (!port || !token) throw new Error("malformed port file"); - } catch { await this.startDaemon(); - const text = fs.readFileSync(PORT_FILE(), "utf8"); - [port, token] = text.split(/\s+/); + } catch (spawnErr) { + const existing = await this._probeExistingDaemon(`failed(${String(spawnErr.message).slice(0, 60)})`); + if (existing) { + this.logger.warn( + `[gui] spawn failed (${spawnErr.message}) — existing daemon detected at port=${EMRGD_PORT}, reusing` + ); + return existing; + } + this.logger.warn(`[gui] spawn failed (${spawnErr.message}) — no existing daemon reachable, giving up`); + throw spawnErr; + } + // spawn 成功:daemon 永远写规范 ~/.emrg/emrgd.token(daemon.py config_dir()), + // 权威读取固定该位置(16:03:31)。 + const pt = this._readPortToken(); + if (!pt) throw new Error("token file not written after spawn"); + this.logger.info(`[gui] daemon spawned ok: port=${EMRGD_PORT}`); + return pt; + } + + async ensureConnected({ skipStart = false } = {}) { + // Rant 2026-08-09T18:47:37:1. 读 token 文件(固定规范 ~/.emrg)→ + // 无则拉 daemon;spawn 失败先探测已有 daemon,活着直接复用,不再盲报 + // "failed to start after 3 attempts"。每步打结构化诊断日志(B1-B5)。 + // P2 connManager(rant 2026-08-10T15:07:19):skipStart=true 时 daemon 生命周期 + // 由 connManager 独占管理——本实例只连接**已运行**的 daemon,绝不自行拉起。 + let port, token; + const pt = this._readPortToken(); + if (pt) { + port = pt.port; + token = pt.token; + this.logger.info( + `[gui] ensureConnected: token_file_exists=true, port=${port}, source=${pt.source}` + ); + } else { + if (skipStart) { + throw new Error( + `daemon not running (skipStart): no token file at ${TOKEN_FILE()}` + ); + } + this.logger.info(`[gui] ensureConnected: token_file_exists=false — spawning daemon`); + const r = await this._spawnOrProbe(); + port = r.port; + token = r.token; } - // 2. ws 连接(G43 stale port:连接失败删文件重拉一次) + // 2. ws 连接(G43 stale token:连接失败删文件重拉一次) try { - this.ws = new WebSocket(`ws://127.0.0.1:${port}`, { maxPayload: MAX_PAYLOAD }); + this.ws = new WebSocket(`ws://127.0.0.1:${EMRGD_PORT}`, { maxPayload: MAX_PAYLOAD }); } catch (e) { // ws 构造一般异步失败;在 open 事件处理 throw e; @@ -182,14 +368,33 @@ class DaemonClient { try { await this._awaitOpen(); } catch (e) { - // G43:port 文件存在但连不上(daemon 已死/端口被占)→ 删文件重拉一次 - this.logger.warn(`[gui] ws connect failed: ${e.message} — stale port, respawning daemon`); + // G43 加固(rant 2026-08-09T13:16:36 根因):token 文件存在但连不上时, + // 先探测固定端口(rant 2026-08-21T15:26:42:TCP 探活;rant 16:45:06 后 + // emrgd.pid 已彻底移除)——daemon 还活着就【绝不删 token 文件】。旧 G43 + // 直接 unlink 会把健康 daemon 的 token 文件删掉 → 僵尸态(daemon 活着、 + // scheduler 永远 cannot connect、PID 锁挡住新 spawn)。只有 daemon 真死 + // 了才删+重拉。 + if (await this._daemonProcessAlive()) { + this.logger.warn( + `[gui] ws connect failed: ${e.message} — daemon port alive, keeping token file (transient)` + ); + try { this.ws.close(); } catch { /* ignore */ } + throw new Error(`daemon unreachable (port alive): ${e.message}`); + } + if (skipStart) { + this.logger.warn( + `[gui] ws connect failed: ${e.message} — stale token, daemon dead (skipStart: not respawning)` + ); + try { this.ws.close(); } catch { /* ignore */ } + throw new Error(`daemon unreachable (skipStart): ${e.message}`); + } + this.logger.warn(`[gui] ws connect failed: ${e.message} — stale token, respawning daemon`); try { this.ws.close(); } catch { /* ignore */ } - try { fs.unlinkSync(PORT_FILE()); } catch { /* ignore */ } - await this.startDaemon(); - const text = fs.readFileSync(PORT_FILE(), "utf8"); - [port, token] = text.split(/\s+/); - this.ws = new WebSocket(`ws://127.0.0.1:${port}`, { maxPayload: MAX_PAYLOAD }); + try { fs.unlinkSync(TOKEN_FILE()); } catch { /* ignore */ } + const r = await this._spawnOrProbe(); + port = r.port; + token = r.token; + this.ws = new WebSocket(`ws://127.0.0.1:${EMRGD_PORT}`, { maxPayload: MAX_PAYLOAD }); await this._awaitOpen(); } @@ -232,6 +437,11 @@ class DaemonClient { this.connected = true; this._authFailed = false; + this._spawnAttempts = 0; // 连接生命周期成功 → 重置 spawn 节流计数 + // Rant 2026-08-09T18:47:37 B5:最终状态一行自证——GUI 连的是谁、连没连上。 + this.logger.info( + `[gui] ensureConnected result=connected, daemon_running=true, port=${port}, token_set=${!!token}` + ); // 5. 注册 message/close 监听 → 事件流分发 this.ws.on("message", (data) => this._onFrame(data)); @@ -254,6 +464,7 @@ class DaemonClient { } close() { + this._flushDeltaBuf(); // 断连前冲刷残留 delta(防丢失) if (this.ws) { try { this.ws.close(); } catch { /* ignore */ } this.ws = null; @@ -261,6 +472,20 @@ class DaemonClient { this.connected = false; } + // P2(rant 2026-08-10T15:07:19 + 14:11):批量冲刷 delta 缓冲。 + // 有定时器则清;有残留则按 {chunks} 形状一次性发出(与 main.js G122 同形)。 + _flushDeltaBuf() { + if (this._deltaTimer) { + clearTimeout(this._deltaTimer); + this._deltaTimer = null; + } + if (this._deltaBuf.length) { + const chunks = this._deltaBuf; + this._deltaBuf = []; + this._emit("message_delta", { chunks }); + } + } + // ── 事件 ──────────────────────────────────────────────── onEvent(callback) { @@ -276,11 +501,12 @@ class DaemonClient { // ── 消息发送 ──────────────────────────────────────────── - sendTask({ sessionId, cwd, prompt, stream = true, images = null, requestId = null, mode = "auto" }) { + sendTask({ sessionId, cwd, prompt, images = null, requestId = null, sandbox = "workspace-write" }) { // G32:request_id 必须作为 id 字段发出(daemon 只回显不自生成) - // G96:stream 必须显式 true(daemon 读 stream 默认 False) // G143:外部预生成 requestId 优先(renderer send 前标记自有流,消除 IPC 往返竞态窗口) - // WorkBuddy P2:mode="ask" → daemon 不启用工具(纯对话) + // Rant 2026-08-20T18:18:sandbox 档位(read-only / workspace-write / danger-full-access), + // 随每条 task 消息发送——daemon 用它控制该次工具执行的写权限(默认可写工作区)。 + // rant 21:20:38:非 stream 路径已删除——所有 task 恒走 tool_loop(流式) const rid = requestId || crypto.randomUUID(); const payload = { type: "task", @@ -289,17 +515,22 @@ class DaemonClient { cwd, prompt, timestamp: new Date().toISOString(), - stream, images, + sandbox, }; - if (mode && mode !== "auto") payload.mode = mode; this._setCurrentStream(rid); + // G65:自有流锁——本连接发出流式 task 即标记,done/error/cancelled/断连释放 + // (多会话各自独立;main.js emrg:sendMessage 的 G65 切会话检查读本字段) + this.ownStream = true; + this.ownStreamRequestId = rid; this.ws.send(JSON.stringify(payload)); return rid; } sendCommand(type, params = {}) { - this.ws.send(JSON.stringify({ type, ...params })); + // Wire message type last: a payload field named "type" (e.g. the task type in + // task CRUD) must never override the wire message type (rant 2026-08-14T21:48:00). + this.ws.send(JSON.stringify({ ...params, type })); } // G93/G103:命令-响应配对(pending FIFO,按响应帧 type 配对)。 @@ -380,10 +611,13 @@ class DaemonClient { return; } if (frame.type === "cancelled") { + this._flushDeltaBuf(); // 终态前冲刷(rant 14:11 同源:delta 不晚于终态) + // 自有流取消 → 释放 G65 锁(带 request_id 的 cancelled 明确是本流的终态) + if (frame.request_id === this.ownStreamRequestId) this._releaseOwnStream(); this._emit("cancelled", frame); return; } - if (["sessions_list", "models_list", "history_list", "tasks_list"].includes(frame.type)) { + if (["sessions_list", "models_list", "history_list", "tasks_list", "files_list"].includes(frame.type)) { this._emit("list_result", frame); return; } @@ -392,16 +626,28 @@ class DaemonClient { return; } if (frame.done) { + this._flushDeltaBuf(); // 终态前冲刷 delta:保证 delta 不晚于终态(rant 14:11) this._onDone(frame); this._emit("done", frame); return; } if (frame.delta) { this._onDelta(frame); + if (this._deltaBatchMs > 0) { + this._deltaBuf.push(frame); + if (!this._deltaTimer) { + this._deltaTimer = setTimeout(() => this._flushDeltaBuf(), this._deltaBatchMs); + } + return; // 批量模式:不即时发单帧 + } this._emit("message_delta", frame); return; } if (frame.error) { + this._flushDeltaBuf(); // 终态前冲刷(rant 14:11 同源) + // session busy 是即发错误(daemon 返回后无 done 跟随)——释放自有流锁,防 G65 锁泄漏 + // (流式错误如 LLM error 则有 done 跟随,由 done 分支释放,不在此处理) + if (frame.error && String(frame.error).includes("session busy")) this._releaseOwnStream(); this._emit("error", frame); return; } @@ -436,6 +682,10 @@ class DaemonClient { if (rid && this._currentStream && this._currentStream.requestId === rid) { this.clearActiveStream(); } + // G65:仅自有流的 done 释放锁(广播 done 不影响);timeout 兜底同样只清自有 + if (rid === this.ownStreamRequestId || (frame.timeout && this.ownStream)) { + this._releaseOwnStream(); + } // G83:done 清理分组缓存(DOM 保留) if (rid) this._cleanupGroup(rid, true); } @@ -509,6 +759,7 @@ class DaemonClient { // G41/G89/G97:断连处理 _onClose() { this.connected = false; + this._releaseOwnStream(); // 断连即释放 G65 自有流锁(防锁泄漏) this._rejectAllPending("connection closed"); this.clearActiveStream(); // G94 timer 清理:断连后 30s 超时 timer 不应再触发(防虚假"响应超时"提示) this.clearGroups(); // G97:断连清空广播分组缓存(含 10 分钟 timer),防"幽灵"分组残留 @@ -535,4 +786,4 @@ function generateSessionId(seed) { return sid; } -module.exports = { DaemonClient, generateSessionId, PORT_FILE, SESSION_ID_RE, MAX_PAYLOAD }; +module.exports = { DaemonClient, generateSessionId, TOKEN_FILE, SESSION_ID_RE, MAX_PAYLOAD, EMRGD_PORT }; diff --git a/emrg/gui/gui-state.js b/emrg/gui/gui-state.js new file mode 100644 index 00000000..0b270371 --- /dev/null +++ b/emrg/gui/gui-state.js @@ -0,0 +1,46 @@ +// gui-state.js — P4 of the GUI multi-session rant (2026-08-10T15:07:19) +// +// gui_state.json persistence: { openSessions: [{sid, projectName, projectPath, +// lastActive}], activeSid }. +// +// This slice (P4 slice 1) covers the WRITE path (main.js keeps open sessions +// alive across switches, persists them, and exposes close/get IPC). The READ +// path (restore on boot + sidebar UI) lands in P4 slice 2. +// +// Design notes (from the rant): +// - Open sessions = cross-project tabs (aligned with the TUI multi-open model). +// - Cap 20: never persist more than the cap; restore (slice 2) takes the most +// recent 20 by lastActive. +// - Write is atomic (.tmp + rename) so a crash mid-write never corrupts state. +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const DEFAULT_CAP = 20; + +function guiStatePath(homeDir) { + return path.join(homeDir, ".emrg", "gui_state.json"); +} + +// 清洗 openSessions:跳过失效条目(缺 sid/projectPath),按 lastActive 倒序, +// 截断到 cap(默认 20)。写盘前与恢复(slice 2)共用同一规则。 +function sanitizeOpenSessions(list, cap = DEFAULT_CAP) { + const valid = (list || []).filter( + (s) => s && typeof s.sid === "string" && s.sid && typeof s.projectPath === "string" && s.projectPath + ); + valid.sort((a, b) => String(b.lastActive || "").localeCompare(String(a.lastActive || ""))); + return valid.slice(0, cap); +} + +// 原子写:tmp + rename(镜像 install-info.json 原子写 #569 模式)。 +// 目录缺失 → 创建。写失败 → 抛(调用方捕获并记录,不阻断主流程)。 +function saveGuiState(homeDir, state) { + const p = guiStatePath(homeDir); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const tmp = p + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8"); + fs.renameSync(tmp, p); +} + +module.exports = { guiStatePath, sanitizeOpenSessions, saveGuiState, DEFAULT_CAP }; diff --git a/emrg/gui/main.js b/emrg/gui/main.js index d6fb7c7e..c0766158 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -5,13 +5,16 @@ * 安全:contextIsolation + nodeIntegration:false + sandbox:true(renderer 零网络权限)。 */ -const { app, BrowserWindow, dialog, ipcMain, shell } = require("electron"); +const { app, BrowserWindow, dialog, ipcMain, shell, WebContentsView } = require("electron"); const fs = require("fs"); const os = require("os"); const path = require("path"); +const { pathToFileURL } = require("url"); const { spawn } = require("child_process"); const { parse: parseToml, stringify: stringifyToml } = require("smol-toml"); -const { DaemonClient, generateSessionId, SESSION_ID_RE, PORT_FILE } = require("./daemon_client"); +const { generateSessionId, SESSION_ID_RE } = require("./daemon_client"); +const { ConnManager } = require("./conn-manager"); +const { guiStatePath, sanitizeOpenSessions, saveGuiState, DEFAULT_CAP } = require("./gui-state"); const APP_VERSION = require("./package.json").version; // ── 单实例锁(G85/G120:第二个实例退出并 focus 已有窗口)── @@ -24,21 +27,57 @@ if (!app.requestSingleInstanceLock()) { function main() { const logger = createLogger(); let win = null; - let client = null; - let projectDir = os.homedir(); + let connManager = null; // P2(rant 15:07:19):连接管理器 = daemon 生命周期唯一 owner + // Rant 2026-08-20T16:03:31:GUI"工作目录"概念已删除——无项目上下文时的兜底 cwd 固定为 home。 + const DEFAULT_CWD = os.homedir(); let configExists = false; let currentSessionId = null; - let ownStream = false; // 自有流运行中(G65:禁止切会话) - let ownStreamRequestId = null; // 自有流 request_id(广播 done 不清锁) let reconnectTimer = null; + // Rant 2026-08-09T13:16:36 ③/⑤:重连指数退避(1s→2s→4s→…封顶 60s)。 + // 之前固定 1s——daemon 缺失时每 5s 一轮 spawn,弹窗/日志风暴。成功连接后复位。 + let reconnectDelayMs = 1000; + const MAX_RECONNECT_DELAY_MS = 60_000; + // Rant 2026-08-09T13:16:36 ⑤:daemon_stopped 提示每个连接生命周期只发一次—— + // 否则退避封顶 60s 后每轮重试都命中节流、渲染层每分钟追加一条重复系统消息 + // (对称 TUI app.py _throttle_warned,PR #594)。成功连接后复位。 + let daemonStoppedNotified = false; let stopping = false; + // Rant 2026-08-21T12:44:34:定时探活心跳——daemon 级 disconnected 事件此前无人 + // 监听(grep 证实),断连只能靠 ws close 被动通知;心跳每 15s ping 一次, + // 无 pong 或连接已断 → 主动 scheduleReconnect(指数退避已有)。 + let heartbeatTimer = null; + const HEARTBEAT_MS = 15_000; + // P4(rant 15:07:19):跨项目打开的会话状态——sid → {projectName, projectPath, + // lastActive}。写盘防抖 1s(打开/关闭/切换时更新,镜像 rant 设计)。 + let openSessions = new Map(); + let guiStateTimer = null; + const GUI_STATE_DEBOUNCE_MS = 1000; + // P2.3(rant 12:20:35):HTML 预览 WebContentsView——懒创建、单实例复用、右对齐 bounds 同步。 + // renderer 崩溃 reload 后由 renderer 侧拉取(emrg:getPreviewState)恢复(main 是真相源)。 + let previewView = null; // WebContentsView 实例(首次打开 HTML tab 才创建) + let previewAdded = false; // 已 addChildView(防重复添加) + let previewPath = null; // 当前加载的预览文件绝对路径(null = 无) + let previewVisible = false; // 当前是否显示(激活 HTML tab) + let previewLayout = { width: 280, collapsed: false, contentTop: 0 }; // renderer 上报的面板布局 // ── 窗口 ──────────────────────────────────────────────── + // rant 2026-08-11T17:37:03:打包版曾用 Electron 默认图标(蓝色原子球)。 + // package.json build.mac/win/linux.icon 显式指向 packaging/assets 单文件修复主图标; + // 这里为 Windows/Linux 窗口标题栏提供运行时图标(打包版经 extraResources 落到 resources/icon.png)。 + function windowIconPath() { + const candidates = [ + path.join(__dirname, "..", "icon.png"), // packaged: resources/icon.png(extraResources) + path.join(__dirname, "..", "packaging", "assets", "icon.png"), // source: 仓库 packaging/assets/icon.png + ]; + return candidates.find((p) => fs.existsSync(p)) || undefined; + } + function createWindow() { win = new BrowserWindow({ width: 1000, height: 700, + icon: windowIconPath(), webPreferences: { contextIsolation: true, nodeIntegration: false, @@ -48,12 +87,17 @@ function main() { }, }); win.loadFile(path.join(__dirname, "renderer", "index.html")); + // P2.3:窗口尺寸变化 → 预览 bounds 跟随(右对齐矩形) + win.on("resize", () => updatePreviewBounds()); // G87:窗口状态持久化 restoreWindowBounds(win); win.on("close", () => saveWindowBounds(win)); // G101:renderer 崩溃恢复 win.webContents.on("render-process-gone", () => { logger.warn("[gui] renderer gone — reloading"); + // P2.3:预览 view 是独立 WebContents,崩溃不影响它;reload 完成后 renderer + // 经 emrg:getPreviewState 拉取当前预览路径重新开 Tab(恢复 bounds/loadURL 一致, + // 防预览与 Tab 栏不匹配,R5-④) win.loadFile(path.join(__dirname, "renderer", "index.html")); }); win.webContents.on("unresponsive", () => { @@ -151,10 +195,11 @@ vision = false resolve(fs.readFileSync(configPath(), "utf8")); return; } - const python = client?._findPython() || "python3"; + const python = connManager?.daemonConn()?._findPython() || "python3"; const child = spawn(python, ["-c", "from emrg.config import ensure_config; ensure_config()"], { - cwd: projectDir, + cwd: DEFAULT_CWD, stdio: "ignore", + ...(process.platform === "win32" ? { windowsHide: true } : {}), }); child.on("exit", () => { resolve(fs.existsSync(configPath()) ? fs.readFileSync(configPath(), "utf8") : ""); @@ -193,7 +238,7 @@ vision = false function validateConfig(c) { // 设计 §7.1:直接接收所需字段 + 基本类型检查(防写坏 config.toml 的健壮性,非安全设计) const out = {}; - for (const k of ["apiKey", "baseUrl", "model", "projectDir", "theme"]) { + for (const k of ["apiKey", "baseUrl", "model", "theme"]) { if (c[k] !== undefined) out[k] = typeof c[k] === "string" ? c[k] : String(c[k]); } if (Array.isArray(c.models)) { @@ -218,91 +263,150 @@ vision = false // G34/G71/G112:config 存在性 → ensureConnected → ping → list_sessions configExists = fs.existsSync(configPath()); const cfg = readConfig(); - projectDir = cfg.gui?.project_dir || os.homedir(); - // G121:校验 project_dir 存在可写 - let projectDirValid = true; - try { - fs.accessSync(projectDir, fs.constants.W_OK); - } catch { projectDirValid = false; } if (!configExists) { // config 缺失 → 不拉起 daemon(daemon 启动即崩),直接返回缺配置 - return { config_exists: false, api_key_configured: false, project_dir: projectDir, project_dir_valid: projectDirValid, server_id: "", model: "", version: APP_VERSION }; + return { config_exists: false, api_key_configured: false, server_id: "", model: "", version: APP_VERSION }; } const keyConfigured = isKeyConfigured(cfg.llm?.api_key); if (!keyConfigured) { - return { config_exists: true, api_key_configured: false, project_dir: projectDir, project_dir_valid: projectDirValid, server_id: "", model: "", version: APP_VERSION }; + return { config_exists: true, api_key_configured: false, server_id: "", model: "", version: APP_VERSION }; } await ensureConnected(); const pong = await waitForPong(); const sessions = await listSessions(); + // P4 slice 2:启动恢复打开会话(gui_state.json → 重开连接 + resume 重订阅) + await restoreOpenSessions(sessions); return { config_exists: true, api_key_configured: true, - project_dir: projectDir, - project_dir_valid: projectDirValid, server_id: pong?.identity?.instance_id || "", model: pong?.model || "", evolution_count: pong?.evolution_count ?? 0, // G19:init 透传演化计数(waitForPong 已消耗 pong) + current_version: pong?.current_version || "", // rant 18:30:57:进程实际运行版本(升级横幅对比基准;14:38:27 起为内存版本) version: APP_VERSION, // WorkBuddy P3:版本号随 package.json 走(此前 renderer 硬编码 v0.2.7) sessions, + open_sessions: openSessionsList(), + active_sid: currentSessionId, // P4 slice 2:恢复后的激活会话(renderer 直接采用) }; }); - ipcMain.handle("emrg:sendMessage", async (_e, { sessionId, text, requestId, mode }) => { + ipcMain.handle("emrg:sendMessage", async (_e, { sessionId, text, requestId, sandbox }) => { if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); if (!validateText(text)) throw new Error("invalid text"); if (requestId !== undefined && (typeof requestId !== "string" || requestId.length < 8 || requestId.length > 64)) { throw new Error("invalid request_id"); // G143:renderer 预生成 id 的格式护栏 } - if (!client || !client.connected) throw new Error("daemon not connected"); - ownStream = true; + // P2:每会话独立连接——首条消息前自动打开(新会话不 resume,daemon 隐式订阅) + // P5 slice 2:cwd 取该会话所属项目(跨项目会话用其项目路径,非全局 projectDir) + const sessionCwd = openSessions.get(sessionId)?.projectPath || DEFAULT_CWD; + let conn = connManager?.get(sessionId); + if (!conn || !conn.connected) { + conn = await openSession(sessionId, sessionCwd, { resume: false }); + } + markSessionActive(sessionId); // P4:发送 = 会话活动 → lastActive + 持久化 let rid; try { // G143:renderer 预生成 requestId(send 前标记自有流,消除 IPC 往返竞态窗口) - // WorkBuddy P2:mode="ask" → 纯对话(daemon 不启用工具) - rid = client.sendTask({ sessionId, cwd: projectDir, prompt: text, stream: true, requestId, mode }); + // Rant 2026-08-20T18:18:sandbox 档位(read-only / workspace-write / danger-full-access) + // G65:conn.sendTask 内部标记 ownStream(每连接独立锁) + rid = conn.sendTask({ sessionId, cwd: sessionCwd, prompt: text, requestId, sandbox }); } catch (e) { - ownStream = false; // sendTask 抛异常(ws.send 失败)→ 释放锁,防 G65 锁泄漏 - ownStreamRequestId = null; + conn._releaseOwnStream(); // sendTask 抛异常(ws.send 失败)→ 释放锁,防 G65 锁泄漏 throw e; } - ownStreamRequestId = rid; // 追踪自有流(G65 锁仅由自有 done 释放) return { ok: true, requestId: rid }; // G124:回传 requestId → renderer 识别自有流 }); ipcMain.handle("emrg:listSessions", async () => listSessions()); - ipcMain.handle("emrg:switchSession", async (_e, { sessionId }) => { + // Rant 2026-08-21T12:44:34:一键"重启生效"。旧实现只发 shutdown —— daemon 会被 + // TUI 客户端拉回(TUI 断线自动重连+自动 spawn,emrg/client/app.py:378-394), + // 而 GUI 自己永不重连(实证 emrg-gui.log 11:41:杀 daemon 后 36 分钟无重连, + // 状态栏绿点假象 + "daemon not connected")。 + // 新实现:spawn `python -m emrg._stop_all --skip-gui` —— 复用全链路 stop + // (顺序 GUI→TUI→daemon,客户端先死不会重拉 daemon;--skip-gui 跳过 stop_gui, + // 否则 taskkill /IM EMRG.exe / ps-scan EMRG.app 会杀掉 GUI 主进程本身, + // relaunch 永不执行)→ 等 exit 0 → GUI 自己 app.relaunch() + app.exit(0) → + // 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon。TUI 不需要感知 + // 重启(直接被杀,不会进重连循环)。 + // 打包模式:宿主 PATH python3 无 `emrg` 可导入 → 用安装版运行时 python + + // PYTHONPATH(daemon_client._findInstalledPython/_installedPythonPath)。 + ipcMain.handle("emrg:restartDaemon", async () => { + const dc = connManager?.daemonConn(); + const isPackaged = !!dc?._isPackaged; + const python = isPackaged && dc + ? dc._findInstalledPython() + : (dc?._findPython() || "python3"); + const spawnOpts = { + cwd: os.homedir(), + stdio: ["ignore", "ignore", "pipe"], + }; + if (isPackaged && dc) spawnOpts.env = { ...process.env, PYTHONPATH: dc._installedPythonPath() }; + if (process.platform === "win32") spawnOpts.windowsHide = true; // 防黑窗(源码模式同 G68) + const result = await new Promise((resolve) => { + const child = spawn(python, ["-m", "emrg._stop_all", "--skip-gui"], spawnOpts); + let err = ""; + child.stderr?.on("data", (d) => { err += String(d); }); + child.on("error", (e) => resolve({ ok: false, error: `spawn failed: ${e.message}` })); + child.on("close", (code) => resolve( + code === 0 + ? { ok: true } + : { ok: false, error: `stop_all exit ${code}: ${err.slice(-500)}` } + )); + }); + if (!result.ok) throw new Error(result.error); + app.relaunch(); // 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon + app.exit(0); + return { ok: true }; + }); + + ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => { if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); - if (ownStream) throw new Error("stream in progress — cannot switch"); // G65 - if (!client?.connected) throw new Error("daemon not connected"); - client.clearGroups(); // G110:切会话清空旧分组缓存(含 timer),防广播"幽灵"残留 - const meta = await client.sendCommandAndWait("resume_session", { session_id: sessionId, cwd: projectDir }, 5000) - .catch((e) => { - // G106:被动删除恢复——resume error → 刷新列表 + 切最近 - if (/not found|error/i.test(e.message)) { - return listSessions().then((sessions) => { - const next = sessions[0]?.session_id || null; - return { error: "session_not_found", sessions, next_session: next }; - }); - } - throw e; - }); - if (meta.error === "session_not_found") { - // G106:resume 失败(会话已删)→ 不更新 currentSessionId(renderer 会切到 next_session, - // 但 main 侧保持旧值,重连 resume 也不指向已删会话;renderer 随后 switchSession(next) 会纠正) - return meta; + // P6(rant 15:07:19 边界):projectPath 校验(跨项目打开时传项目路径) + if (projectPath !== undefined && (typeof projectPath !== "string" || !projectPath.trim())) { + throw new Error("invalid project path"); + } + // P6(rant 15:07:19 上限 20):显式打开新会话超限 → 提示不自动关(已打开 sid 复用不拦) + if (openSessions.size >= DEFAULT_CAP && !openSessions.has(sessionId)) { + throw new Error(`too many open sessions (${DEFAULT_CAP}) — close some first`); + } + // G65:自有流运行中禁止切会话(每连接独立锁,查当前激活连接) + if (connManager?.get(currentSessionId)?.ownStream) throw new Error("stream in progress — cannot switch"); + const prevSid = currentSessionId; + // G110:切会话清空旧连接分组缓存(含 timer),防广播"幽灵"残留 + connManager?.get(prevSid)?.clearGroups(); + // P5 slice 2:跨项目打开——用该项目路径 resume(非全局 projectDir) + const targetPath = projectPath || openSessions.get(sessionId)?.projectPath || DEFAULT_CWD; + try { + await openSession(sessionId, targetPath); // 打开(新)会话连接 + resume_session 自动订阅 + } catch (e) { + // G106:被动删除恢复——resume error → 刷新列表 + 切最近 + if (/not found|error/i.test(e.message)) { + connManager?.close(sessionId); // 防御:失败连接已由 open 内部关闭 + return listSessions().then((sessions) => { + const next = sessions[0]?.session_id || null; + return { error: "session_not_found", sessions, next_session: next }; + }); + } + throw e; } currentSessionId = sessionId; win.setTitle(`EMRG — ${sessionId}`); // G109 - return meta; + // P4(rant 15:07:19):多会话保持——切走**不再关闭**旧会话连接(浏览器 tab + // 效果:切回继续生成/看现场)。关闭走 emrg:closeSession(P4 slice 2 侧边栏)。 + markSessionActive(sessionId); // 更新 lastActive + activeSid 防抖写盘 + return {}; }); ipcMain.handle("emrg:deleteSession", async (_e, { sessionId }) => { if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); - await client.sendCommandAndWait("delete_session", { session_id: sessionId, cwd: projectDir }, 5000); + await requireConn().sendCommandAndWait("delete_session", { session_id: sessionId, cwd: DEFAULT_CWD }, 5000); + connManager?.close(sessionId); // P2:删除会话 → 关闭该会话连接(若打开) + openSessions.delete(sessionId); // P4:删除(删数据)→ 一并移出打开会话簿记 + schedulePersistGuiState(); + broadcastOpenSessions(); return { ok: true }; }); @@ -310,38 +414,70 @@ vision = false if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); const clean = String(title || "").trim().slice(0, 80); // 截断超长标题 if (!clean) throw new Error("empty title"); - const frame = await client.sendCommandAndWait("rename_session", { session_id: sessionId, cwd: projectDir, title: clean }, 5000); + const frame = await requireConn().sendCommandAndWait("rename_session", { session_id: sessionId, cwd: DEFAULT_CWD, title: clean }, 5000); + // 跨项目会话重命名成功后立即同步侧边栏标题(rant 12:01:44) + const v = openSessions.get(sessionId); + if (v) { + v.title = frame.title || clean; + broadcastOpenSessions(); + } return { ok: true, title: frame.title || clean }; }); - ipcMain.handle("emrg:newSession", async () => { + // P4(rant 15:07:19):关闭会话 = 断开连接 + 移除 + 持久化,**保留磁盘数据** + // (与 delete_session 区分:关闭留数据 / 删除删数据)。 + ipcMain.handle("emrg:closeSession", async (_e, { sessionId }) => { + if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); + return closeSession(sessionId); + }); + + // P4:跨项目打开会话列表(侧边栏数据源,slice 2 消费) + ipcMain.handle("emrg:getOpenSessions", async () => ({ + openSessions: openSessionsList(), + activeSid: currentSessionId, + })); + + ipcMain.handle("emrg:newSession", async (_e, { projectPath } = {}) => { + // P6(rant 15:07:19 边界):projectPath 校验(新建会话指定项目时) + if (projectPath !== undefined && (typeof projectPath !== "string" || !projectPath.trim())) { + throw new Error("invalid project path"); + } // G14/G81:本地生成 session_id(无 new_session 消息) const sid = generateSessionId(); // 同步 main 侧会话状态:重连后 resume 正确会话(G41)+ 窗口标题(G109) currentSessionId = sid; win.setTitle(`EMRG — ${sid}`); + // P5 slice 2:新会话指定项目 → 先记簿记(发送时用其 cwd 建连接) + if (projectPath) touchOpenSession(sid, projectPath); + // P4:新会话在首条消息前不建连接(sendMessage 自动 open)——此处仅标记激活 + // 并刷新持久化(activeSid 前进;openSessions 条目随 openSession 落簿记) + schedulePersistGuiState(); + broadcastOpenSessions(); return { session_id: sid }; }); ipcMain.handle("emrg:clearSession", async (_e, { sessionId }) => { // GUI / 指令 P1:/clear — 清空当前会话(daemon 协议 clear_session 已存在) if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); - await client.sendCommandAndWait("clear_session", { session_id: sessionId, cwd: projectDir }, 5000); + await requireConn().sendCommandAndWait("clear_session", { session_id: sessionId, cwd: DEFAULT_CWD }, 5000); return { ok: true }; }); ipcMain.handle("emrg:compactSession", async (_e, { sessionId }) => { // GUI / 指令 P1:/compact — 压缩当前会话历史(daemon 协议 compact 已存在) if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); - await client.sendCommandAndWait("compact", { session_id: sessionId, cwd: projectDir }, 5000); + await requireConn().sendCommandAndWait("compact", { session_id: sessionId, cwd: DEFAULT_CWD }, 5000); return { ok: true }; }); - ipcMain.handle("emrg:listHistory", async (_e, { sessionId }) => { - // GUI / 指令 P2:/rewind — 获取会话历史消息点(daemon 协议 list_history 已存在) + ipcMain.handle("emrg:listHistory", async (_e, { sessionId, limit, offset } = {}) => { + // GUI / 指令 P2:/rewind + rant 14:15:12 历史按需加载(limit/offset 可选) if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); - const frame = await client.sendCommandAndWait("list_history", { session_id: sessionId, cwd: projectDir }, 5000); - return { messages: frame.messages || [] }; + const payload = { session_id: sessionId, cwd: DEFAULT_CWD }; + if (limit != null) payload.limit = limit; + if (offset != null) payload.offset = offset; + const frame = await requireConn().sendCommandAndWait("list_history", payload, 5000); + return { messages: frame.messages || [], hasMore: !!frame.has_more }; }); ipcMain.handle("emrg:rewindSession", async (_e, { sessionId, recordIndex }) => { @@ -350,9 +486,9 @@ vision = false if (typeof recordIndex !== "number" || !Number.isInteger(recordIndex) || recordIndex < 0) { throw new Error("invalid record_index"); } - const frame = await client.sendCommandAndWait( + const frame = await requireConn().sendCommandAndWait( "rewind_session", - { session_id: sessionId, cwd: projectDir, record_index: recordIndex }, + { session_id: sessionId, cwd: DEFAULT_CWD, record_index: recordIndex }, 5000 ); return { ok: true, removedCount: frame.removed_count ?? 0 }; @@ -360,34 +496,85 @@ vision = false ipcMain.handle("emrg:listMemories", async (_e, { scope = "project", sessionId } = {}) => { // GUI / 指令 P3:/memory — 列出记忆(daemon list_memories → memories_list) - const params = { scope, cwd: projectDir }; + const params = { scope, cwd: DEFAULT_CWD }; if (scope === "session") { if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); params.session_id = sessionId; } - const frame = await client.sendCommandAndWait("list_memories", params, 5000); + const frame = await requireConn().sendCommandAndWait("list_memories", params, 5000); return frame.memories || []; }); ipcMain.handle("emrg:readMemory", async (_e, { memoryId, scope = "project", sessionId } = {}) => { // GUI / 指令 P3:/memory — 读取单条记忆(daemon read_memory → memory_content) if (typeof memoryId !== "string" || !memoryId.trim()) throw new Error("invalid memory_id"); - const params = { scope, memory_id: memoryId.trim(), cwd: projectDir }; + const params = { scope, memory_id: memoryId.trim(), cwd: DEFAULT_CWD }; if (scope === "session") { if (!validateSessionId(sessionId)) throw new Error("invalid session_id"); params.session_id = sessionId; } - const frame = await client.sendCommandAndWait("read_memory", params, 5000); + const frame = await requireConn().sendCommandAndWait("read_memory", params, 5000); return frame.memory || { id: memoryId, content: "" }; }); + // 右栏工作区面板 P1(rant 2026-08-11T12:20:35):list_files / read_file 透传 + // 走 requireConn() = 当前会话连接天然认证;daemon list_files → files_list + ipcMain.handle("emrg:listFiles", async (_e, { path: p } = {}) => { + if (typeof p !== "string" || !p.trim()) throw new Error("invalid path"); + const frame = await requireConn().sendCommandAndWait("list_files", { path: p.trim() }, 10000); + if (frame.error) throw new Error(frame.error); + return { entries: frame.entries || [], truncated: !!frame.truncated }; + }); + + ipcMain.handle("emrg:readFile", async (_e, { path: p, startLine, lineLimit } = {}) => { + if (typeof p !== "string" || !p.trim()) throw new Error("invalid path"); + const params = { path: p.trim() }; + if (startLine !== undefined) params.start_line = startLine; + if (lineLimit !== undefined) params.line_limit = lineLimit; + const frame = await requireConn().sendCommandAndWait("read_file", params, 10000); + if (frame.error) throw new Error(frame.error); + return { content: frame.content || "", binary: !!frame.binary, truncated: !!frame.truncated, totalLines: frame.total_lines }; + }); + + // ── P2.3 + P3.4(rant 12:20:35):HTML 预览 WebContentsView ────────── + // 混合模型:非 HTML 走 renderer DOM 查看器;.html/.htm 走内嵌浏览器预览。 + // 懒创建(R7-⑤)+ 单实例复用(一次只显示一个预览,切换 = 重新加载 R7-⑥)。 + ipcMain.handle("emrg:previewHtml", async (_e, { path: p } = {}) => showPreview(p)); + + ipcMain.handle("emrg:closePreview", async (_e, { path: p } = {}) => { + // 关闭/切走 HTML tab → 隐藏预览(懒销毁:窗口关闭自动回收,R5-⑤) + const target = typeof p === "string" && p.trim() ? path.resolve(p.trim()) : null; + if (previewVisible && (!target || previewPath === target)) { + previewVisible = false; + previewPath = null; + updatePreviewBounds(); + } + return { ok: true }; + }); + + // renderer 上报面板布局(宽度/折叠/内容区顶部 = Tab 栏高)→ main 调 setBounds(R2-⑧) + ipcMain.handle("emrg:panelResized", async (_e, { width, collapsed, contentTop } = {}) => { + previewLayout = { + width: typeof width === "number" && width > 0 ? width : previewLayout.width, + collapsed: collapsed === true, + contentTop: typeof contentTop === "number" && contentTop >= 0 ? contentTop : previewLayout.contentTop, + }; + updatePreviewBounds(); + return { ok: true }; + }); + + // renderer 崩溃 reload 后拉取当前预览状态(main 是真相源,R5-④ 恢复通道) + ipcMain.handle("emrg:getPreviewState", async () => ({ + path: previewVisible && previewPath ? previewPath : null, + })); + ipcMain.handle("emrg:listSkills", async () => { // GUI / 指令 P3:/skills — 读取技能列表(TUI 本地 load_skills 等价物,daemon 无协议) // 技能在 ~/.emrg/skills/*.md(user)与 /.emrg/skills/*.md(project) const skills = []; const dirs = [ { dir: path.join(os.homedir(), ".emrg", "skills"), source: "user" }, - { dir: path.join(projectDir, ".emrg", "skills"), source: "project" }, + { dir: path.join(DEFAULT_CWD, ".emrg", "skills"), source: "project" }, ]; for (const { dir, source } of dirs) { let files = []; @@ -408,61 +595,176 @@ vision = false ipcMain.handle("emrg:listProjects", async () => { // GUI / 指令 P4:/rant 项目下拉 — daemon list_projects → projects_list - const frame = await client.sendCommandAndWait("list_projects", {}, 5000); + const frame = await requireConn().sendCommandAndWait("list_projects", {}, 5000); return frame.projects || []; }); + // P5(rant 15:07:19):某项目的会话列表(list_sessions(cwd=projectPath)) + ipcMain.handle("emrg:listProjectSessions", async (_e, { projectPath }) => { + if (typeof projectPath !== "string" || !projectPath) throw new Error("invalid project path"); + const frame = await requireConn().sendCommandAndWait("list_sessions", { cwd: projectPath }, 5000); + return { sessions: frame.sessions || [] }; + }); + + // P5:新建项目 = 轻量命令带 cwd → daemon 隐式 _touch_project 注册(零改动) + ipcMain.handle("emrg:registerProject", async (_e, { path: p }) => { + if (typeof p !== "string" || !p) throw new Error("invalid project path"); + try { + fs.accessSync(p, fs.constants.W_OK); // 目录可写校验(G121) + } catch { + throw new Error("project directory not writable"); + } + await requireConn().sendCommandAndWait("list_sessions", { cwd: p }, 5000); // 隐式注册 + return { ok: true, path: p }; + }); + + // P5 slice 2:删除项目 = 只删 projects.yml 条目(保留磁盘数据)+ 关闭该项目已打开会话 + // 受保护项目不可删除(内置 project emrg / 内置 task emrg-task → 演化依赖,删了悬空); + // `.emrg` 是 _touch_project 历史记录**非内置 → 可删**(再次访问重新注册)。 + // 返回 { ok, removed, closed: [sids] };renderer 收到后负责切换激活会话。 + ipcMain.handle("emrg:removeProject", async (_e, { name, path: p } = {}) => { + if (typeof name !== "string" || !name.trim()) throw new Error("invalid project name"); + // 受保护项目:内置 project `emrg` + 内置 task `emrg-task` + if (name === "emrg" || name === "emrg-task") { + return { ok: false, protected: true, error: "protected system project" }; + } + // 该项目已打开的会话 → 关闭连接 + 移除 + 写盘(激活中先切相邻由 renderer 处理) + const closed = []; + for (const [sid, v] of [...openSessions.entries()]) { + if (v.projectPath === p || v.projectName === name) { + connManager?.close(sid); + openSessions.delete(sid); + closed.push(sid); + } + } + if (currentSessionId && closed.includes(currentSessionId)) { + currentSessionId = null; // 激活会话被关 → 无激活(renderer 切相邻) + } + const frame = await requireConn().sendCommandAndWait("remove_project", { name: name.trim() }, 5000); + schedulePersistGuiState(); + broadcastOpenSessions(); + return { ok: true, removed: Boolean(frame.removed), closed, name }; + }); + ipcMain.handle("emrg:listTasks", async () => { // GUI / 指令 P4:/trigger — daemon list_tasks → tasks_list - const frame = await client.sendCommandAndWait("list_tasks", {}, 5000); + const frame = await requireConn().sendCommandAndWait("list_tasks", {}, 5000); return frame.tasks || []; }); ipcMain.handle("emrg:triggerTask", async (_e, { name }) => { // GUI / 指令 P4:/trigger — daemon trigger_task → trigger_result if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name"); - const frame = await client.sendCommandAndWait("trigger_task", { name: name.trim() }, 5000); + const frame = await requireConn().sendCommandAndWait("trigger_task", { name: name.trim() }, 5000); + return frame; + }); + + // ── Task/template CRUD IPC (rant 2026-08-12T18:23:15 P3) ────────── + ipcMain.handle("emrg:taskCreate", async (_e, payload) => { + const { name, type, project, interval, enabled, repo, description } = payload || {}; + if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name"); + if (typeof type !== "string" || !type.trim()) throw new Error("invalid task type"); + if (typeof project !== "string" || !project.trim()) throw new Error("invalid project"); + const frame = await requireConn().sendCommandAndWait("task_create", { + name: name.trim(), task_type: type.trim(), project: project.trim(), + interval, enabled, repo, description, + }, 8000); + if (!frame.ok && frame.error) throw new Error(frame.error); + return frame; + }); + + ipcMain.handle("emrg:taskUpdate", async (_e, payload) => { + const { name, type, ...fields } = payload || {}; + if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name"); + if (type !== undefined && typeof type !== "string") throw new Error("invalid task type"); + if (type !== undefined && type.trim()) fields.task_type = type.trim(); + const frame = await requireConn().sendCommandAndWait("task_update", { name: name.trim(), ...fields }, 8000); + if (!frame.ok && frame.error) throw new Error(frame.error); + return frame; + }); + + ipcMain.handle("emrg:taskDelete", async (_e, { name }) => { + if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name"); + const frame = await requireConn().sendCommandAndWait("task_delete", { name: name.trim() }, 8000); + if (!frame.ok && frame.error) throw new Error(frame.error); + return frame; + }); + + ipcMain.handle("emrg:taskTemplateList", async () => { + const frame = await requireConn().sendCommandAndWait("task_template_list", {}, 5000); + return frame.templates || []; + }); + + ipcMain.handle("emrg:taskTemplateCreate", async (_e, { name, prompt }) => { + if (typeof name !== "string" || !name.trim()) throw new Error("invalid template name"); + if (typeof prompt !== "string" || !prompt.trim()) throw new Error("invalid template prompt"); + const frame = await requireConn().sendCommandAndWait("task_template_create", { name: name.trim(), prompt }, 8000); + if (!frame.ok && frame.error) throw new Error(frame.error); + return frame; + }); + + ipcMain.handle("emrg:taskTemplateUpdate", async (_e, { name, prompt }) => { + if (typeof name !== "string" || !name.trim()) throw new Error("invalid template name"); + if (typeof prompt !== "string" || !prompt.trim()) throw new Error("invalid template prompt"); + const frame = await requireConn().sendCommandAndWait("task_template_update", { name: name.trim(), prompt }, 8000); + if (!frame.ok && frame.error) throw new Error(frame.error); + return frame; + }); + + ipcMain.handle("emrg:taskTemplateDelete", async (_e, { name }) => { + if (typeof name !== "string" || !name.trim()) throw new Error("invalid template name"); + const frame = await requireConn().sendCommandAndWait("task_template_delete", { name: name.trim() }, 8000); + if (!frame.ok && frame.error) throw new Error(frame.error); return frame; }); + // rant 14:10:14 P4:rant 面板 — 读取 rants.jsonl(可选 status 筛选) + ipcMain.handle("emrg:listRants", async (_e, { status = "" } = {}) => { + if (typeof status !== "string") throw new Error("invalid status"); + const frame = await requireConn().sendCommandAndWait("list_rants", { status: status.trim() }, 5000); + return frame.rants || []; + }); + ipcMain.handle("emrg:sendRant", async (_e, { message, project = "" } = {}) => { // GUI / 指令 P4:/rant — 提交反馈到演化系统(daemon rant 协议,字段序与 rants.jsonl 一致) if (typeof message !== "string" || !message.trim()) throw new Error("invalid rant message"); - const frame = await client.sendCommandAndWait("rant", { + const frame = await requireConn().sendCommandAndWait("rant", { message: message.trim().slice(0, 10000), project: String(project || "").trim(), - timestamp: new Date().toISOString(), + // timestamp deliberately NOT sent: daemon stamps rants with local time + // (rant 2026-08-07T13:34Z — GUI previously sent new Date().toISOString(), + // which is UTC and 8h behind on UTC+8 hosts) }, 5000); return { ok: true, count: frame.count ?? 0 }; }); ipcMain.handle("emrg:evolutionSummary", async (_e, { limit = 5 } = {}) => { // GUI / 指令 P3:自进化可见化 — daemon evolution_summary(count + 最近改进) - const frame = await client.sendCommandAndWait("evolution_summary", { limit }, 5000); + const frame = await requireConn().sendCommandAndWait("evolution_summary", { limit }, 5000); return { count: frame.count ?? 0, recent: frame.recent || [] }; }); ipcMain.handle("emrg:githubStatus", async () => { // Windows GCM rant Stage 2:设置页 GitHub 连接状态(daemon github_status) - const frame = await client.sendCommandAndWait("github_status", {}, 10000); + const frame = await requireConn().sendCommandAndWait("github_status", {}, 10000); return { authenticated: Boolean(frame.authenticated), user: frame.user || null }; }); ipcMain.handle("emrg:githubConnect", async (_e, { token }) => { // Windows GCM rant Stage 2:PAT 授权 + setup-git(daemon github_connect) - const frame = await client.sendCommandAndWait("github_connect", { token: String(token || "").trim() }, 40000); + const frame = await requireConn().sendCommandAndWait("github_connect", { token: String(token || "").trim() }, 40000); return { ok: Boolean(frame.ok), user: frame.user || null, error: frame.error || null }; }); ipcMain.handle("emrg:githubDisconnect", async () => { // Windows GCM rant Stage 2:断开 GitHub(daemon github_disconnect) - const frame = await client.sendCommandAndWait("github_disconnect", {}, 40000); + const frame = await requireConn().sendCommandAndWait("github_disconnect", {}, 40000); return { ok: Boolean(frame.ok), error: frame.error || null }; }); ipcMain.handle("emrg:githubConnectWeb", async () => { // Windows GCM rant Stage 2b:device flow 启动(daemon github_connect_web) - const frame = await client.sendCommandAndWait("github_connect_web", {}, 15000); + const frame = await requireConn().sendCommandAndWait("github_connect_web", {}, 15000); return { ok: Boolean(frame.ok), code: frame.code || null, url: frame.url || null, error: frame.error || null }; }); @@ -474,7 +776,7 @@ vision = false }); ipcMain.handle("emrg:setModel", async (_e, { model }) => { - await client.sendCommandAndWait("set_model", { model }, 5000); + await requireConn().sendCommandAndWait("set_model", { model }, 5000); return { ok: true }; }); @@ -488,7 +790,7 @@ vision = false }); ipcMain.handle("emrg:listModels", async () => { - const frame = await client.sendCommandAndWait("list_models", {}, 5000); + const frame = await requireConn().sendCommandAndWait("list_models", {}, 5000); return frame.models || []; }); @@ -507,7 +809,6 @@ vision = false apiKey: isKeyConfigured(key) ? key : "", baseUrl: cfg.llm?.base_url || "", model: cfg.llm?.model || "", - projectDir: cfg.gui?.project_dir || os.homedir(), models, modelDetails, theme: cfg.gui?.theme || "system", // §7.1:外观主题持久化(浅色/深色/跟随系统) @@ -516,7 +817,7 @@ vision = false ipcMain.handle("emrg:saveSettings", async (_e, rawConfig) => { const cfg = validateConfig(rawConfig || {}); - const wasRunning = await client?.isRunning() || false; // G123 + const wasRunning = Boolean(connManager?.daemonConn()?.connected); // G123:daemon 已连 = 运行中 let text; if (!fs.existsSync(configPath())) { text = await ensureConfigTemplate(); // G116 @@ -530,11 +831,6 @@ vision = false if (cfg.apiKey !== undefined) toml.llm.api_key = cfg.apiKey; if (cfg.baseUrl !== undefined) toml.llm.base_url = cfg.baseUrl; if (cfg.model !== undefined) toml.llm.model = cfg.model; - if (cfg.projectDir !== undefined) { - toml.gui = toml.gui || {}; - toml.gui.project_dir = cfg.projectDir; // G115:snake_case 落盘 - projectDir = cfg.projectDir; - } if (cfg.theme !== undefined) { toml.gui = toml.gui || {}; toml.gui.theme = cfg.theme; // §7.1:主题持久化(浅色/深色/跟随系统) @@ -562,11 +858,12 @@ vision = false // G24:无参数 // G141:断连边界——ws 可能已 null/closed(_onClose 后 connected=false),sendCommand 抛异常 // 不能让它泄漏为 IPC reject → renderer unhandled rejection(对比 sendMessage 的 try-catch 防护) - if (client?.ws) { - try { await client.sendCommand("cancel"); } catch { /* 断连时忽略 */ } + // P2:cancel 发到当前激活连接(自有流所在连接),并释放其 G65 锁 + const c = activeConn(); + if (c?.ws) { + try { await c.sendCommand("cancel"); } catch { /* 断连时忽略 */ } } - ownStream = false; - ownStreamRequestId = null; + c?._releaseOwnStream(); return { ok: true }; }); @@ -577,112 +874,271 @@ vision = false }); } - // ── daemon 生命周期 ───────────────────────────────────── - - async function ensureConnected() { - if (!client) { - // Phase 4(rant #12 §4 R7):打包模式传 app.isPackaged → daemon_client 走 - // 捆绑 emrgd 分支(_findDaemonExecutable)。 - client = new DaemonClient({ projectDir, logger, isPackaged: app.isPackaged }); - // G122:message_delta 16ms 批量推送 - let deltaBuf = []; - let deltaTimer = null; - // rant 14:11:冲刷 delta 缓冲——终态事件(done/error/cancelled)直通不走缓冲, - // 若残留 delta 在 16ms 定时器之后才 flush,会晚于终态到达渲染层 → - // handleDelta 找不到 group 节点 → 建孤儿节点(误标"来自其他客户端")+ 光标永不消失。 - const flushDeltaBuf = () => { - if (deltaTimer) { - clearTimeout(deltaTimer); - deltaTimer = null; - } - if (deltaBuf.length && win && !win.isDestroyed()) { - const chunks = deltaBuf; - deltaBuf = []; - win.webContents.send("emrg:event", { type: "message_delta", data: { chunks } }); - } - }; - client.onEvent((type, data) => { - if (type === "message_delta") { - deltaBuf.push(data); - if (!deltaTimer) { - deltaTimer = setTimeout(flushDeltaBuf, 16); - } - return; - } - if (type === "done" || type === "error" || type === "cancelled") { - flushDeltaBuf(); // 终态前先清空缓冲:delta 保证不晚于终态(webContents.send 保序) - } - if (type === "done") { - // 仅自有流的 done 释放 G65 锁(广播 done 不影响);timeout 兜底同样只清自有 - if (data.request_id === ownStreamRequestId || (data.timeout && ownStream)) { - ownStream = false; - ownStreamRequestId = null; - } - } - if (type === "error") { - // session busy 是即发错误(daemon 返回后无 done 跟随)——释放 ownStream,防 G65 锁泄漏 - // (流式错误如 LLM error 则有 done 跟随,由 done 分支释放,不在此处理) - if (data.error && String(data.error).includes("session busy")) { - ownStream = false; - ownStreamRequestId = null; - } - } + // ── daemon 生命周期(P2:connManager 为 daemon 唯一 owner)────────── + + // 惰性初始化 connManager(挂事件桥 + 恢复钩子)。 + function ensureConnManager() { + if (connManager) return connManager; + connManager = new ConnManager({ logger, isPackaged: app.isPackaged }); + // 每个新会话连接建立时挂 renderer 事件桥(附带 sid;含 recoverAll 重开路径) + connManager.onOpen((sid, conn, projectPath) => { + conn.onEvent((type, data) => { + // P4:消息活动刷新该会话 lastActive(激活/发送/done 更新,写盘防抖 1s) + if (type === "done" || type === "message_delta") touchOpenSession(sid, projectPath); if (win && !win.isDestroyed()) { - win.webContents.send("emrg:event", { type, data }); + // 主动关闭(切走/删除)不触发断线横幅——真断连/daemon 重启照常转发 + if (type === "disconnected" && conn._intentionalClose) return; + win.webContents.send("emrg:event", { type, data, sid }); } }); - client.onEvent((type) => { - if (type === "disconnected") { - ownStream = false; - ownStreamRequestId = null; - scheduleReconnect(); + }); + // daemon 重启恢复完成后刷新 UI 状态(对齐旧 G41 重连成功块) + connManager.onRecovered(async () => { + try { + const sessions = await listSessions(); + sendToRenderer("sessions", { sessions }); + const pong = await waitForPong(); + sendToRenderer("status", { connected: true, server_id: pong?.identity?.instance_id, model: pong?.model, current_version: pong?.current_version || "" }); + logger.info("[gui] connManager recovery complete"); + } catch (e) { + logger.warn(`[gui] post-recovery refresh failed: ${e.message}`); + } + }); + return connManager; + } + + // 当前激活连接:有会话连接用会话连接(同一 daemon,命令通用);否则 daemon 级连接。 + function activeConn() { + if (!connManager) return null; + if (currentSessionId) { + const c = connManager.get(currentSessionId); + if (c) return c; + } + return connManager.daemonConn(); + } + + // 同步取可用连接(未连 → 抛错,与旧 `if (!client?.connected) throw` 语义一致)。 + function requireConn() { + const c = activeConn(); + if (!c || !c.connected) throw new Error("daemon not connected"); + return c; + } + + // 打开(或复用)会话连接;事件桥由 onOpen 钩子统一挂(含 recoverAll 重开)。 + async function openSession(sid, projectPath, { resume = true } = {}) { + const existing = connManager.get(sid); + if (existing && existing.connected) { + touchOpenSession(sid, projectPath); // P4:复用连接也刷新打开会话状态 + return existing; + } + const conn = await connManager.open(sid, projectPath, { resume }); + touchOpenSession(sid, projectPath); + return conn; + } + + // ── P4 openSessions 簿记 + gui_state.json 持久化 ────────────────── + + function projectNameOf(projectPath) { + return path.basename(projectPath) || projectPath; + } + + // 记录/刷新一个打开会话(复用连接、恢复重开、recoverAll 均刷新) + function touchOpenSession(sid, projectPath) { + openSessions.set(sid, { + projectName: projectNameOf(projectPath), + projectPath, + lastActive: new Date().toISOString(), + }); + broadcastOpenSessions(); + // 跨项目标题(rant 12:01:44):异步拉该会话所在项目列表取 title—— + // 找到则 v.title = s.title 并广播(失败/无 title → 保持 undefined → 侧边栏 sid 兜底) + listSessions(projectPath) + .then((sessions) => { + const v = openSessions.get(sid); + if (!v) return; // 会话已关闭/移除 + const s = sessions.find((x) => x.session_id === sid); + if (s && s.title) { + v.title = s.title; + broadcastOpenSessions(); } - }); + }) + .catch(() => { /* 拉取失败保持 undefined → sid 兜底 */ }); + } + + // 激活会话变化(切换/新会话/发送)→ 更新 lastActive + activeSid → 防抖写盘 + function markSessionActive(sid) { + if (sid && openSessions.has(sid)) { + openSessions.get(sid).lastActive = new Date().toISOString(); + } + schedulePersistGuiState(); + broadcastOpenSessions(); + } + + function schedulePersistGuiState() { + if (guiStateTimer) clearTimeout(guiStateTimer); + guiStateTimer = setTimeout(() => { + guiStateTimer = null; + persistGuiStateNow(); + }, GUI_STATE_DEBOUNCE_MS); + guiStateTimer.unref?.(); + } + + function persistGuiStateNow() { + try { + const entries = [...openSessions.entries()].map(([sid, v]) => ({ + sid, + projectName: v.projectName, + projectPath: v.projectPath, + lastActive: v.lastActive, + })); + const state = { + openSessions: sanitizeOpenSessions(entries), // 写盘侧也守上限 20 + activeSid: currentSessionId, + }; + saveGuiState(os.homedir(), state); + } catch (e) { + logger.warn(`[gui] gui_state.json persist failed: ${e.message}`); } + } + + // 读盘 + 清洗(启动恢复用;损坏/缺失 → 空) + function readGuiState() { try { - await client.ensureConnected(); + const p = guiStatePath(os.homedir()); + if (!fs.existsSync(p)) return { openSessions: [], activeSid: null }; + const raw = JSON.parse(fs.readFileSync(p, "utf8")); + return { + openSessions: sanitizeOpenSessions(raw.openSessions || []), + activeSid: raw.activeSid || null, + }; + } catch (e) { + logger.warn(`[gui] gui_state.json read failed: ${e.message}`); + return { openSessions: [], activeSid: null }; + } + } + + // P4 slice 2:启动恢复——gui_state.json 中的打开会话(上限 20)逐个重开连接 + + // resume 重订阅;activeSid 失效 → 第一个有效;失效条目跳过 + 重写盘。 + async function restoreOpenSessions(sessions) { + const { openSessions: saved, activeSid } = readGuiState(); + const validSids = new Set((sessions || []).map((s) => s.session_id)); + let restored = 0; + for (const entry of saved) { + if (restored >= 20) break; // 恢复上限(sanitize 已守,双保险) + if (!validSids.has(entry.sid)) continue; // 失效条目跳过 + try { + await openSession(entry.sid, entry.projectPath); // resume 重订阅 + restored += 1; + } catch (e) { + logger.warn(`[gui] restore session ${entry.sid} failed: ${e.message}`); + openSessions.delete(entry.sid); // 失效 → 移除 + } + } + // activeSid:优先恢复原激活;失效 → 第一个有效 + if (openSessions.has(activeSid)) { + currentSessionId = activeSid; + } else if (openSessions.size > 0) { + currentSessionId = openSessionsList()[0].sid; + } + if (currentSessionId) { + win?.setTitle(`EMRG — ${currentSessionId}`); + } + schedulePersistGuiState(); // 失效条目剔除后重写盘 + broadcastOpenSessions(); + return restored; + } + + // 通知 renderer 打开会话列表变化(侧边栏数据源,slice 2) + function broadcastOpenSessions() { + sendToRenderer("open_sessions", { + openSessions: openSessionsList(), + activeSid: currentSessionId, + }); + } + + // 关闭会话(保留磁盘数据):断连 + 移除 + 持久化。P4 slice 2 侧边栏"关闭"入口用。 + async function closeSession(sid) { + const entry = openSessions.get(sid); + connManager?.close(sid); // 主动关闭(_intentionalClose 抑制断线横幅) + openSessions.delete(sid); + schedulePersistGuiState(); + if (currentSessionId === sid) currentSessionId = null; // 关闭激活会话 → 无激活 + broadcastOpenSessions(); + return { ok: true, closed: !!entry }; + } + + function openSessionsList() { + return [...openSessions.entries()] + .map(([sid, v]) => ({ sid, projectName: v.projectName, projectPath: v.projectPath, lastActive: v.lastActive, title: v.title })) + .sort((a, b) => String(b.lastActive || "").localeCompare(String(a.lastActive || ""))); + } + + function guiStateFilePath() { + return guiStatePath(os.homedir()); + } + + async function ensureConnected() { + ensureConnManager(); + try { + await connManager.ensureDaemon(); logger.info("[gui] connected to emrgd"); cancelReconnect(); + reconnectDelayMs = 1000; // 退避复位 + daemonStoppedNotified = false; // 节流提示复位(下个生命周期可再提示) + startHeartbeat(); // rant 2026-08-21T12:44:34:定时探活(断连主动重连) sendToRenderer("status", { connected: true }); } catch (e) { - if (client._authFailed) { + const dm = connManager.daemonConn(); + if (dm?._authFailed) { // G88:认证失败 → 停止自动重试 sendToRenderer("status", { connected: false, auth_failed: true, error: e.message }); return; } + // Rant 2026-08-09T13:16:36 ⑤:spawn 节流命中 → 告知宿主真实原因 + // (含 emrgd.log 尾部),不再无限拉起 daemon。只提示一次,防退避重试 + // 每分钟重复追加系统消息。 + if (String(e.message).includes("after 3 attempts") && !daemonStoppedNotified) { + daemonStoppedNotified = true; + sendToRenderer("status", { connected: false, daemon_stopped: true, error: e.message }); + } logger.warn(`[gui] ensureConnected failed: ${e.message}`); scheduleReconnect(); } } + // daemon 级重连退避(connManager 重启恢复覆盖会话连接;此处覆盖"无会话连接 + // 时 daemon 连接不可用"的初始/空闲场景)。 function scheduleReconnect() { if (stopping || reconnectTimer) return; + stopHeartbeat(); // 重连期间不再心跳(心跳失败路径会再调本函数,防叠) + const delay = reconnectDelayMs; + reconnectDelayMs = Math.min(reconnectDelayMs * 2, MAX_RECONNECT_DELAY_MS); // 指数退避 reconnectTimer = setTimeout(async () => { reconnectTimer = null; sendToRenderer("status", { connected: false, reconnecting: true }); await ensureConnected(); - if (client?.connected) { - // G41:重连成功 → list_sessions + 重新 resume 当前会话 + if (connManager.daemonConn()?.connected) { + // G41(P2 改写):恢复当前会话连接(若 daemon 重启后未由 recoverAll 重开) + if (currentSessionId && !connManager.get(currentSessionId)) { + try { await openSession(currentSessionId, DEFAULT_CWD); } catch { /* 会话可能已删 */ } + } const sessions = await listSessions(); sendToRenderer("sessions", { sessions }); - if (currentSessionId) { - try { - await client.sendCommandAndWait("resume_session", { session_id: currentSessionId, cwd: projectDir }, 5000); - } catch { /* 会话可能已删 */ } - } const pong = await waitForPong(); - sendToRenderer("status", { connected: true, server_id: pong?.identity?.instance_id, model: pong?.model }); + sendToRenderer("status", { connected: true, server_id: pong?.identity?.instance_id, model: pong?.model, current_version: pong?.current_version || "" }); } - }, 1000); + }, delay); } function cancelReconnect() { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } } - async function waitForPong(timeoutMs = 3000) { + async function waitForPong(timeoutMs = 3000, connOverride = null) { + const conn = connOverride || activeConn(); + if (!conn || !conn.connected) return null; // 未连接 → 直接超时语义(不抛) return new Promise((resolve) => { - const off = client.onEvent((type, data) => { + const off = conn.onEvent((type, data) => { if (type === "pong") { off(); clearTimeout(timer); @@ -690,13 +1146,54 @@ vision = false } }); const timer = setTimeout(() => { off(); resolve(null); }, timeoutMs); - client.sendCommand("ping"); + conn.sendCommand("ping"); }); } - async function listSessions() { + // ── 心跳探活(rant 2026-08-21T12:44:34)───────────────────── + function startHeartbeat() { + if (heartbeatTimer || stopping) return; + heartbeatTimer = setInterval(() => { _heartbeatTick(); }, HEARTBEAT_MS); + logger.info(`[gui] heartbeat started (every ${HEARTBEAT_MS / 1000}s)`); + } + + function stopHeartbeat() { + if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } + } + + async function _heartbeatTick() { + if (stopping) { stopHeartbeat(); return; } + const conn = connManager?.daemonConn(); + if (!conn) return; + if (!conn.connected) { + // daemon 级断连无人监听(旧缺陷)→ 心跳主动补触发重连 + logger.warn("[gui] heartbeat: daemon connection dropped — scheduling reconnect"); + stopHeartbeat(); + scheduleReconnect(); + return; + } + const pong = await waitForPong(3000, conn); + if (!pong) { + logger.warn("[gui] heartbeat: no pong from daemon — scheduling reconnect"); + stopHeartbeat(); + try { conn.close(); } catch { /* ignore */ } + scheduleReconnect(); + return; + } + // Rant 2026-08-21T14:38:27:升级判断——installed_version(磁盘实时)≠ + // current_version(进程内存运行版本)且 installed 非空 → 发专用 upgrade 事件 + // (不复用 status,避免 handleStatus 副作用刷屏),由 renderer 弹横幅。 + if (pong.installed_version && pong.installed_version !== pong.current_version) { + sendToRenderer("upgrade", { + current_version: pong.current_version || "", + installed_version: pong.installed_version, + }); + } + } + + async function listSessions(cwd = DEFAULT_CWD) { try { - const frame = await client.sendCommandAndWait("list_sessions", { cwd: projectDir }, 5000); + const frame = await requireConn().sendCommandAndWait("list_sessions", { cwd }, 5000); return frame.sessions || []; } catch (e) { logger.warn(`[gui] list_sessions failed: ${e.message}`); @@ -710,6 +1207,72 @@ vision = false } } + // ── P2.3 + P3.4:HTML 预览 WebContentsView ──────────────────────── + + /** 懒创建预览 view(R7-⑤):sandbox + contextIsolation 对齐主窗口;安全清单(缺口 8): + * setWindowOpenHandler 禁新窗 + will-frame-navigate 仅允许 file:// 主框架导航(防 HTML + * 内跳转远程 URL)。程序化 loadURL 不走 will-frame-navigate——入口只有 showPreview, + * 已校验扩展名 + 绝对路径 + 文件存在,无绕过路径。 */ + function ensurePreviewView() { + if (previewView) return previewView; + previewView = new WebContentsView({ + webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false }, + }); + previewView.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + previewView.webContents.on("will-frame-navigate", (event, url, isInPlace, isMainFrame) => { + if (isMainFrame && !url.startsWith("file:")) event.preventDefault(); + }); + return previewView; + } + + /** 右对齐矩形(R4-②):x = winW - panelW、y = contentTop(Tab 栏高)、w = panelW、 + * h = winH - contentTop。折叠 → 置不可见区域(0 尺寸 + 不可见)。纯函数,便于单测。 */ + function previewRect(winW, winH, layout) { + if (layout.collapsed) return { x: winW, y: 0, width: 0, height: 0 }; + const width = Math.max(40, Math.min(layout.width || 280, winW)); + const contentTop = layout.contentTop || 0; + return { + x: Math.max(0, winW - width), + y: contentTop, + width, + height: Math.max(0, winH - contentTop), + }; + } + + function updatePreviewBounds() { + if (!previewView || !win || win.isDestroyed()) return; + if (!previewVisible) { previewView.setVisible(false); return; } + const cb = win.getContentBounds(); + const r = previewRect(cb.width, cb.height, previewLayout); + previewView.setBounds(r); + previewView.setVisible(true); + } + + /** 显示 HTML 预览:校验 → 懒创建 → loadURL(切换 = 重新加载 R7-⑥)→ bounds 同步 */ + async function showPreview(p) { + if (typeof p !== "string" || !p.trim()) return { ok: false, error: "invalid path" }; + const abs = path.resolve(p.trim()); + if (!/\.html?$/i.test(abs)) return { ok: false, error: "not_html" }; + if (!fs.existsSync(abs)) return { ok: false, error: "file_not_found" }; + try { + const view = ensurePreviewView(); + if (!previewAdded && win && !win.isDestroyed()) { + win.contentView.addChildView(view); + previewAdded = true; + } + if (previewPath !== abs) { + await view.webContents.loadURL(pathToFileURL(abs).href); + previewPath = abs; + } + previewVisible = true; + updatePreviewBounds(); + return { ok: true }; + } catch (e) { + logger.warn(`[gui] preview load failed: ${e.message}`); + return { ok: false, error: String(e.message || "load_failed") }; + } + } + // ── 应用生命周期 ──────────────────────────────────────── app.on("second-instance", () => { @@ -764,7 +1327,14 @@ vision = false app.on("window-all-closed", () => { stopping = true; cancelReconnect(); - if (client) client.close(); + stopHeartbeat(); // rant 2026-08-21T12:44:34:退出清理心跳定时器 + persistGuiStateNow(); // P4:退出前冲刷未落盘的打开会话状态(防抖 timer 取消) + connManager?.closeAll(); // P2:关闭全部会话连接 + daemon 级连接 + // P2.3:窗口关闭自动销毁子 view(R5-⑤ 无需手动清理);复位状态防二次使用 + previewView = null; + previewAdded = false; + previewPath = null; + previewVisible = false; if (process.platform !== "darwin") app.quit(); }); diff --git a/emrg/gui/package-lock.json b/emrg/gui/package-lock.json index 8b0d9acb..fcb286e7 100644 --- a/emrg/gui/package-lock.json +++ b/emrg/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "emrg-gui", - "version": "0.2.0", + "version": "0.2.65", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "emrg-gui", - "version": "0.2.0", + "version": "0.2.65", "dependencies": { "dompurify": "^3.4.13", "highlight.js": "^11.10.0", @@ -16,7 +16,8 @@ }, "devDependencies": { "electron": "^31.3.0", - "electron-builder": "^24.13.3" + "electron-builder": "^24.13.3", + "monaco-editor": "^0.56.0" } }, "node_modules/@develar/schema-utils": { @@ -3134,6 +3135,40 @@ "node": ">=10" } }, + "node_modules/monaco-editor": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", + "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dompurify": "3.4.8", + "marked": "14.0.0" + } + }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "dev": true, + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/monaco-editor/node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/emrg/gui/package.json b/emrg/gui/package.json index 30fe08fe..d9770516 100644 --- a/emrg/gui/package.json +++ b/emrg/gui/package.json @@ -1,6 +1,6 @@ { "name": "emrg-gui", - "version": "0.2.11", + "version": "0.2.65", "description": "EMRG GUI — Electron client for the EMRG self-evolving AI agent (Phase 3)", "main": "main.js", "scripts": { @@ -19,7 +19,8 @@ }, "devDependencies": { "electron": "^31.3.0", - "electron-builder": "^24.13.3" + "electron-builder": "^24.13.3", + "monaco-editor": "^0.56.0" }, "build": { "appId": "com.emrg.gui", @@ -28,17 +29,24 @@ { "from": "../dist/runtime", "to": "runtime" + }, + { + "from": "../packaging/assets/icon.png", + "to": "icon.png" } ], "files": [ "main.js", "preload.js", "renderer/**", + "vendor/**", + "conn-manager.js", + "gui-state.js", "daemon_client.js", "package.json", "scripts/**" ], - "icon": "../packaging/assets/", + "icon": "icon.png", "mac": { "hardenedRuntime": true, "notarize": false, @@ -51,7 +59,8 @@ ] } ], - "category": "public.app-category.developer-tools" + "category": "public.app-category.developer-tools", + "icon": "icon.icns" }, "win": { "target": [ @@ -61,7 +70,8 @@ "x64" ] } - ] + ], + "icon": "icon.ico" }, "linux": { "target": [ @@ -73,12 +83,16 @@ "from": "../dist/runtime", "to": "runtime" } - ] + ], + "icon": "icon.png" }, "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true, "perMachine": false + }, + "directories": { + "buildResources": "../packaging/assets" } } -} \ No newline at end of file +} diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js index 1ccc6678..c09cd058 100644 --- a/emrg/gui/preload.js +++ b/emrg/gui/preload.js @@ -10,9 +10,12 @@ const api = { init: () => ipcRenderer.invoke("emrg:init"), sendMessage: (payload) => ipcRenderer.invoke("emrg:sendMessage", payload), listSessions: () => ipcRenderer.invoke("emrg:listSessions"), + restartDaemon: () => ipcRenderer.invoke("emrg:restartDaemon"), switchSession: (payload) => ipcRenderer.invoke("emrg:switchSession", payload), - newSession: () => ipcRenderer.invoke("emrg:newSession"), + newSession: (payload) => ipcRenderer.invoke("emrg:newSession", payload), deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload), + closeSession: (payload) => ipcRenderer.invoke("emrg:closeSession", payload), + getOpenSessions: () => ipcRenderer.invoke("emrg:getOpenSessions"), renameSession: (payload) => ipcRenderer.invoke("emrg:renameSession", payload), setModel: (payload) => ipcRenderer.invoke("emrg:setModel", payload), clearSession: (payload) => ipcRenderer.invoke("emrg:clearSession", payload), @@ -21,11 +24,29 @@ const api = { rewindSession: (payload) => ipcRenderer.invoke("emrg:rewindSession", payload), listMemories: (payload) => ipcRenderer.invoke("emrg:listMemories", payload), readMemory: (payload) => ipcRenderer.invoke("emrg:readMemory", payload), + listFiles: (payload) => ipcRenderer.invoke("emrg:listFiles", payload), // 右栏工作区 P1:目录树 + readFile: (payload) => ipcRenderer.invoke("emrg:readFile", payload), // 右栏工作区 P1:文件查看器 + previewHtml: (payload) => ipcRenderer.invoke("emrg:previewHtml", payload), // P2.3:HTML 预览(WebContentsView) + closePreview: (payload) => ipcRenderer.invoke("emrg:closePreview", payload), // P2.3:关闭/切走 HTML 预览 + panelResized: (payload) => ipcRenderer.invoke("emrg:panelResized", payload), // P2.3:面板布局上报 → bounds 同步 + getPreviewState: () => ipcRenderer.invoke("emrg:getPreviewState"), // P2.3:renderer 崩溃恢复拉取 listSkills: () => ipcRenderer.invoke("emrg:listSkills"), listProjects: () => ipcRenderer.invoke("emrg:listProjects"), + listProjectSessions: (payload) => ipcRenderer.invoke("emrg:listProjectSessions", payload), + registerProject: (payload) => ipcRenderer.invoke("emrg:registerProject", payload), + removeProject: (payload) => ipcRenderer.invoke("emrg:removeProject", payload), listTasks: () => ipcRenderer.invoke("emrg:listTasks"), triggerTask: (payload) => ipcRenderer.invoke("emrg:triggerTask", payload), + // Task/template CRUD (rant 2026-08-12T18:23:15 P3) + taskCreate: (payload) => ipcRenderer.invoke("emrg:taskCreate", payload), + taskUpdate: (payload) => ipcRenderer.invoke("emrg:taskUpdate", payload), + taskDelete: (payload) => ipcRenderer.invoke("emrg:taskDelete", payload), + taskTemplateList: () => ipcRenderer.invoke("emrg:taskTemplateList"), + taskTemplateCreate: (payload) => ipcRenderer.invoke("emrg:taskTemplateCreate", payload), + taskTemplateUpdate: (payload) => ipcRenderer.invoke("emrg:taskTemplateUpdate", payload), + taskTemplateDelete: (payload) => ipcRenderer.invoke("emrg:taskTemplateDelete", payload), sendRant: (payload) => ipcRenderer.invoke("emrg:sendRant", payload), + listRants: (payload) => ipcRenderer.invoke("emrg:listRants", payload), evolutionSummary: (payload) => ipcRenderer.invoke("emrg:evolutionSummary", payload), githubStatus: () => ipcRenderer.invoke("emrg:githubStatus"), githubConnect: (payload) => ipcRenderer.invoke("emrg:githubConnect", payload), diff --git a/emrg/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index 16ff50ca..aeac0d59 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -19,12 +19,6 @@ background: var(--bg-soft); color: var(--text-1); } -/* 键盘导航聚焦(↑↓)——区别于当前会话高亮 */ -.conv-item.kbd-focus { - outline: 2px solid var(--accent); - outline-offset: -2px; - background: var(--bg-soft); -} .conv-item.active { background: var(--accent-soft); color: var(--accent); @@ -48,6 +42,11 @@ } .msg.user { align-self: flex-end; + /* rant 21:28:49:flex 容器中 auto margin 优先于 align-self——.session-view > * 的 + margin-left/right:auto(居中 760px)盖掉了 align-self:flex-end → 用户气泡被居中 + 而非右对齐。margin-left:auto + margin-right:0(特异性 0,2,0 覆盖 0,1,0)恢复右对齐。 */ + margin-left: auto; + margin-right: 0; background: var(--accent-soft); color: var(--text-1); border-radius: var(--radius-bubble); @@ -90,6 +89,19 @@ font-size: var(--fs-aux); color: var(--text-3); } +/* 历史消息(rant 14:15:12:只读展示,视觉弱化区分于实时消息) */ +.msg.user.history { + opacity: 0.72; + animation: none; +} +.history-load-bar { + text-align: center; + font-size: var(--fs-secondary); + color: var(--accent); + padding: var(--sp-2) 0; + cursor: pointer; + user-select: none; +} /* Markdown 内容排版 */ .msg-body p { @@ -204,9 +216,9 @@ display: flex; align-items: center; gap: var(--sp-2); - font-size: var(--fs-secondary); + font-size: var(--fs-small); color: var(--text-2); - padding: 4px 2px; + padding: 2px 2px; border-radius: 8px; cursor: pointer; user-select: none; @@ -219,14 +231,18 @@ } .tool-row .tool-spinner { display: inline-block; - width: 14px; - height: 14px; - border: 2px solid var(--amber-soft); + width: 12px; + height: 12px; + border: 1.5px solid var(--amber-soft); border-top-color: var(--amber); border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0; } +/* rant 21:08:工具完成后 spinner 不再转圈(JS 亦移除元素,CSS 兜底防闪烁) */ +.tool-row:not(.running) .tool-spinner { + display: none; +} .tool-row.running { color: var(--amber); } @@ -246,6 +262,15 @@ margin-left: auto; transition: transform var(--dur-med) var(--ease); } +.tool-row .tool-intent { + font-size: var(--fs-secondary); + color: var(--text-2); + margin-left: var(--sp-2); + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .tool-row.expanded .tool-chevron { transform: rotate(180deg); } @@ -269,6 +294,44 @@ margin: 0 0 6px 2px; } +/* rant 21:28:49:工具耗时(成功时 label 后 · 3.2s) */ +.tool-time { + font-size: var(--fs-aux); + color: var(--text-3); + margin-left: 4px; +} + +/* ── 连续工具合并组(方案 A:⌄ N 个工具执行 · Ts) ─────── */ +.tool-group { + display: flex; + flex-direction: column; +} +.tool-group-bar { + display: flex; + align-items: center; + gap: var(--sp-1); + font-size: var(--fs-small); + color: var(--text-3); + padding: 2px 4px; + border-radius: 6px; + cursor: pointer; + user-select: none; + transition: background-color var(--dur-fast) var(--ease); +} +.tool-group-bar:hover { + background: var(--bg-soft); +} +.tool-group-bar .tool-group-chev { + font-size: 10px; + transition: transform var(--dur-med) var(--ease); +} +.tool-group.collapsed .tool-group-rows { + display: none; +} +.tool-group:not(.collapsed) .tool-group-bar .tool-group-chev { + transform: rotate(180deg); +} + /* ── 空状态欢迎屏 ─────────────────────── */ #empty-state { flex: 1; @@ -560,11 +623,20 @@ dialog::backdrop { margin-bottom: var(--sp-3); } .dialog-card label > input, -.dialog-card label > select { +.dialog-card label > select, +.dialog-card label > textarea { width: 100%; margin-top: var(--sp-1); padding: 8px 10px; } +/* rant 2026-08-07T13:32Z: rant dialog textarea was unstyled — default box was + tiny. Full width + comfortable min-height + vertical resize + inherited font. */ +.dialog-card label > textarea { + min-height: 120px; + resize: vertical; + font: inherit; + line-height: 1.5; +} .dialog-card .hint { font-size: var(--fs-aux); color: var(--text-3); @@ -732,6 +804,347 @@ dialog::backdrop { .model-form-vision input { accent-color: var(--accent); } + +/* 定时任务管理(rant 2026-08-12T18:23:15 P3:GUI 任务/自定义类型 CRUD) */ +.task-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 220px; + overflow-y: auto; + margin-top: 4px; +} +.task-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-soft); +} +.task-name { + font-weight: 600; + flex-shrink: 0; + max-width: 40%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-hint { + font-size: var(--fs-aux); + color: var(--text-3); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.task-badge { + font-size: var(--fs-aux); + color: var(--text-3); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0 4px; + flex-shrink: 0; +} +/* rant 21:36:01:Rant 面板 5 列 + 状态徽标三态配色(已完成绿 / 进行中琥珀 / 待处理灰) */ +.rant-head { + display: flex; + gap: 8px; + padding: 2px 8px 6px; + font-size: var(--fs-aux); + font-weight: 600; + color: var(--text-3); +} +.rant-head .rant-col-time, +.rant-row .rant-col-time { + flex: 0 0 150px; +} +.rant-head .rant-col-project, +.rant-row .rant-col-project { + flex: 0 0 90px; +} +.rant-head .rant-col-status, +.rant-row .rant-col-status { + flex: 0 0 76px; +} +.rant-head .rant-col-progress, +.rant-row .rant-col-progress { + flex: 1.2; + min-width: 0; +} +.rant-head .rant-col-content, +.rant-row .rant-col-content { + flex: 2; + min-width: 0; +} +.rant-head span, +.rant-row .rant-col-time, +.rant-row .rant-col-project, +.rant-row .rant-col-progress, +.rant-row .rant-col-content { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.rant-row .rant-col-time, +.rant-row .rant-col-project, +.rant-row .rant-col-progress, +.rant-row .rant-col-content { + font-size: var(--fs-aux); + color: var(--text-2); +} +.task-badge.badge-done { + color: #16a34a; + border-color: #16a34a; + background: color-mix(in srgb, #16a34a 10%, transparent); +} +.task-badge.badge-warn { + color: #d97706; + border-color: #d97706; + background: color-mix(in srgb, #d97706 10%, transparent); +} +.task-badge.badge-muted { + color: var(--text-3); + border-color: var(--border); + background: transparent; +} +.rant-detail { + padding: 6px 8px; + border-top: 1px solid var(--border); + font-size: var(--fs-secondary); +} +.rant-meta { + font-size: var(--fs-aux); + color: var(--text-3); + margin-bottom: 4px; +} +.rant-md { + margin-top: 4px; +} +.rant-progress { + margin-top: 8px; + padding-top: 6px; + border-top: 1px dashed var(--border); + font-size: var(--fs-aux); + color: var(--text-3); + word-break: break-all; +} +/* rant 10:41:43:rant 详情 markdown —— 【】标题预处理后的 h4 层次 + 嵌套列表缩进可见 */ +.rant-md h4 { + margin: 10px 0 4px; + font-size: 13px; + color: var(--text-1); +} +.rant-md li > ul, +.rant-md li > ol { + margin-left: 1em; +} +.task-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} +.task-empty { + font-size: var(--fs-aux); + color: var(--text-3); + text-align: center; + padding: var(--sp-2); +} + +.task-form { + margin-top: 8px; + padding: var(--sp-3); + border: 1px dashed var(--border); + border-radius: 10px; + background: var(--bg-soft); +} +.task-form select, +.task-form textarea { + width: 100%; + margin-top: 2px; + padding: 6px 10px; + font-size: var(--fs-secondary); +} + +/* rant 09:23:10:任务 running 徽标(常驻运行的任务从源头减少误点) */ +.task-running-badge { + background: var(--bg-soft); + color: var(--text-3); + font-size: 10px; + padding: 1px 6px; + border-radius: 8px; + margin-left: 4px; +} + +/* rant 10:36:39:任务状态徽标(待运行/待调度)+ 下次运行倒计时 */ +.task-pending-badge, +.task-idle-badge { + background: var(--bg-soft); + color: var(--text-3); + font-size: 10px; + padding: 1px 6px; + border-radius: 8px; + margin-left: 4px; +} +.task-pending-badge { + color: var(--accent); + background: var(--accent-soft, var(--bg-soft)); +} +.task-next-run { + font-size: var(--fs-aux); + color: var(--text-3); + flex-shrink: 0; + white-space: nowrap; + font-variant-numeric: tabular-nums; /* 倒计时走秒不抖动 */ +} + +/* rant 2026-08-18T10:45:52:任务行"上次执行"元信息(运行时间/摘要/降频标识) */ +/* rant 2026-08-20T10:34:40:主表一行 —— 去掉 flex-basis:100%(曾强制"上次运行"独占一行), + 改为 flex: 0 1 auto 与 actions 同行;窄窗口下靠自身 flex-wrap 内部合理换行 */ +.task-meta { + flex: 0 1 auto; + display: flex; + align-items: center; + gap: 6px; + font-size: var(--fs-aux); + color: var(--text-3); + min-width: 0; + flex-wrap: wrap; +} +.task-meta-item { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.task-meta-summary { + max-width: 60%; + color: var(--text-2); +} +.task-saturation-badge { + background: var(--warn-soft, var(--bg-soft)); + color: var(--warn, var(--text-3)); + font-size: 10px; + padding: 1px 6px; + border-radius: 8px; + border: 1px solid var(--warn-soft, var(--border)); +} +/* rant 2026-08-18T21:32:32:任务卡点击展开的最近运行子表(时间/干了什么/降频) */ +.task-run-detail { + flex-basis: 100%; + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; + padding-top: 4px; + border-top: 1px dashed var(--border); + min-width: 0; +} +.task-run-detail.hidden { + display: none; +} +.task-run-head, +.task-run-row { + display: grid; + grid-template-columns: 72px 1fr 92px 1fr; + gap: 6px; + font-size: var(--fs-aux); + min-width: 0; + align-items: center; +} +.task-run-head { + color: var(--text-3); + font-weight: 600; +} +.task-run-time { + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.task-run-done { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-2); +} +.task-run-reason { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-3); +} +/* rant 2026-08-20T22:59:16 #3:work/reason cell 多行截断(限制行数 + 省略号), + 点击 cell 在本条记录下方展开完整内容(Markdown 渲染),再点收起 */ +.task-run-cell { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + white-space: normal; + cursor: pointer; + line-height: 1.35; +} +.task-run-cell:hover { + color: var(--accent, var(--text-2)); +} +.task-run-expand { + flex-basis: 100%; + grid-column: 1 / -1; + font-size: var(--fs-aux); + color: var(--text-2); + background: var(--bg-soft, transparent); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 8px; + margin: 2px 0; + overflow-wrap: anywhere; +} +.task-run-expand.hidden { + display: none; +} +.task-run-expand :first-child { + margin-top: 0; +} +.task-run-expand :last-child { + margin-bottom: 0; +} +.task-run-flag { + display: flex; + gap: 4px; + align-items: center; + min-width: 0; +} +.task-run-badge-warn { + background: var(--warn-soft, var(--bg-soft)); + color: var(--warn, var(--text-3)); + border-color: var(--warn-soft, var(--border)); +} +.task-run-badge-idle { + color: var(--text-3); +} +.task-run-empty { + flex-basis: 100%; + font-size: var(--fs-aux); + color: var(--text-3); + padding: 4px 0; +} + +/* rant 09:17:45:提示词编辑器 Monaco 挂载容器(长提示词 300px 起步) */ +.monaco-host { + width: 100%; + min-height: 300px; + margin-top: 2px; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + background: var(--bg-1); +} +.task-form .hidden, +.monaco-host.hidden { + display: none; +} + .theme-options { display: flex; gap: var(--sp-2); @@ -919,6 +1332,30 @@ dialog::backdrop { color: var(--text-1); } +/* ── 通用 toast(rant 2026-08-15T09:20:27:面板操作反馈全局可见) ── */ +.toast { + position: fixed; + top: 16px; + right: 20px; + z-index: 1100; + max-width: calc(100vw - 40px); + padding: 10px 16px; + border-radius: var(--radius-btn); + background: var(--bg-panel); + border: 1px solid var(--border); + box-shadow: var(--shadow-lg, 0 8px 24px rgba(0,0,0,0.18)); + animation: toast-in 0.22s var(--ease); + pointer-events: none; +} +.toast-msg { + font-size: var(--fs-secondary); + color: var(--text-1); + white-space: pre-wrap; +} +.toast-success { border-left: 4px solid var(--ok, #2e9e5b); } +.toast-error { border-left: 4px solid var(--danger, #d64545); } +.toast-info { border-left: 4px solid var(--accent, #4a7dff); } + /* ── 进化完成 toast(WorkBuddy P3)────────────────── */ .evolution-toast { position: fixed; diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css index 6db46a77..24ebd158 100644 --- a/emrg/gui/renderer/css/layout.css +++ b/emrg/gui/renderer/css/layout.css @@ -1,14 +1,15 @@ -/* layout.css — 整体布局:左侧边栏 + 主区(居中单栏聊天 + 输入卡片) +/* layout.css — 整体布局:左侧边栏 + 工作区(#workspace 会话/面板视图 + 输入卡片) * 结构: * #app * #sidebar(品牌区 + 新对话 + 分组列表 + 底部设置/连接圆点) - * #main(#chat-view 滚动区 + #empty-state 欢迎屏 + #composer-wrap 输入卡片) + * #main(#workspace 视图容器 + #empty-state 欢迎屏 + #composer-wrap 输入卡片) */ #app { display: flex; height: 100vh; width: 100vw; + position: relative; /* #result-resizer 绝对定位锚点(P2 框架) */ } /* ── 侧边栏 ─────────────────────────────── */ @@ -35,6 +36,102 @@ font-size: 18px; } +/* 侧边栏导航(rant 14:10:14 P1:5 入口 activity-bar 式) */ +.side-nav { + display: flex; + gap: 2px; + padding: 0 var(--sp-3) var(--sp-2); +} +.side-nav-item { + flex: 1; + min-height: 30px; + border: 1px solid transparent; + border-radius: var(--radius-card); + background: transparent; + color: var(--text-2); + font-size: 15px; + line-height: 1; + cursor: pointer; + transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); +} +.side-nav-item:hover { + background: var(--bg-soft); + color: var(--text-1); +} +.side-nav-item.active { + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); +} +/* 工作区视图(rant 18:55:09 v0.2:面板从侧边栏移入 #workspace,整块工作区显示)—— + * 默认隐藏;.active 时填充整个工作区(宽布局,不再受侧边栏 264px 限制) */ +.workspace-view { + display: none; + flex-direction: column; + height: 100%; + overflow-y: auto; + padding: var(--sp-4); +} +.workspace-view.active { + display: flex; +} +/* 面板视图内容包装(v0.1 .side-panel-body 视觉对等:正文级字号 + 次级文字色) */ +.workspace-view-body { + font-size: var(--fs-secondary); + color: var(--text-3); +} + +/* rant 21:49:51:面板视图标题(设置/Rant/项目/任务面板统一) */ +.workspace-view-title { + font-size: var(--fs-title); + font-weight: 600; + color: var(--text-1); + margin: 0 0 var(--sp-3); +} + +/* rant 14:10:14 P2:设置面板 tab(模型服务/工作目录/GitHub/外观/语言/关于) */ +.panel-tabs { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: var(--sp-3); +} +.panel-tab { + flex: 1; + min-width: 0; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-2); + font-size: var(--fs-secondary); + line-height: 1.2; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease); +} +.panel-tab:hover { + background: var(--bg-soft); + color: var(--text-1); +} +.panel-tab.active { + background: color-mix(in srgb, var(--accent) 14%, transparent); + border-color: color-mix(in srgb, var(--accent) 40%, transparent); + color: var(--accent); +} +.panel-tab-body .settings-group { + margin-bottom: var(--sp-3); +} +.panel-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: var(--sp-3); + padding-top: var(--sp-2); + border-top: 1px solid var(--border); +} + /* 成长状态卡(WorkBuddy P3:自进化可见化) */ .growth-card { margin: 0 var(--sp-3) var(--sp-3); @@ -80,6 +177,29 @@ box-shadow: var(--shadow-md); } +/* B2(rant 21:59:11):"打开会话"入口——新对话按钮下的轻量 ghost 按钮 */ +.open-chat-btn { + margin: calc(-1 * var(--sp-1)) var(--sp-3) var(--sp-3); + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: 8px var(--sp-3); + background: transparent; + color: var(--text-2); + font-size: var(--fs-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-btn); + cursor: pointer; + transition: + background-color var(--dur-fast) var(--ease), + color var(--dur-fast) var(--ease); +} +.open-chat-btn:hover { + background: var(--bg-soft); + color: var(--text-1); +} + #conv-list { flex: 1; overflow-y: auto; @@ -94,6 +214,18 @@ letter-spacing: 0.02em; } +/* P4 slice 2:打开会话区(跨项目 tab)——紧贴新对话按钮,视觉轻量 */ +#open-sessions { + padding-bottom: var(--sp-1); + border-bottom: 1px solid var(--border); + margin-bottom: var(--sp-1); + max-height: 40%; + overflow-y: auto; +} +.open-sessions-label { + padding-top: var(--sp-2); +} + .sidebar-footer { display: flex; align-items: center; @@ -112,29 +244,82 @@ background: var(--bg); } -/* 聊天滚动区:居中单栏最大 760px,左右留白 ≥24px */ -#chat-view { +/* 工作区包装(rant 18:55:09 v0.2 起:#chat-view 更名 #workspace——会话视图 + 面板视图的 + * 公共父容器;每会话一个 .session-view,每面板一个 .workspace-view,display 互斥切换) */ +#workspace { flex: 1; - overflow-y: auto; + position: relative; + overflow: hidden; display: flex; flex-direction: column; - gap: var(--sp-5); +} + +/* 每会话滚动容器:只有激活会话可见,切换 display 保留状态(浏览器 tab 效果) */ +.session-view { + position: absolute; + inset: 0; + overflow-y: auto; + display: none; + flex-direction: column; + gap: var(--sp-3); padding: var(--sp-5) var(--sp-5) var(--sp-4); - scroll-behavior: smooth; + /* rant 21:28:49:去掉 smooth——JS scrollTop=scrollHeight 也走平滑动画,工具行快速 + 连续追加时滚不到底 → 最新内容不可见。JS 跳转全部即时;用户手动滚动不受影响。 */ } -#chat-view > * { +.session-view.active { + display: flex; +} +/* 会话标题栏:项目/名称(id) 或 项目/id,滚动置顶、全宽贴边(负 margin 抵消 .session-view padding) */ +.session-header { + position: sticky; + top: 0; + z-index: 5; + flex-shrink: 0; + margin: calc(-1 * var(--sp-5)) calc(-1 * var(--sp-5)) var(--sp-3) !important; + padding: var(--sp-3) var(--sp-5); + width: auto !important; + max-width: none !important; + font-size: var(--fs-secondary); + color: var(--text-2); + background: var(--bg); + border-bottom: 1px solid var(--border); +} +/* P3 finalize:会话连接断开标记(P4 每会话容器可见;激活会话另走全局横幅) */ +.session-view.disconnected { + opacity: 0.55; +} +.session-view.disconnected::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient(45deg, transparent 0 8px, rgba(128, 128, 128, 0.08) 8px 16px); +} +#workspace > *:not(.session-view):not(.workspace-view), +.session-view > * { width: 100%; max-width: 760px; margin-left: auto; margin-right: auto; } +/* 工具执行全宽左对齐(宿主 2026-08-20 17:50:30):消息正文保持 760px 阅读宽度, + 工具行/组/输出不受限,铺满整个聊天窗口左侧。 */ +.session-view > .tool-row, +.session-view > .tool-group, +.session-view > .tool-group .tool-group-rows, +.session-view > .tool-output, +.session-view > .tool-expand-btn { + max-width: none; + margin-left: 0; + margin-right: 0; +} -/* 输入区 */ +/* 输入区(宿主 2026-08-20 17:50:30:状态栏/输入框 100% 全宽,不再 808px 居中) */ #composer-wrap { position: relative; /* / 指令补全菜单锚点(rant 19:44 P1) */ padding: var(--sp-2) var(--sp-5) var(--sp-4); - max-width: 808px; - margin: 0 auto; + max-width: none; + margin: 0; width: 100%; } @@ -218,6 +403,29 @@ } #github-banner .btn { padding: 4px 12px; min-height: 0; font-size: var(--fs-secondary); } +/* ── 升级完成横幅(rant 2026-08-20T18:30:57:版本变化 → 提示 + 一键重启) ── */ +#upgrade-banner { + position: absolute; + top: var(--sp-3); + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: var(--sp-2); + background: var(--accent-soft); + color: var(--accent); + border: 1px solid var(--border); + border-radius: 999px; + padding: 6px var(--sp-3) 6px var(--sp-4); + font-size: var(--fs-secondary); + box-shadow: var(--shadow-md); + z-index: 20; + animation: banner-in var(--dur-med) var(--ease); + white-space: nowrap; + max-width: 90%; +} +#upgrade-banner .btn { padding: 4px 12px; min-height: 0; font-size: var(--fs-secondary); } + /* ── 侧边栏折叠(⌘B / 窄屏) ────────────── */ body.sidebar-collapsed #sidebar { display: none; @@ -231,7 +439,8 @@ body.sidebar-collapsed #app { #sidebar { display: none; } - #chat-view { + /* #workspace 现为绝对定位包装(padding 移入 .session-view,避免滚动条内缩) */ + .session-view { padding: var(--sp-4) var(--sp-4) var(--sp-3); } #composer-wrap { @@ -251,13 +460,33 @@ body.sidebar-collapsed #app { transition: width 0.2s cubic-bezier(.2,.8,.3,1), min-width 0.2s cubic-bezier(.2,.8,.3,1); } #result-panel.collapsed { - width: 0; - min-width: 0; - border-left: none; + /* 方案 A(rant 2026-08-10T14:11:18):折叠成窄条而非 0,header(含 toggle 按钮)保持可见可点 */ + width: 40px; + min-width: 40px; + border-left: 1px solid var(--border); +} +/* P2 框架:拖拽调整宽度期间抑制 width transition(R1-①:transition 与 mousemove 冲突) */ +#result-panel.dragging { + transition: none; } -#result-panel.collapsed .result-header, #result-panel.collapsed .result-list { - display: none; + display: none; /* 只藏内容区 */ +} +#result-panel.collapsed .result-pane { + display: none; /* 折叠时所有 pane 隐藏(含激活中的文件 pane) */ +} +#result-panel.collapsed .result-header { + display: flex; + justify-content: center; + padding: var(--sp-3) 0; +} +#result-panel.collapsed .result-title, +#result-panel.collapsed .result-tabs, +#result-panel.collapsed .result-tabbar { + display: none; /* 窄条只留 toggle 按钮 */ +} +#result-panel.collapsed .result-toggle { + transform: rotate(180deg); /* » → « 指示可展开 */ } .result-header { @@ -287,12 +516,317 @@ body.sidebar-collapsed #app { background: var(--bg-soft); } -.result-list { +/* ── P2 框架:Tab 栏(文件 / 产物 + 打开文件 Tab) ── */ +.result-tabs { + display: flex; + align-items: center; + gap: var(--sp-1); + overflow-x: auto; + scrollbar-width: none; +} +.result-tabs::-webkit-scrollbar { display: none; } +.result-tab { + background: none; + border: none; + padding: var(--sp-1) var(--sp-3); + font-size: var(--fs-secondary); + color: var(--text-3); + cursor: pointer; + border-radius: 6px; + white-space: nowrap; +} +.result-tab:hover { + color: var(--text-1); + background: var(--bg-soft); +} +.result-tab.active { + color: var(--text-1); + background: var(--bg-soft); + font-weight: 600; +} +/* 打开文件 Tab 条(P3 查看器接入;rant 17:28 并入 .result-tabs 同排,每文件一个可关闭 Tab) */ +.result-tabbar { + display: none; + flex-direction: row; + align-items: center; + gap: var(--sp-1); + overflow-x: auto; + scrollbar-width: none; +} +.result-tabbar.has-tabs { display: inline-flex; } +.result-tabbar::-webkit-scrollbar { display: none; } +.result-filetab { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + padding: 2px var(--sp-2); + font-size: var(--fs-small); + color: var(--text-2); + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + white-space: nowrap; + max-width: 160px; + overflow: hidden; + height: 22px; /* 与静态 tab 同高(同排并列) */ +} +.result-filetab.active { + color: var(--text-1); + border-color: var(--accent); +} +.filetab-close { + color: var(--text-3); + font-size: 12px; + line-height: 1; + padding: 0 2px; + border-radius: 4px; +} +.filetab-close:hover { + color: var(--text-1); + background: var(--bg-hover); +} + +/* ── P2 框架:内容 pane 切换(激活 pane 显示) ── */ +.result-pane { display: none; } +.result-pane.active { display: flex; flex-direction: column; } + +/* ── 宽度拖拽手柄 ── */ +#result-resizer { + position: absolute; + top: 0; + bottom: 0; + width: 6px; + right: 277px; /* JS 随宽度更新(right = panelWidth - 3) */ + cursor: col-resize; + z-index: 40; +} +#result-resizer:hover { + background: var(--accent); + opacity: 0.35; +} +#result-resizer.dragging { + background: var(--accent); + opacity: 0.5; +} + +/* ── P3.1:文件浏览器(懒加载目录树,VS Code 风格 rant 2026-08-12T17:28:19) ── */ +.result-files { + /* flex:1 + min-height:0(rant 2026-08-13T12:46:12):flex 子项默认 flex:0 1 auto 高度随内容 + 增长永不压缩进容器 → overflow-y:auto 永不触发(有滚动条槽但滚不动);与 .result-list 对齐 */ flex: 1; + min-height: 0; overflow-y: auto; + /* 滚动条 hover 显示(VS Code 行为:平时隐藏,移入文件树区域显示) */ + scrollbar-width: thin; + scrollbar-gutter: stable; +} +.result-files::-webkit-scrollbar { width: 8px; } +.result-files::-webkit-scrollbar-thumb { background: transparent; border-radius: 4px; } +.result-files:hover::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); } +.result-files::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); } + +.ft-row { + display: block; /* 块级排布:.ft-head 行 + .ft-kids 子树纵向排列,行高自适应 + (修复布局 bug:定高 flex-wrap 使下一兄弟行与展开目录首个子项 + 重叠,headless Chrome 像素实证——兄弟行遮挡子项) */ + flex-shrink: 0; /* rant 20:58:57:.ft-row 是 .result-files(flex column)的 flex + item,默认 shrink:1 + overflow:hidden → min-height:auto 解析为 0, + flex 把整棵树压缩到容器高度,scrollHeight==clientHeight 无法滚动 */ + cursor: pointer; + font-size: var(--fs-small); + color: var(--text-2); + white-space: nowrap; + overflow: hidden; +} +.ft-head { + display: flex; + align-items: center; + height: 24px; /* VS Code 紧凑行高(默认 22px,取 24px 适配 14px 图标) */ + border-radius: 4px; +} +.ft-row:hover > .ft-head { + background: var(--bg-soft); + color: var(--text-1); +} +/* 选中态:背景 + 左侧 2px accent 竖条(VS Code active 行) */ +.ft-row.active > .ft-head { + background: var(--bg-soft); + color: var(--text-1); + box-shadow: inset 2px 0 0 var(--accent); +} +.ft-dir { + color: var(--text-1); + font-weight: 500; +} +.ft-icon { + flex: none; + width: 14px; + height: 14px; + margin-right: 4px; + color: currentColor; /* mono:图标颜色跟随文字色 */ + fill: currentColor; +} +.ft-icon path:not([fill]) { fill: currentColor; } +/* 展开/收起 chevron(VS Code 风格 ▸/▾,rant 2026-08-13T20:58:57) */ +.ft-chevron { + flex: none; + width: 14px; + height: 14px; + margin-right: 2px; + color: currentColor; + fill: currentColor; +} +.ft-chevron path:not([fill]) { fill: currentColor; } +.ft-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; /* 超长名省略号 */ + white-space: nowrap; +} +.ft-kids { + position: relative; /* 正常流布局:位于 .ft-head 之下(不再作为 flex 行溢出) */ +} +.ft-kids.hidden { + display: none; +} +/* 缩进引导线:嵌套结构天然对齐父行图标列(left:2px ≈ 父行图标起点) */ +.ft-kids::before { + content: ""; + position: absolute; + left: 2px; + top: 0; + bottom: 0; + border-left: 1px solid var(--border); +} +.ft-hint { + padding: var(--sp-2) var(--sp-3); + color: var(--text-3); + font-size: var(--fs-small); +} + +/* ── P3.3:文件查看器(基础版) ── */ +.result-viewer { + overflow: hidden; +} +.viewer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + border-bottom: 1px solid var(--border); + flex: none; +} +.viewer-path { + font-size: var(--fs-small); + color: var(--text-2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.viewer-open { + flex: none; + background: none; + border: 1px solid var(--border); + color: var(--text-2); + font-size: var(--fs-small); + padding: 2px var(--sp-2); + border-radius: 6px; + cursor: pointer; +} +.viewer-open:hover { + color: var(--text-1); + background: var(--bg-soft); +} +.viewer-pre { + flex: 1; + overflow: auto; + margin: 0; padding: var(--sp-3); + font-size: var(--fs-small); + line-height: 1.5; + white-space: pre; + color: var(--text-1); +} +/* P3.3:查看器图片直显 + md 渲染 */ +.viewer-img { + flex: 1; + object-fit: contain; + max-width: 100%; + padding: var(--sp-3); + box-sizing: border-box; +} +.viewer-md { + flex: 1; + overflow: auto; + padding: var(--sp-3); + font-size: var(--fs-secondary); + line-height: 1.6; + color: var(--text-1); +} +.viewer-md h1, .viewer-md h2, .viewer-md h3 { margin: var(--sp-2) 0; } +.viewer-md code { + background: var(--bg-soft); + border-radius: 4px; + padding: 1px 4px; +} +.viewer-md pre code { + display: block; + padding: var(--sp-3); + overflow-x: auto; +} +/* P3.4:HTML 预览占位(混合模型——WebContentsView 叠加其上;折叠/隐藏时占位可见) */ +.viewer-html { + flex: 1; display: flex; - flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--sp-3); + box-sizing: border-box; +} +.viewer-html .result-empty { + max-width: 100%; +} + +/* ── P3.2:产物文件行(write/edit 成功文件,点击打开查看器 Tab) ── */ +.artifact-row { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + cursor: pointer; + overflow: hidden; +} +.artifact-row:hover { + border-color: var(--accent); + background: var(--bg-soft); +} +.artifact-name { + font-size: var(--fs-secondary); + font-weight: 600; + color: var(--text-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: none; + max-width: 55%; +} +.artifact-rel { + font-size: var(--fs-small); + color: var(--text-3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.result-list { + flex: 1; + overflow-y: auto; + padding: var(--sp-3); gap: var(--sp-2); } .result-empty { diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html index e69ff749..9877985f 100644 --- a/emrg/gui/renderer/index.html +++ b/emrg/gui/renderer/index.html @@ -2,7 +2,7 @@ - + EMRG @@ -10,6 +10,7 @@ +

@@ -24,11 +25,22 @@
🌱 已自我进化 0
边工作边学习,越用越懂你
+ + - + + + + + @@ -44,8 +56,221 @@ - -
+ + + + +
+
+
+ +

项目管理

+
+
+ +
+
+
+
+
+ +

任务管理

+
管理定时任务与自定义任务类型(内置类型只读)
+
+
+ + +
+ + + + + + +
+
+
+
+ +

Rant 管理

+
+ + + + +
+
+ 时间 + 项目 + 状态 + 进度 + 内容 +
+
+
+ +
+ + +
+
+
+ +

设置

+
+ + + + + +
+
+
+ + +
可用模型
+
+ + + +
点左侧圆点可设为默认模型,切换后下一条消息即生效
+
+
+ + + + +
+ + +
+
+
@@ -67,9 +292,10 @@ 加载中… -
- - +
+ + +
@@ -80,105 +306,30 @@
- + + +
- - -
-

设置

-
-
模型服务
- - -
可用模型
-
- - - -
点左侧圆点可设为默认模型,切换后下一条消息即生效
-
-
-
工作目录
- -
-
-
GitHub 连接
-
-
- - - -
-
用于自进化推送 PR;授权后自动执行 gh auth setup-git,git 操作不再弹 GCM
-
-
-
外观
- -
-
-
语言
- -
-
-
关于
-
-
EMRG v0.2.8 · 🌱 已自我进化 0
-
EMRG 是一个会自我进化的 AI 智能体——每次改进都会自动汇报,你可以随时在这里看到它的成长。
-
-
- -
-
最近改进
-
-
-
- - -
-
-
-
@@ -195,13 +346,6 @@

欢迎使用 EMRG

-
@@ -237,9 +381,23 @@

重命名对话

+ + +
@@ -253,13 +411,29 @@

/ 指令帮助

- -
-

切换对话

-

点击切换,或输入 /resume <id> 直接切换。

-
+ + +
+

打开会话

+

选择项目后选择要打开的会话(跨项目多开)。

+
+
+ + + +
+
+
+ + + +
+

新建会话

+

选择项目新建会话(跨项目多开)。

+
- + +
@@ -316,34 +490,10 @@

连接 GitHub

- -
-

🧬 进化 — 告诉 EMRG 往哪里走

-

你的输入会驱动 EMRG 的自我进化——它会认真读,并据此改进自己。

- - -
- - -
-
-
- - - -
-

后台任务

-

点击任务立即触发一次运行。

-
-
- -
-
-
+ +