Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation,
- 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` (96: 22 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- Unit tests `npm test` (98: 24 daemon_client + 22 app-commands + 27 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)
Expand All @@ -93,8 +93,8 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (641) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (96: 22 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
Python: `uv run pytest tests/ -v` (647) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (98: 24 daemon_client + 22 app-commands + 27 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 路径不受影响)

Expand Down
4 changes: 2 additions & 2 deletions README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,15 +274,15 @@ EMRG 不只是追赶——它自己追上来。
git clone https://github.com/argszero/emrg.git
cd emrg
uv sync # 安装依赖
uv run pytest tests/ -v # 跑测试(当前 641 项)
uv run pytest tests/ -v # 跑测试(当前 647 项)
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 测试(96 项:22 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration)
npm test # 运行 Node 测试(98 项:24 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration)
```

CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,15 +273,15 @@ EMRG doesn't just keep up — it catches up on its own.
git clone https://github.com/argszero/emrg.git
cd emrg
uv sync # install deps
uv run pytest tests/ -v # run tests (currently 641 items)
uv run pytest tests/ -v # run tests (currently 647 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 (96: 22 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration)
npm test # run Node tests (98: 24 daemon_client + 22 app-commands + 27 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`).
Expand Down
6 changes: 6 additions & 0 deletions emrg/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from pathlib import Path

from emrg import __version__
from emrg._win import win32_no_window_kwargs
from emrg.connect import cleanup_server, connect_to_server
from websockets.exceptions import ConnectionClosed

Expand Down Expand Up @@ -126,6 +127,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

Expand Down Expand Up @@ -341,6 +345,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)
Expand All @@ -357,6 +362,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)
Expand Down
39 changes: 39 additions & 0 deletions emrg/_win.py
Original file line number Diff line number Diff line change
@@ -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 {}
18 changes: 14 additions & 4 deletions emrg/client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -39,6 +40,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 (
Expand All @@ -55,7 +57,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:
Expand All @@ -66,6 +69,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:
Expand All @@ -78,7 +82,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
Expand All @@ -95,6 +100,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
Expand All @@ -108,7 +114,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:
Expand Down Expand Up @@ -139,6 +146,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
Expand All @@ -149,7 +157,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

Expand All @@ -164,6 +173,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
Expand Down
6 changes: 5 additions & 1 deletion emrg/client/daemon_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from pathlib import Path
from typing import AsyncIterator

from emrg._win import win32_no_window_kwargs
from emrg.connect import (
AuthError,
cleanup_server,
Expand Down Expand Up @@ -76,7 +77,10 @@ async def start_daemon() -> subprocess.Popen:
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():
Expand Down
32 changes: 30 additions & 2 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,16 @@ const WebSocket = require("ws");
// 真实的 ~/.emrg/emrgd.port → 演化周期 10 小时连不上 daemon(WinError 1225)。
// 所有调用点必须传 this.projectDir(默认 os.homedir() 保持生产行为不变)。
const PORT_FILE = (projectDir = os.homedir()) => path.join(projectDir, ".emrg", "emrgd.port");
const EMRGD_LOG = (projectDir = os.homedir()) => path.join(projectDir, ".emrg", "emrgd.log");
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}$/;

Expand Down Expand Up @@ -72,6 +77,7 @@ class DaemonClient {
this._authFailed = false;
this._reconnectTimer = null;
this._stopReconnect = false;
this._spawnAttempts = 0; // 连接生命周期内 spawn 计数(成功 auth 后归零)
}

// ── 生命周期 ────────────────────────────────────────────
Expand All @@ -91,7 +97,28 @@ class DaemonClient {
}
}

_readLogTail(lines = 15) {
// R124 对应(daemon_manager.py):spawn 超时后读 emrgd.log 尾部,
// 让宿主看到真实失败原因(缺 DLL / PATH / 端口冲突),而不是干巴巴的
// "failed to start within timeout"(rant 2026-08-09T13:16:36 验收项 ②)。
try {
const data = fs.readFileSync(EMRGD_LOG(this.projectDir), "utf8");
const tail = data.trim().split("\n").slice(-lines).join("\n");
return tail ? `\n emrgd.log tail:\n${tail}` : "";
} catch {
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) {
Expand All @@ -116,7 +143,7 @@ class DaemonClient {
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();
Expand All @@ -138,7 +165,7 @@ class DaemonClient {
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()}`);
}

_findDaemonExecutable() {
Expand Down Expand Up @@ -240,6 +267,7 @@ class DaemonClient {

this.connected = true;
this._authFailed = false;
this._spawnAttempts = 0; // 连接生命周期成功 → 重置 spawn 节流计数

// 5. 注册 message/close 监听 → 事件流分发
this.ws.on("message", (data) => this._onFrame(data));
Expand Down
19 changes: 19 additions & 0 deletions emrg/gui/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ function main() {
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;

// ── 窗口 ────────────────────────────────────────────────
Expand Down Expand Up @@ -646,20 +654,31 @@ vision = false
await client.ensureConnected();
logger.info("[gui] connected to emrgd");
cancelReconnect();
reconnectDelayMs = 1000; // 退避复位
daemonStoppedNotified = false; // 节流提示复位(下个生命周期可再提示)
sendToRenderer("status", { connected: true });
} catch (e) {
if (client._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();
}
}

function scheduleReconnect() {
if (stopping || reconnectTimer) return;
const delay = reconnectDelayMs;
reconnectDelayMs = Math.min(reconnectDelayMs * 2, MAX_RECONNECT_DELAY_MS); // 指数退避
reconnectTimer = setTimeout(async () => {
reconnectTimer = null;
sendToRenderer("status", { connected: false, reconnecting: true });
Expand Down
5 changes: 5 additions & 0 deletions emrg/gui/renderer/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,11 @@ const App = (() => {
} else if (data.auth_failed) {
updateConnectionDot("red");
Chat.addSystemMessage(_t("app.authFailed"));
} else if (data.daemon_stopped) {
// Rant 2026-08-09T13:16:36 ⑤:spawn 节流命中——显示真实失败原因(含
// emrgd.log 尾部),提示宿主手动启动,不再无限重试弹窗。
updateConnectionDot("red");
Chat.addSystemMessage(_t("app.daemonStopped", { msg: data.error || "" }));
} else {
updateConnectionDot("red");
}
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ const I18N = (() => {
"app.unknownError": "未知错误",
"app.unknown": "未知",
"app.authFailed": "认证失败了,请检查设置里的 API Key。",
"app.daemonStopped": "daemon 启动失败(已停止自动重试)。请在终端运行 `emrg server` 排查;\n{msg}",
"app.versionInfo": "EMRG GUI v{ver} · 实例 {id} · 模型 {model} · 已进化 {n} 次",
},

Expand Down Expand Up @@ -592,6 +593,7 @@ const I18N = (() => {
"app.unknownError": "Unknown error",
"app.unknown": "unknown",
"app.authFailed": "Authentication failed — check your API Key in Settings.",
"app.daemonStopped": "daemon failed to start (auto-retry stopped). Run `emrg server` in a terminal to debug;\n{msg}",
"app.versionInfo": "EMRG GUI v{ver} · Instance {id} · Model {model} · Evolved {n} times",
},
};
Expand Down
Loading
Loading