diff --git a/Agent.md b/Agent.md
index 12c4196c..60c634f0 100644
--- a/Agent.md
+++ b/Agent.md
@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```
-Python: `uv run pytest tests/ -v` (982) — import check: `uv run python -c "from emrg.client.app import run_client"`
+Python: `uv run pytest tests/ -v` (983) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 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 路径不受影响)
diff --git a/emrg/gui/main.js b/emrg/gui/main.js
index bcc56d37..cd5c8245 100644
--- a/emrg/gui/main.js
+++ b/emrg/gui/main.js
@@ -279,6 +279,7 @@ vision = false
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:安装版本(GUI 对比显示升级横幅)
version: APP_VERSION, // WorkBuddy P3:版本号随 package.json 走(此前 renderer 硬编码 v0.2.7)
sessions,
open_sessions: openSessionsList(),
@@ -315,6 +316,20 @@ vision = false
ipcMain.handle("emrg:listSessions", async () => listSessions());
+ // Rant 2026-08-20T18:30:57:一键"重启生效"——发 shutdown(source=gui-restart)让
+ // daemon 停;connManager 检测到全部掉线 → restart-recovery → ensureDaemon 用新
+ // 安装代码重新 spawn(GUI 本就是 daemon 生命周期 owner,不发子进程调 CLI)。
+ ipcMain.handle("emrg:restartDaemon", async () => {
+ const conn = activeConn();
+ if (!conn || !conn.connected) throw new Error("daemon not connected");
+ try {
+ conn.sendCommand("shutdown", { source: "gui-restart" });
+ } catch (e) {
+ throw new Error(`shutdown failed: ${e.message}`);
+ }
+ return { ok: true };
+ });
+
ipcMain.handle("emrg:switchSession", async (_e, { sessionId, projectPath } = {}) => {
if (!validateSessionId(sessionId)) throw new Error("invalid session_id");
// P6(rant 15:07:19 边界):projectPath 校验(跨项目打开时传项目路径)
@@ -851,7 +866,7 @@ vision = false
const sessions = await listSessions();
sendToRenderer("sessions", { sessions });
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 || "" });
logger.info("[gui] connManager recovery complete");
} catch (e) {
logger.warn(`[gui] post-recovery refresh failed: ${e.message}`);
@@ -1076,7 +1091,7 @@ vision = false
const sessions = await listSessions();
sendToRenderer("sessions", { sessions });
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 || "" });
}
}, delay);
}
diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js
index 2e72bf9b..c09cd058 100644
--- a/emrg/gui/preload.js
+++ b/emrg/gui/preload.js
@@ -10,6 +10,7 @@ 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: (payload) => ipcRenderer.invoke("emrg:newSession", payload),
deleteSession: (payload) => ipcRenderer.invoke("emrg:deleteSession", payload),
diff --git a/emrg/gui/renderer/css/layout.css b/emrg/gui/renderer/css/layout.css
index 407d79d3..e0f97b4f 100644
--- a/emrg/gui/renderer/css/layout.css
+++ b/emrg/gui/renderer/css/layout.css
@@ -392,6 +392,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;
diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html
index d15ff8f8..d58f10fb 100644
--- a/emrg/gui/renderer/index.html
+++ b/emrg/gui/renderer/index.html
@@ -57,6 +57,13 @@
+
+
+
+
+
+
+
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js
index 53cd7ad2..ba843810 100644
--- a/emrg/gui/renderer/js/app.js
+++ b/emrg/gui/renderer/js/app.js
@@ -76,6 +76,8 @@ const App = (() => {
state.serverId = init.server_id || "";
state.model = init.model || "";
state.version = init.version || "";
+ state.currentVersion = init.current_version || ""; // rant 18:30:57:安装版本(升级横幅对比基准)
+ state.lastKnownVersion = state.currentVersion;
state.evolutionCount = init.evolution_count ?? null;
state.lastKnownEvolutionCount = state.evolutionCount;
updateConnectionDot(init.config_exists && init.api_key_configured ? "green" : "gray");
@@ -1335,6 +1337,39 @@ const App = (() => {
$("conn-banner").classList.add("hidden");
}
+ // ── 升级完成横幅(rant 2026-08-20T18:30:57) ─────────────
+ function maybeShowUpgradeBanner(currentVersion) {
+ if (!currentVersion) return; // 无版本数据(dev 运行)→ 不显示
+ if (currentVersion === state.lastKnownVersion) return; // 版本未变
+ const b = $("upgrade-banner");
+ if (!b) return;
+ const msg = $("upgrade-banner-msg");
+ if (msg) msg.textContent = _t("app.upgradeBannerMsg", { version: currentVersion });
+ b.classList.remove("hidden");
+ state.lastKnownVersion = currentVersion; // 已提示,防重复弹
+ }
+ function hideUpgradeBanner() {
+ const b = $("upgrade-banner");
+ if (b) b.classList.add("hidden");
+ }
+ function initUpgradeBanner() {
+ const restart = $("upgrade-banner-restart");
+ if (restart) restart.addEventListener("click", async () => {
+ restart.disabled = true;
+ try {
+ await window.emrg.restartDaemon();
+ // shutdown 已发——connManager restart-recovery 会重新 spawn 新 daemon 并重连;
+ // 恢复后 status 事件里 current_version 已 = 新版本 → 横幅消失。
+ hideUpgradeBanner();
+ } catch (e) {
+ restart.disabled = false;
+ Chat.addSystemMessage(_t("app.upgradeRestartFailed", { msg: e.message }));
+ }
+ });
+ const dismiss = $("upgrade-banner-dismiss");
+ if (dismiss) dismiss.addEventListener("click", hideUpgradeBanner);
+ }
+
// ── 事件处理(main 已分类) ─────────────
async function handleEvent(evt) {
const { type, data } = evt;
@@ -1540,6 +1575,8 @@ const App = (() => {
if (data.server_id) state.serverId = data.server_id;
if (data.model) state.model = data.model;
state.evolutionCount = data.evolution_count ?? state.evolutionCount;
+ // rant 18:30:57:pong 携带 current_version → 对比已知版本,变化则弹升级横幅
+ maybeShowUpgradeBanner(data.current_version || state.currentVersion);
updateModelSwitcher();
updateGrowthCard();
maybeShowEvolutionToast();
@@ -1746,6 +1783,7 @@ const App = (() => {
Dialogs.initTaskManagement(); // rant 18:23:15 P3:定时任务/自定义类型管理
Dialogs.initRantPanel(); // rant 14:10:14 P4:rant 面板(筛选/新建)
initGithubBanner(); // Windows GCM rant Stage 2:演化需 GitHub 但未认证时的连接横幅
+ initUpgradeBanner(); // rant 18:30:57:升级完成横幅(重启生效按钮)
initModelSwitcher();
initModeSwitcher(); // WorkBuddy P2:Ask/Auto 工作模式
ResultPanel.init(); // WorkBuddy P1:结果面板(⌘\ 折叠 + 窄屏自动隐藏)
diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js
index ef60ac08..960c81a0 100644
--- a/emrg/gui/renderer/js/i18n.js
+++ b/emrg/gui/renderer/js/i18n.js
@@ -416,6 +416,9 @@ const I18N = (() => {
"app.modelSwitchFailed": "切换模型失败了:{msg}",
"app.modelListFailed": "读取模型列表失败了:{msg}",
"app.askModeNotice": "Ask 模式:我只对话,不执行工具。输入内容问我就好。",
+ "app.upgradeBannerMsg": "EMRG 已升级到 {version},重启后生效",
+ "app.upgradeRestartFailed": "重启失败:{msg}",
+ "app.upgradeRestartBtn": "重启生效",
"app.unknownResult": "结果未知——连接中断",
"app.error": "出了点问题:{msg}",
"app.unknownError": "未知错误",
@@ -826,6 +829,9 @@ const I18N = (() => {
"app.modelSwitchFailed": "Failed to switch model: {msg}",
"app.modelListFailed": "Failed to load models: {msg}",
"app.askModeNotice": "Ask mode: I only chat — no tools. Just ask.",
+ "app.upgradeBannerMsg": "EMRG upgraded to {version} — restart to apply",
+ "app.upgradeRestartFailed": "Restart failed: {msg}",
+ "app.upgradeRestartBtn": "Restart to apply",
"app.unknownResult": "Result unknown — connection lost",
"app.error": "Something went wrong: {msg}",
"app.unknownError": "Unknown error",
diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js
index c5403ae3..c004c0b4 100644
--- a/emrg/gui/test/renderer.smoke.test.js
+++ b/emrg/gui/test/renderer.smoke.test.js
@@ -148,6 +148,7 @@ const ELEMENT_IDS = [
"result-tabs", "result-tab-files", "result-tab-artifacts", "result-tabbar", "result-files", "result-viewer", "result-resizer",
"growth-card", "growth-count", "about-recent",
"github-banner", "github-banner-msg", "github-banner-connect", "github-banner-dismiss",
+ "upgrade-banner", "upgrade-banner-msg", "upgrade-banner-restart", "upgrade-banner-dismiss",
"toast", "toast-msg",
// rant 18:23:15 P3:定时任务/自定义类型管理(settings 区)
"task-list", "task-add-btn", "task-template-mgr-btn",
@@ -733,6 +734,58 @@ test("GCM rant Stage 2:演化增长 + 未认证 → GitHub 连接横幅出现
assert.ok(toastBlock.includes("maybeShowGithubBanner()"), "演化增长应触发 GitHub 横幅检查");
});
+test("rant 18:30:57:版本变化 → 升级横幅出现 + 重启按钮触发 restartDaemon(正反两态)", async () => {
+ // 正态:status current_version 与已知版本不同 → 横幅出现
+ const { ctx } = makeSandbox({
+ init: async () => ({
+ config_exists: true,
+ api_key_configured: true,
+ current_version: "0.2.58",
+ sessions: [],
+ }),
+ });
+ await tick();
+ await vm.runInContext(`(function() {
+ document.getElementById("upgrade-banner").classList.add("hidden");
+ App.state.lastKnownVersion = "0.2.58";
+ App.handleEvent({ type: "status", data: { connected: true, current_version: "0.2.59" } });
+ })()`, ctx);
+ const visible = vm.runInContext(
+ '!document.getElementById("upgrade-banner").classList.contains("hidden")',
+ ctx
+ );
+ assert.strictEqual(visible, true, "版本变化 → 升级横幅应出现");
+
+ // 负态:版本未变 → 横幅保持隐藏
+ const { ctx: ctx2 } = makeSandbox({});
+ await tick();
+ await vm.runInContext(`(function() {
+ document.getElementById("upgrade-banner").classList.add("hidden");
+ App.state.lastKnownVersion = "0.2.58";
+ App.handleEvent({ type: "status", data: { connected: true, current_version: "0.2.58" } });
+ })()`, ctx2);
+ const hidden = vm.runInContext(
+ 'document.getElementById("upgrade-banner").classList.contains("hidden")',
+ ctx2
+ );
+ assert.strictEqual(hidden, true, "版本未变 → 横幅应保持隐藏");
+
+ // 重启按钮 → restartDaemon 调用
+ let restarted = false;
+ const { ctx: ctx3 } = makeSandbox({});
+ await tick();
+ await vm.runInContext(`(async function() {
+ App._testRestartDaemon = () => { window.__restartCalled = true; };
+ })()`, ctx3);
+ const appSrc = fs.readFileSync(path.join(RENDERER_JS, "app.js"), "utf8");
+ assert.ok(appSrc.includes("restartDaemon"), "重启按钮应调用 window.emrg.restartDaemon");
+ const GUI_DIR = path.join(__dirname, "..");
+ const mainSrc = fs.readFileSync(path.join(GUI_DIR, "main.js"), "utf8");
+ assert.ok(mainSrc.includes("emrg:restartDaemon"), "main.js 应注册 emrg:restartDaemon IPC");
+ const preloadSrc = fs.readFileSync(path.join(GUI_DIR, "preload.js"), "utf8");
+ assert.ok(preloadSrc.includes("restartDaemon"), "preload 应暴露 restartDaemon");
+});
+
test("右键菜单:重命名对话框 → renameSession 调用(设计 §3.2)", async () => {
let renamed = null;
const { ctx, els } = makeSandbox({
diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py
index 426581fe..b1855063 100644
--- a/emrg/server/daemon.py
+++ b/emrg/server/daemon.py
@@ -632,6 +632,19 @@ async def _run_upgrade_session(self, session_id: str, cwd: str, prompt: str) ->
logger.debug("upgrade session failed (retry next tick)", exc_info=True)
self._session_busy[session_id] = False
+ def _current_installed_version(self) -> str:
+ """Current installed EMRG version from ~/.emrg/install/version.txt.
+
+ Rant 2026-08-20T18:30:57: raw data only, zero judgment — the GUI
+ compares it with its last known version and shows the upgrade banner.
+ Returns "" when the file is missing (dev/standalone runs).
+ """
+ try:
+ v = (Path.home() / ".emrg" / "install" / "version.txt").read_text(encoding="utf-8").strip()
+ return v
+ except (OSError, ValueError):
+ return ""
+
def _evolution_count(self) -> int:
"""Total completed evolution cycles across scheduler handlers + disk.
@@ -1397,6 +1410,10 @@ async def _process_message(
"started_at": self.start_time.isoformat(),
"pid": os.getpid(),
"model": self.llm.config.model,
+ # Rant 2026-08-20T18:30:57:并入当前安装版本——GUI 轮询 pong 时对比
+ # 上次已知版本,发现变化 → 弹"已升级,重启生效"横幅。daemon 只回原始数据,
+ # 零判断逻辑(升级判断由 GUI 负责)。
+ "current_version": self._current_installed_version(),
})
return
diff --git a/tests/test_daemon.py b/tests/test_daemon.py
index d4adff51..0c8a2508 100644
--- a/tests/test_daemon.py
+++ b/tests/test_daemon.py
@@ -1719,3 +1719,35 @@ def test_shutdown_message_missing_source_degrades(tmp_path, caplog):
assert "shutdown requested by client (peer=unknown peer, source=unknown)" in caplog.text
assert server._stop_reason == "shutdown_msg"
+
+
+def test_pong_includes_current_version(tmp_path, monkeypatch):
+ """Pong carries current_version from ~/.emrg/install/version.txt.
+
+ Rant 2026-08-20T18:30:57: the GUI compares the version it last saw with
+ the daemon's installed version and shows the upgrade banner on change.
+ Missing file → empty string (dev runs), never an error.
+ """
+ import emrg.server.daemon as daemon_mod
+ install = tmp_path / ".emrg" / "install"
+ install.mkdir(parents=True)
+ (install / "version.txt").write_text("0.2.59\n", encoding="utf-8")
+
+ monkeypatch.setattr(daemon_mod.Path, "home", lambda: tmp_path)
+
+ server = _make_server()
+ assert server._current_installed_version() == "0.2.59"
+
+ # Missing file → "" (no crash)
+ empty = tmp_path / "no-install"
+ monkeypatch.setattr(daemon_mod.Path, "home", lambda: empty)
+ assert server._current_installed_version() == ""
+
+ # Pong payload includes the field
+ monkeypatch.setattr(daemon_mod.Path, "home", lambda: tmp_path)
+ writer = _FakeWriter()
+ import asyncio
+ asyncio.run(server._process_message({"type": "ping"}, writer))
+ frame = _last_frame(writer)
+ assert frame["type"] == "pong"
+ assert frame["current_version"] == "0.2.59"