diff --git a/Agent.md b/Agent.md
index 037bbdf..3d7e93b 100644
--- a/Agent.md
+++ b/Agent.md
@@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request
- 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` (183: 43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
+ - Unit tests `npm test` (187: 43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state); 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)
@@ -112,8 +112,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` (687) — import check: `uv run python -c "from emrg.client.app import run_client"`
-GUI: `cd emrg/gui && npm test` (183: 43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
+Python: `uv run pytest tests/ -v` (688) — import check: `uv run python -c "from emrg.client.app import run_client"`
+GUI: `cd emrg/gui && npm test` (187: 43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state) — 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 路径不受影响)
diff --git a/README.cn.md b/README.cn.md
index 4ac1845..0aebf4e 100644
--- a/README.cn.md
+++ b/README.cn.md
@@ -144,7 +144,7 @@ EMRG 不只是追赶——它自己追上来。
贡献指南、源码安装、架构、详细 FAQ → [DEVELOPMENT.md](DEVELOPMENT.md)。
-快速检查:`uv run pytest tests/ -v`(当前 687 项)· `cd emrg/gui && npm test`(183 项:43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state)
+快速检查:`uv run pytest tests/ -v`(当前 688 项)· `cd emrg/gui && npm test`(187 项:43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state)
---
diff --git a/README.md b/README.md
index 6f21ba0..ba4877f 100644
--- a/README.md
+++ b/README.md
@@ -144,7 +144,7 @@ They're products. EMRG is an experiment in *closing the loop* — the AI improve
Contributing, source installs, architecture, and the full FAQ → [DEVELOPMENT.md](DEVELOPMENT.md).
-Quick checks: `uv run pytest tests/ -v` (currently 687 items) · `cd emrg/gui && npm test` (183: 43 daemon_client + 19 conn-manager + 22 app-commands + 63 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state)
+Quick checks: `uv run pytest tests/ -v` (currently 688 items) · `cd emrg/gui && npm test` (187: 43 daemon_client + 19 conn-manager + 22 app-commands + 67 renderer smoke + 15 i18n + 7 integration + 3 commands + 4 build-config + 7 gui-state)
---
diff --git a/emrg/gui/main.js b/emrg/gui/main.js
index 676e3b1..3a44a07 100644
--- a/emrg/gui/main.js
+++ b/emrg/gui/main.js
@@ -559,11 +559,17 @@ vision = false
return { authenticated: Boolean(frame.authenticated), user: frame.user || null };
});
- ipcMain.handle("emrg:updateCheck", async () => {
+ ipcMain.handle("emrg:updateCheck", async (_e, { force } = {}) => {
// Auto update-check prompt (rant 2026-08-10T07:12:12): query daemon's
// cached latest release; display-only, no auto download/install.
+ // force=true (rant 2026-08-11T09:18:16): settings manual check button
+ // — daemon runs a fresh GitHub fetch instead of returning the cache.
try {
- const frame = await requireConn().sendCommandAndWait("update_check", {}, 10000);
+ const frame = await requireConn().sendCommandAndWait(
+ "update_check",
+ { force: Boolean(force) },
+ 10000,
+ );
return {
current_version: frame.current_version || "",
latest_version: frame.latest_version || "",
diff --git a/emrg/gui/preload.js b/emrg/gui/preload.js
index 62b34a9..fd5d9a9 100644
--- a/emrg/gui/preload.js
+++ b/emrg/gui/preload.js
@@ -33,7 +33,7 @@ const api = {
sendRant: (payload) => ipcRenderer.invoke("emrg:sendRant", payload),
evolutionSummary: (payload) => ipcRenderer.invoke("emrg:evolutionSummary", payload),
githubStatus: () => ipcRenderer.invoke("emrg:githubStatus"),
- updateCheck: () => ipcRenderer.invoke("emrg:updateCheck"),
+ updateCheck: (payload) => ipcRenderer.invoke("emrg:updateCheck", payload),
updateCheckPrompted: (payload) => ipcRenderer.invoke("emrg:updateCheckPrompted", payload),
githubConnect: (payload) => ipcRenderer.invoke("emrg:githubConnect", payload),
githubDisconnect: () => ipcRenderer.invoke("emrg:githubDisconnect"),
diff --git a/emrg/gui/renderer/index.html b/emrg/gui/renderer/index.html
index 12aa42f..7c0c886 100644
--- a/emrg/gui/renderer/index.html
+++ b/emrg/gui/renderer/index.html
@@ -170,6 +170,9 @@
设置
EMRG v0.2.8 · 🌱 已自我进化 0 次
+
+
+
EMRG 是一个会自我进化的 AI 智能体——每次改进都会自动汇报,你可以随时在这里看到它的成长。
diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js
index acfc2ec..209194f 100644
--- a/emrg/gui/renderer/js/app.js
+++ b/emrg/gui/renderer/js/app.js
@@ -119,6 +119,11 @@ const App = (() => {
// 修复:boot 成功路径必须启用输入框(此前仅 done/cancelled/disconnected/error
// 回调会调用 setComposerDisabled(false),形成"需先发消息才能启用输入框"死锁)
setComposerDisabled(false);
+ // rant 2026-08-11T09:18:16:启动主动更新提示——不开设置也能看到新版本
+ // (非阻塞系统消息,一次幂等;daemon 未就绪时静默失败)
+ if (window.EMRG_Dialogs?.promptUpdateAtStartup) {
+ Dialogs.promptUpdateAtStartup();
+ }
} catch (e) {
Chat.addSystemMessage(_t("app.bootFailed", { msg: e.message }));
}
diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js
index 834ab4a..46b5265 100644
--- a/emrg/gui/renderer/js/dialogs.js
+++ b/emrg/gui/renderer/js/dialogs.js
@@ -234,6 +234,8 @@ const Dialogs = (() => {
await refreshGithubStatus();
// Auto update-check prompt (rant 2026-08-10T07:12:12): about area shows
// a one-time non-intrusive line when a newer release exists.
+ // rant 2026-08-11T09:18:16:手动"检查更新"按钮(force 重新检查)
+ initUpdateCheckButton();
await refreshUpdateCheck();
} catch (e) {
Chat.addSystemMessage(_t("settings.readFailed", { msg: e.message }));
@@ -490,19 +492,23 @@ const Dialogs = (() => {
// Auto update-check prompt (rant 2026-08-10T07:12:12): display-only, one
// line in the about area, never a modal — no auto download/install.
- async function refreshUpdateCheck() {
- const el = $("about-update");
- if (!el) return; // 元素缺失(测试桩)时忽略
+ // force (rant 2026-08-11T09:18:16): settings manual check button — run a
+ // fresh GitHub fetch instead of returning the daemon's cached result.
+ // ⚠️ 局部变量命名 updEl(勿用 el——会遮蔽模块级 el() 元素工厂,el("a",…)
+ // 抛 TypeError → catch 吞掉 → 更新行永远 hidden,正是 #602 隐藏缺陷)
+ async function refreshUpdateCheck({ force = false } = {}) {
+ const updEl = $("about-update");
+ if (!updEl) return; // 元素缺失(测试桩)时忽略
try {
- const u = await window.emrg.updateCheck();
+ const u = await window.emrg.updateCheck({ force });
if (!u || !u.enabled || !u.has_update || !u.latest_version) {
- el.classList.add("hidden");
- el.textContent = "";
+ updEl.classList.add("hidden");
+ updEl.textContent = "";
return;
}
if (u.latest_version === u.prompted_version) {
- el.classList.add("hidden");
- el.textContent = "";
+ updEl.classList.add("hidden");
+ updEl.textContent = "";
return;
}
const link = el("a", {
@@ -510,17 +516,51 @@ const Dialogs = (() => {
target: "_blank",
rel: "noopener",
}, _t("settings.updateAvailable", { latest: u.latest_version }));
- el.textContent = "";
- el.appendChild(link);
- el.classList.remove("hidden");
+ updEl.textContent = "";
+ updEl.appendChild(link);
+ updEl.classList.remove("hidden");
// 幂等:同版本只提示一次
try { await window.emrg.updateCheckPrompted({ version: u.latest_version }); } catch { /* ignore */ }
} catch {
- el.classList.add("hidden");
- el.textContent = "";
+ updEl.classList.add("hidden");
+ updEl.textContent = "";
}
}
+ // 设置页"检查更新"手动按钮(rant 2026-08-11T09:18:16):点击立即强制
+ // 重新检查(不等 TTL 轮询),检查中显示"检查中…",完成后恢复按钮。
+ function initUpdateCheckButton() {
+ const btn = $("about-update-check-btn");
+ if (!btn) return; // 元素缺失(测试桩)时忽略
+ btn.addEventListener("click", async () => {
+ const original = btn.textContent;
+ btn.disabled = true;
+ btn.textContent = _t("settings.checkingUpdate");
+ try {
+ await refreshUpdateCheck({ force: true });
+ } finally {
+ btn.disabled = false;
+ btn.textContent = original;
+ }
+ });
+ }
+
+ // 启动主动更新提示(rant 2026-08-11T09:18:16):GUI 启动成功路径调用,
+ // 不依赖打开设置对话框——有新版本且未提示过时输出一条非阻塞系统消息
+ // (对齐 TUI 启动 system 行)。boot 时 daemon 可能未就绪 → 静默失败。
+ async function promptUpdateAtStartup() {
+ try {
+ const u = await window.emrg.updateCheck({ force: false });
+ if (!u || !u.enabled || !u.has_update || !u.latest_version) return;
+ if (u.latest_version === u.prompted_version) return;
+ Chat.addSystemMessage(
+ _t("app.updateAvailable", { latest: u.latest_version }),
+ );
+ // 幂等:同版本只提示一次
+ try { await window.emrg.updateCheckPrompted({ version: u.latest_version }); } catch { /* ignore */ }
+ } catch { /* boot-time daemon may not be ready — silent */ }
+ }
+
// ── 确认对话框(替代 confirm/alert) ────
let confirmCb = null;
function showConfirm(title, message, opts = {}) {
@@ -730,6 +770,9 @@ const Dialogs = (() => {
initGithubSection,
initDeviceDialog,
refreshGithubStatus,
+ refreshUpdateCheck,
+ initUpdateCheckButton,
+ promptUpdateAtStartup,
initOpenSessionDialog, // P5:打开会话对话框初始化
showOpenSessionDialog, // P5:两步打开会话
initNewSessionDialog, // P5 slice 2:新建会话对话框初始化
diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js
index 43c57a7..c90905b 100644
--- a/emrg/gui/renderer/js/i18n.js
+++ b/emrg/gui/renderer/js/i18n.js
@@ -92,6 +92,9 @@ const I18N = (() => {
"settings.githubNotConnected": "未连接",
"settings.githubStatusFailed": "状态获取失败",
"settings.updateAvailable": "发现新版本 v{latest} —— 点击前往 Releases 下载(不会自动安装)",
+ "settings.checkUpdate": "检查更新",
+ "settings.checkingUpdate": "检查中…",
+ "app.updateAvailable": "发现新版本 v{latest} —— 点击前往 Releases 下载(不会自动安装):https://github.com/argszero/emrg/releases",
"settings.githubTokenEmpty": "请先粘贴 GitHub Personal Access Token",
"settings.githubConnecting": "连接中…",
"settings.githubConnected": "已连接 GitHub:@{user}(gh auth setup-git 已执行)",
@@ -419,6 +422,9 @@ const I18N = (() => {
"settings.githubNotConnected": "Not connected",
"settings.githubStatusFailed": "Failed to load status",
"settings.updateAvailable": "New version v{latest} available — click to visit Releases (no auto-install)",
+ "settings.checkUpdate": "Check for updates",
+ "settings.checkingUpdate": "Checking…",
+ "app.updateAvailable": "New version v{latest} available — https://github.com/argszero/emrg/releases (no auto-install)",
"settings.githubTokenEmpty": "Please paste a GitHub Personal Access Token first",
"settings.githubConnecting": "Connecting…",
"settings.githubConnected": "Connected to GitHub: @{user} (gh auth setup-git done)",
diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js
index b089a6a..3f1d54c 100644
--- a/emrg/gui/test/renderer.smoke.test.js
+++ b/emrg/gui/test/renderer.smoke.test.js
@@ -126,6 +126,7 @@ const ELEMENT_IDS = [
"tasks-dialog", "tasks-list", "tasks-close",
"result-panel", "result-list", "result-toggle",
"growth-card", "growth-count", "about-recent",
+ "about-update", "about-update-check-btn",
"github-banner", "github-banner-msg", "github-banner-connect", "github-banner-dismiss",
];
@@ -1602,3 +1603,87 @@ test("B3: 发送消息清除该会话草稿;新会话从空草稿开始", asyn
const newDraft = await vm.runInContext("App.state.drafts.get('s-new')", ctx);
assert.strictEqual(newDraft, "", "new session starts with empty draft");
});
+
+test("rant 09:18:启动主动更新提示——有新版本且未提示过 → 系统消息一次", async () => {
+ const { ctx } = makeSandbox({
+ updateCheck: async () => ({ enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "", current_version: "0.2.23" }),
+ updateCheckPrompted: async () => ({}),
+ });
+ await tick();
+ const r = await vm.runInContext(`(async function() {
+ let prompted = null;
+ window.emrg.updateCheckPrompted = async (p) => { prompted = p; };
+ await EMRG_Dialogs.promptUpdateAtStartup();
+ const texts = [];
+ for (let i = 0; i < $("chat-view").children.length; i++) texts.push($("chat-view").children[i].textContent);
+ return { n: texts.length, joined: texts.join("|"), prompted: JSON.stringify(prompted) };
+ })()`, ctx);
+ assert.ok(r.n >= 1, "应输出一条系统消息");
+ assert.ok(r.joined.includes("0.2.99"), `系统消息应含新版本号,实际 ${r.joined}`);
+ assert.ok(r.prompted.includes("0.2.99"), "应记录已提示版本(幂等)");
+});
+
+test("rant 09:18:启动提示幂等——已提示过/未启用/无更新 → 不提示", async () => {
+ const { ctx } = makeSandbox({
+ updateCheck: async () => ({ enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "0.2.99", current_version: "0.2.23" }),
+ });
+ await tick();
+ const r = await vm.runInContext(`(async function() {
+ await EMRG_Dialogs.promptUpdateAtStartup();
+ return { n: $("chat-view").children.length };
+ })()`, ctx);
+ assert.strictEqual(r.n, 0, "已提示过同版本 → 不得重复提示");
+});
+
+test("rant 09:18:设置页手动检查按钮——点击强制重新检查并显示检查中", async () => {
+ const { ctx } = makeSandbox({
+ updateCheck: async (payload) => ({ enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "", current_version: "0.2.23", _force: payload && payload.force }),
+ updateCheckPrompted: async () => ({}),
+ });
+ await tick();
+ const r = await vm.runInContext(`(async function() {
+ const calls = [];
+ window.emrg.updateCheck = async (payload) => {
+ calls.push(payload || {});
+ return { enabled: true, has_update: true, latest_version: "0.2.99", prompted_version: "" };
+ };
+ const btn = $("about-update-check-btn");
+ btn.textContent = "检查更新"; // 模拟 index.html 静态文案(沙箱不加载 HTML)
+ EMRG_Dialogs.initUpdateCheckButton();
+ btn.click();
+ const during = { text: btn.textContent, disabled: btn.disabled };
+ await new Promise((res) => setTimeout(res, 20));
+ return {
+ calls: JSON.stringify(calls),
+ duringText: during.text,
+ afterText: btn.textContent,
+ afterDisabled: btn.disabled,
+ updateShown: !$("about-update").classList.contains("hidden"),
+ };
+ })()`, ctx);
+ const calls = JSON.parse(r.calls);
+ assert.strictEqual(calls.length, 1, "点击按钮应触发一次 updateCheck");
+ assert.strictEqual(calls[0].force, true, "手动检查必须带 force:true(跳过 TTL 缓存)");
+ assert.strictEqual(r.duringText, "检查中…", "检查中按钮应显示“检查中…”");
+ assert.strictEqual(r.afterText, "检查更新", "完成后按钮文案恢复");
+ assert.strictEqual(r.afterDisabled, false, "完成后按钮恢复可用");
+ assert.strictEqual(r.updateShown, true, "有新版本应显示 about-update 行");
+});
+
+test("rant 09:18:refreshUpdateCheck 透传 force 参数", async () => {
+ const { ctx } = makeSandbox({
+ updateCheck: async (payload) => ({ enabled: false, has_update: false, latest_version: "", prompted_version: "" }),
+ });
+ await tick();
+ const r = await vm.runInContext(`(async function() {
+ const calls = [];
+ window.emrg.updateCheck = async (payload) => { calls.push(payload || {}); return { enabled: false, has_update: false, latest_version: "", prompted_version: "" }; };
+ await EMRG_Dialogs.refreshUpdateCheck({ force: true });
+ await EMRG_Dialogs.refreshUpdateCheck();
+ return JSON.stringify(calls);
+ })()`, ctx);
+ const calls = JSON.parse(r);
+ assert.strictEqual(calls.length, 2);
+ assert.strictEqual(calls[0].force, true, "refreshUpdateCheck({force:true}) 应透传 force");
+ assert.strictEqual(calls[1].force, false, "默认 refreshUpdateCheck() 不带 force");
+});
diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py
index b973fb2..1042727 100644
--- a/emrg/server/daemon.py
+++ b/emrg/server/daemon.py
@@ -1416,10 +1416,22 @@ async def _process_message(
# latest_version (populated at startup + every TTL) plus a
# has_update flag computed against the running version. No auto
# download/install; the client shows a one-time prompt.
+ # force:true (rant 2026-08-11T09:18:16) — GUI manual check
+ # button: run a fresh GitHub fetch first instead of the cache.
import emrg
from emrg.config import load_update_config
- from emrg.update_check import is_newer, load_state, parse_version
+ from emrg.update_check import (
+ is_newer,
+ load_state,
+ parse_version,
+ run_update_check_once,
+ )
+ if msg.get("force"):
+ try:
+ await run_update_check_once()
+ except Exception:
+ logger.debug("forced update check failed", exc_info=True)
current = getattr(emrg, "__version__", "0")
state = load_state()
latest = state.get("latest_version") or ""
diff --git a/tests/test_daemon.py b/tests/test_daemon.py
index 592b99f..99a2efd 100644
--- a/tests/test_daemon.py
+++ b/tests/test_daemon.py
@@ -1328,3 +1328,43 @@ def test_list_projects_ordered_by_latest_session_activity(tmp_path, monkeypatch)
assert new_proj["latest_session_at"] == "2026-08-10T12:00:00"
none_proj = next(p for p in reply["projects"] if p["name"] == "nosess")
assert none_proj["latest_session_at"] == ""
+
+
+def test_update_check_force_runs_fresh_check(monkeypatch):
+ """update_check with force:true runs a fresh fetch; without force it
+ returns the cached state (rant 2026-08-11T09:18:16 manual check button)."""
+ import asyncio
+
+ server = _make_server()
+ writer = _FakeWriter()
+ calls = []
+
+ async def fake_run_once(state=None):
+ calls.append(state)
+ return {"checked": True, "latest_version": "9.9.9", "state": {}}
+
+ monkeypatch.setattr("emrg.update_check.run_update_check_once", fake_run_once)
+ monkeypatch.setattr(
+ "emrg.update_check.load_state",
+ lambda: {"latest_version": "9.9.9", "prompted_version": ""},
+ )
+ monkeypatch.setattr(
+ "emrg.update_check.is_newer",
+ lambda latest, current: latest != current,
+ )
+
+ # force:true → fresh check runs, reply carries the refreshed latest
+ asyncio.run(server._process_message({"type": "update_check", "force": True}, writer))
+ assert len(calls) == 1, "force:true must trigger one fresh check"
+ reply = json.loads(writer._frames[-1])
+ assert reply["type"] == "update_check"
+ assert reply["latest_version"] == "9.9.9"
+ assert reply["has_update"] is True
+
+ # no force → cached path, no fresh check
+ writer._frames.clear()
+ calls.clear()
+ asyncio.run(server._process_message({"type": "update_check"}, writer))
+ assert calls == [], "no force → must return cache without a fresh fetch"
+ reply = json.loads(writer._frames[-1])
+ assert reply["type"] == "update_check"